From 15b40119f09956118cbe1d7f79aab2f647cff330 Mon Sep 17 00:00:00 2001 From: amandeavor Date: Sun, 30 Aug 2026 18:19:56 +0530 Subject: [PATCH 1/2] fix: forward cleanup provider parameters --- src/services/user-profile/ai-cleanup.ts | 28 +++++++++++++++++-------- tests/ai-cleanup.test.ts | 21 ++++++++++++++++++- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/src/services/user-profile/ai-cleanup.ts b/src/services/user-profile/ai-cleanup.ts index 527b229e..d81dc556 100644 --- a/src/services/user-profile/ai-cleanup.ts +++ b/src/services/user-profile/ai-cleanup.ts @@ -6,6 +6,7 @@ import { EXTERNAL_PROFILE_CLEANUP_TIMEOUT_MS, OPENCODE_PROFILE_CLEANUP_TIMEOUT_MS, } from "../request-timeouts.js"; +import { applySafeExtraParams } from "../ai/providers/base-provider.js"; export interface AICleanupResult { cleaned: UserProfileData; @@ -242,21 +243,30 @@ async function callViaExternalAPI( const systemPrompt = "You are a user profile cleanup assistant. Merge duplicate entries and return only JSON."; + const requestBody: Record = {}; + if (CONFIG.memoryExtraParams) { + applySafeExtraParams(requestBody, CONFIG.memoryExtraParams); + } + + // Cleanup relies on these fields for deterministic JSON output. Assign them after optional + // provider parameters so callers cannot replace cleanup semantics through extra params. + Object.assign(requestBody, { + model: CONFIG.memoryModel, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: prompt }, + ], + temperature: 0.3, + response_format: { type: "json_object" }, + }); + const response = await fetch(`${CONFIG.memoryApiUrl}/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${CONFIG.memoryApiKey}`, }, - body: JSON.stringify({ - model: CONFIG.memoryModel, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: prompt }, - ], - temperature: 0.3, - response_format: { type: "json_object" }, - }), + body: JSON.stringify(requestBody), signal: AbortSignal.timeout(EXTERNAL_PROFILE_CLEANUP_TIMEOUT_MS), }); diff --git a/tests/ai-cleanup.test.ts b/tests/ai-cleanup.test.ts index 5a990baa..a1da7309 100644 --- a/tests/ai-cleanup.test.ts +++ b/tests/ai-cleanup.test.ts @@ -88,6 +88,7 @@ import { mock } from "bun:test"; const promptCalls = []; const deleteCalls = []; let externalFetchCalled = false; +let externalRequestBody = null; const cleanupJson = JSON.stringify({ preferences: [{ id: "pref_0", category: "style", description: "Prefer concise answers" }], @@ -107,6 +108,14 @@ mock.module(${JSON.stringify(configUrl)}, () => ({ memoryModel: ${withExternalApi ? '"gpt-ext"' : "undefined"}, memoryApiUrl: ${withExternalApi ? '"http://example.test/v1"' : "undefined"}, memoryApiKey: "test-key", + memoryExtraParams: { + enable_thinking: false, + top_p: 0.7, + model: "must-not-override", + messages: [{ role: "user", content: "must-not-override" }], + temperature: 0.9, + response_format: { type: "text" }, + }, }, })); @@ -141,8 +150,9 @@ mock.module(${JSON.stringify(opencodeProviderLoaderUrl)}, () => ({ })); if (${withExternalApi}) { - globalThis.fetch = async () => { + globalThis.fetch = async (_url, init) => { externalFetchCalled = true; + externalRequestBody = JSON.parse(String(init?.body)); return { ok: true, status: 200, @@ -178,6 +188,7 @@ console.log( promptCalls, deleteCalls, externalFetchCalled, + externalRequestBody, kept: result?.diff?.kept ?? null, removed: result?.diff?.removed?.map((r) => r.id) ?? null, noReply: promptCalls[0]?.noReply, @@ -243,5 +254,13 @@ describe("AI cleanup opencode provider path (#177)", () => { expect(result.parsed?.errorMessage).toBeNull(); expect(result.parsed?.externalFetchCalled).toBe(true); expect(result.parsed?.kept).toEqual(["Prefer concise answers"]); + expect(result.parsed?.externalRequestBody).toMatchObject({ + model: "gpt-ext", + temperature: 0.3, + response_format: { type: "json_object" }, + enable_thinking: false, + top_p: 0.7, + }); + expect(result.parsed?.externalRequestBody?.messages).toHaveLength(2); }); }); From 40eb8bfb0b007d6f9c9681e5960127ef34e1d95e Mon Sep 17 00:00:00 2001 From: amandeavor Date: Mon, 31 Aug 2026 09:40:15 +0530 Subject: [PATCH 2/2] fix: protect stream parameter in safe extra params --- src/services/ai/providers/base-provider.ts | 1 + tests/ai-cleanup.test.ts | 2 + tests/ai-provider-config.test.ts | 43 ++++++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/src/services/ai/providers/base-provider.ts b/src/services/ai/providers/base-provider.ts index 0a76e687..3d30a533 100644 --- a/src/services/ai/providers/base-provider.ts +++ b/src/services/ai/providers/base-provider.ts @@ -25,6 +25,7 @@ const PROTECTED_KEYS = new Set([ "input", "instructions", "conversation", + "stream", ]); export function applySafeExtraParams( diff --git a/tests/ai-cleanup.test.ts b/tests/ai-cleanup.test.ts index a1da7309..fbaa5a62 100644 --- a/tests/ai-cleanup.test.ts +++ b/tests/ai-cleanup.test.ts @@ -111,6 +111,7 @@ mock.module(${JSON.stringify(configUrl)}, () => ({ memoryExtraParams: { enable_thinking: false, top_p: 0.7, + stream: true, model: "must-not-override", messages: [{ role: "user", content: "must-not-override" }], temperature: 0.9, @@ -261,6 +262,7 @@ describe("AI cleanup opencode provider path (#177)", () => { enable_thinking: false, top_p: 0.7, }); + expect(result.parsed?.externalRequestBody?.stream).toBeUndefined(); expect(result.parsed?.externalRequestBody?.messages).toHaveLength(2); }); }); diff --git a/tests/ai-provider-config.test.ts b/tests/ai-provider-config.test.ts index 32f664dc..6b553104 100644 --- a/tests/ai-provider-config.test.ts +++ b/tests/ai-provider-config.test.ts @@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildMemoryProviderConfig } from "../src/services/ai/provider-config.js"; +import { applySafeExtraParams } from "../src/services/ai/providers/base-provider.js"; import { OpenAIChatCompletionProvider } from "../src/services/ai/providers/openai-chat-completion.js"; import { OpenAIResponsesProvider } from "../src/services/ai/providers/openai-responses.js"; import type { ChatCompletionTool } from "../src/services/ai/tools/tool-schema.js"; @@ -292,4 +293,46 @@ describe("AI provider config", () => { expect(capturedBody).toBeDefined(); expect(capturedBody?.temperature).toBeUndefined(); }); + + describe("applySafeExtraParams", () => { + it("copies allowable extra parameters to request body", () => { + const body: Record = { model: "gpt-5-nano" }; + applySafeExtraParams(body, { + top_p: 0.8, + enable_thinking: false, + custom_header: "val", + }); + + expect(body).toEqual({ + model: "gpt-5-nano", + top_p: 0.8, + enable_thinking: false, + custom_header: "val", + }); + }); + + it("blocks protected keys from overriding core request structure", () => { + const body: Record = { + model: "gpt-5-nano", + messages: [{ role: "system", content: "hi" }], + }; + applySafeExtraParams(body, { + model: "override-model", + messages: [{ role: "user", content: "override" }], + tools: ["fake-tool"], + tool_choice: "none", + temperature: 0.9, + input: "override-input", + instructions: "override-instructions", + conversation: "override-convo", + stream: true, + }); + + expect(body).toEqual({ + model: "gpt-5-nano", + messages: [{ role: "system", content: "hi" }], + }); + expect(body.stream).toBeUndefined(); + }); + }); });