Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/memos-local-plugin/core/llm/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ export function createLlmClientWithProvider(
maxTokens: opts?.maxTokens ?? config.maxTokens ?? DEFAULT_MAX_TOKENS,
jsonMode,
stop: opts?.stop,
op: opts?.op,
};
}

Expand Down
8 changes: 8 additions & 0 deletions apps/memos-local-plugin/core/llm/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
45 changes: 45 additions & 0 deletions apps/memos-local-plugin/tests/unit/llm/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } {
Expand Down
Loading