From 2ed904fd0031ba2ab1a2228944687078a4458649 Mon Sep 17 00:00:00 2001 From: autodev-bot Date: Sun, 30 Aug 2026 05:29:29 +0800 Subject: [PATCH] fix(llm): propagate opts.op through facade to providers (#2308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `LlmClient.buildCallInput()` dropped `opts.op` when constructing the `ProviderCallInput` object handed to `provider.complete()` / `stream()`. Callers set `op` for every logical call site (e.g. `capture.summarize`, `retrieval.filter`, `skill.evolve`) but only the audit / metrics paths inside the facade saw the tag — providers never did. As a result any per-op provider behavior (request-body tweaks, routing overrides, reasoning kill-switches keyed on `opts.op === "capture.summarize"`) silently could never fire. The issue-reported symptom: OpenRouter DeepSeek reasoning models burn full token budget and double latency on `capture.summarize` because the recommended `thinking: { type: "disabled" }` switch is unreachable. Fix: - extend `ProviderCallInput` with optional `op?: string` - copy `opts?.op` in `buildCallInput()` Field stays optional so providers must not assume it is set. No public LlmClient surface change; consumers keep calling `complete` / `completeJson` / `stream` unchanged. Added 4 unit tests in `tests/unit/llm/client.test.ts` under an "op propagation (issue #2308)" describe block covering complete / completeJson / stream forwarding and the "no op supplied" case. --- apps/memos-local-plugin/core/llm/client.ts | 1 + apps/memos-local-plugin/core/llm/types.ts | 8 ++++ .../tests/unit/llm/client.test.ts | 45 +++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/apps/memos-local-plugin/core/llm/client.ts b/apps/memos-local-plugin/core/llm/client.ts index ee456ac12..a94d05556 100644 --- a/apps/memos-local-plugin/core/llm/client.ts +++ b/apps/memos-local-plugin/core/llm/client.ts @@ -282,6 +282,7 @@ export function createLlmClientWithProvider( maxTokens: opts?.maxTokens ?? config.maxTokens ?? DEFAULT_MAX_TOKENS, jsonMode, stop: opts?.stop, + op: opts?.op, }; } diff --git a/apps/memos-local-plugin/core/llm/types.ts b/apps/memos-local-plugin/core/llm/types.ts index 2dd761f32..ef61c439f 100644 --- a/apps/memos-local-plugin/core/llm/types.ts +++ b/apps/memos-local-plugin/core/llm/types.ts @@ -257,6 +257,14 @@ export interface ProviderCallInput { maxTokens: number; jsonMode: boolean; stop?: string[]; + /** + * Logical call site (e.g. `capture.summarize`, `retrieval.filter`, + * `skill.evolve`). Forwarded from `LlmCallOptions.op` so providers + * can apply per-op behavior (request-body tweaks, routing overrides, + * reasoning kill-switches, per-op budget caps). Optional — providers + * must not assume it is set. + */ + op?: string; } /** What providers return — pre-facade post-processing. */ diff --git a/apps/memos-local-plugin/tests/unit/llm/client.test.ts b/apps/memos-local-plugin/tests/unit/llm/client.test.ts index cd125ecd8..084ec5d6f 100644 --- a/apps/memos-local-plugin/tests/unit/llm/client.test.ts +++ b/apps/memos-local-plugin/tests/unit/llm/client.test.ts @@ -322,6 +322,51 @@ describe("llm/client", () => { ); }); + // ─── op propagation to providers (issue #2308) ──────────────────────── + // + // The facade must forward `opts.op` onto the `ProviderCallInput` handed to + // `provider.complete()` / `provider.stream()` so per-op provider behavior + // (e.g. request-body tweaks, routing overrides, reasoning kill-switches + // keyed on `capture.summarize`) can fire. Dropping it silently makes + // those switches unreachable. + describe("op propagation (issue #2308)", () => { + it("complete forwards opts.op onto the provider input", async () => { + const fake = new FakeProvider("openai_compatible", () => ({ text: "ok", durationMs: 1 })); + const client = createLlmClientWithProvider(cfg(), fake); + await client.complete("hi", { op: "capture.summarize" }); + expect(fake.lastInput?.op).toBe("capture.summarize"); + }); + + it("completeJson forwards opts.op onto the provider input", async () => { + const fake = new FakeProvider("openai_compatible", () => ({ + text: '{"a":1}', + durationMs: 1, + })); + const client = createLlmClientWithProvider(cfg(), fake); + await client.completeJson<{ a: number }>("score", { op: "retrieval.filter" }); + expect(fake.lastInput?.op).toBe("retrieval.filter"); + }); + + it("stream forwards opts.op onto the provider input (non-streaming provider)", async () => { + // FakeProvider has no stream(); the facade wraps complete() in a + // single-chunk iterable, which still exercises buildCallInput. + const fake = new FakeProvider("openai_compatible", () => ({ text: "one", durationMs: 1 })); + const client = createLlmClientWithProvider(cfg(), fake); + const parts: string[] = []; + for await (const c of client.stream("x", { op: "skill.evolve" })) { + if (!c.done) parts.push(c.delta); + } + expect(fake.lastInput?.op).toBe("skill.evolve"); + }); + + it("omits op field when caller supplies no op (contract stays optional)", async () => { + const fake = new FakeProvider("openai_compatible", () => ({ text: "ok", durationMs: 1 })); + const client = createLlmClientWithProvider(cfg(), fake); + await client.complete("hi"); + expect(fake.lastInput?.op).toBeUndefined(); + }); + }); + // ─── Circuit breaker (issue #1897) ────────────────────────────────────── describe("circuit breaker", () => { function statusSink(): { rows: LlmStatusDetail[]; push: (d: LlmStatusDetail) => void } {