diff --git a/src/services/ai/providers/base-provider.ts b/src/services/ai/providers/base-provider.ts index 0a76e68..3d30a53 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/src/services/user-profile/ai-cleanup.ts b/src/services/user-profile/ai-cleanup.ts index 527b229..d81dc55 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 5a990ba..fbaa5a6 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,15 @@ 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, + stream: true, + model: "must-not-override", + messages: [{ role: "user", content: "must-not-override" }], + temperature: 0.9, + response_format: { type: "text" }, + }, }, })); @@ -141,8 +151,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 +189,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 +255,14 @@ 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?.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 32f664d..6b55310 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(); + }); + }); });