diff --git a/apps/memos-local-plugin/core/config/defaults.ts b/apps/memos-local-plugin/core/config/defaults.ts index 6f06210d0..a043b4cd4 100644 --- a/apps/memos-local-plugin/core/config/defaults.ts +++ b/apps/memos-local-plugin/core/config/defaults.ts @@ -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. @@ -73,6 +75,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { providerIgnore: [], providerOrder: [], openRouter: false, + maxTokens: 4096, }, skillEvolver: { // Empty by default โ€” falls back to the shared `llm` settings. @@ -87,6 +90,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { providerIgnore: [], providerOrder: [], openRouter: false, + maxTokens: 4096, }, storage: { ftsTokenizer: "trigram", diff --git a/apps/memos-local-plugin/core/config/index.ts b/apps/memos-local-plugin/core/config/index.ts index 6d529d960..64370f5ad 100644 --- a/apps/memos-local-plugin/core/config/index.ts +++ b/apps/memos-local-plugin/core/config/index.ts @@ -165,6 +165,13 @@ function pruneUnknown( continue; } if (isPlainObject(v) && isPlainObject((defaults as Record)[k])) { + if (Object.keys((defaults as Record)[k] as Record).length === 0) { + // Empty-object default slot = free-form map (e.g. llm.headers, a + // Record). 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)[k], path, warnings); } else { out[k] = v; diff --git a/apps/memos-local-plugin/core/config/schema.ts b/apps/memos-local-plugin/core/config/schema.ts index 8566f90f3..ff9c1f319 100644 --- a/apps/memos-local-plugin/core/config/schema.ts +++ b/apps/memos-local-plugin/core/config/schema.ts @@ -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, 16, 131072), + /** Extra HTTP headers for the provider request. */ + headers: Type.Optional(Type.Record(Type.String(), Type.String(), { default: {} })), }, { default: {} }); /** @@ -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, 16, 131072), + /** Extra HTTP headers for the provider request. */ + headers: Type.Optional(Type.Record(Type.String(), Type.String(), { default: {} })), }, { default: {} }); const StorageSchema = Type.Object({ diff --git a/apps/memos-local-plugin/core/pipeline/memory-core.ts b/apps/memos-local-plugin/core/pipeline/memory-core.ts index 01402a86b..76e65d700 100644 --- a/apps/memos-local-plugin/core/pipeline/memory-core.ts +++ b/apps/memos-local-plugin/core/pipeline/memory-core.ts @@ -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; }; export interface BootstrapOptions { @@ -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 @@ -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 }) => diff --git a/apps/memos-local-plugin/tests/unit/config/llm-max-tokens-headers.test.ts b/apps/memos-local-plugin/tests/unit/config/llm-max-tokens-headers.test.ts new file mode 100644 index 000000000..40e929a78 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/config/llm-max-tokens-headers.test.ts @@ -0,0 +1,78 @@ +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 (shared SkillEvolverSchema)", () => { + const cfg = resolveConfig({ + skillEvolver: { headers: { "X-Evolver": "v1" } }, + l3Llm: { headers: { "X-L3": "v2" } }, + }); + expect(cfg.skillEvolver.headers).toEqual({ "X-Evolver": "v1" }); + expect(cfg.l3Llm.headers).toEqual({ "X-L3": "v2" }); + expect(cfg.l3Llm.maxTokens).toBe(4096); + }); + + it("rejects out-of-range maxTokens with config_invalid", () => { + expect(() => resolveConfig({ llm: { maxTokens: 8 } })).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); + }); +});