Skip to content
Open
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 src/services/ai/providers/base-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const PROTECTED_KEYS = new Set([
"input",
"instructions",
"conversation",
"stream",
]);

export function applySafeExtraParams(
Expand Down
28 changes: 19 additions & 9 deletions src/services/user-profile/ai-cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, unknown> = {};
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),
});

Expand Down
23 changes: 22 additions & 1 deletion tests/ai-cleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }],
Expand All @@ -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" },
},
},
}));

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
});
43 changes: 43 additions & 0 deletions tests/ai-provider-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, unknown> = { 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<string, unknown> = {
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();
});
});
});