From 0feaebf82d534eb3eeb6a26fb5249621e3fabd5d Mon Sep 17 00:00:00 2001 From: machen Date: Wed, 18 Mar 2026 17:50:13 +0800 Subject: [PATCH 01/12] feat(ai): add DeepSeek provider with session support and tests --- scripts/test-deepseek.ts | 88 ++++++ src/config.ts | 11 +- src/services/ai/ai-provider-factory.ts | 6 +- src/services/ai/providers/deepseek.ts | 379 +++++++++++++++++++++++ src/services/ai/session/session-types.ts | 7 +- tests/deepseek-provider.test.ts | 325 +++++++++++++++++++ 6 files changed, 811 insertions(+), 5 deletions(-) create mode 100644 scripts/test-deepseek.ts create mode 100644 src/services/ai/providers/deepseek.ts create mode 100644 tests/deepseek-provider.test.ts diff --git a/scripts/test-deepseek.ts b/scripts/test-deepseek.ts new file mode 100644 index 0000000..983cb2c --- /dev/null +++ b/scripts/test-deepseek.ts @@ -0,0 +1,88 @@ +import { DeepSeekProvider } from "../src/services/ai/providers/deepseek.js"; +import type { ChatCompletionTool } from "../src/services/ai/tools/tool-schema.js"; + +const apiKey = process.env.DEEPSEEK_API_KEY; +if (!apiKey) { + console.error("Error: DEEPSEEK_API_KEY is not set."); + process.exit(1); +} + +const model = process.env.DEEPSEEK_MODEL ?? "deepseek-chat"; +const apiUrl = process.env.DEEPSEEK_API_URL; + +class FakeSessionManager { + private readonly session = { id: "test-session-1" }; + private readonly messages: any[] = []; + + getSession(): any { + return null; + } + createSession(): any { + return this.session; + } + getMessages(): any[] { + return this.messages; + } + getLastSequence(): number { + return this.messages.length - 1; + } + addMessage(message: any): void { + this.messages.push(message); + } +} + +const echoTool: ChatCompletionTool = { + type: "function", + function: { + name: "echo_greeting", + description: "Return a greeting message", + parameters: { + type: "object", + properties: { + message: { + type: "string", + description: "The greeting message to return", + }, + }, + required: ["message"], + }, + }, +}; + +const config: Record = { + model, + apiKey, + ...(apiUrl ? { apiUrl } : {}), + maxIterations: 3, +}; + +const provider = new DeepSeekProvider(config as any, new FakeSessionManager() as any); + +console.log(`Provider : ${provider.getProviderName()}`); +console.log(`Model : ${model}`); +console.log(`API URL : ${apiUrl ?? "https://api.deepseek.com"} (default)`); +console.log("---"); +console.log("Calling DeepSeek API...\n"); + +const systemPrompt = + "You are a helpful assistant. When asked to greet, call the echo_greeting tool."; +const userPrompt = + "Please greet me by calling the echo_greeting tool with message 'Hello from DeepSeek!'"; + +const result = await provider.executeToolCall( + systemPrompt, + userPrompt, + echoTool, + "integration-test" +); + +if (result.success) { + console.log("SUCCESS"); + console.log(`Iterations : ${result.iterations}`); + console.log(`Result :`, JSON.stringify((result as any).data, null, 2)); +} else { + console.error("FAILED"); + console.error(`Error : ${result.error}`); + console.error(`Iterations : ${result.iterations}`); + process.exit(1); +} diff --git a/src/config.ts b/src/config.ts index b2d806a..8bb607f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -36,7 +36,7 @@ interface OpenCodeMemConfig { autoCaptureMaxIterations?: number; autoCaptureIterationTimeout?: number; autoCaptureLanguage?: string; - memoryProvider?: "openai-chat" | "openai-responses" | "anthropic"; + memoryProvider?: "openai-chat" | "openai-responses" | "anthropic" | "deepseek"; memoryModel?: string; memoryApiUrl?: string; memoryApiKey?: string; @@ -99,7 +99,7 @@ const DEFAULTS: Required< memoryModel?: string; memoryApiUrl?: string; memoryApiKey?: string; - memoryProvider?: "openai-chat" | "openai-responses" | "anthropic"; + memoryProvider?: "openai-chat" | "openai-responses" | "anthropic" | "deepseek"; memoryTemperature?: number | false; memoryExtraParams?: Record; opencodeProvider?: string; @@ -267,7 +267,7 @@ const CONFIG_TEMPLATE = `{ "autoCaptureEnabled": true, - // Provider type: "openai-chat" | "openai-responses" | "anthropic" + // Provider type: "openai-chat" | "openai-responses" | "anthropic" | "deepseek" "memoryProvider": "openai-chat", // REQUIRED for auto-capture (all 3 must be set): @@ -305,6 +305,11 @@ const CONFIG_TEMPLATE = `{ // "memoryApiUrl": "https://api.groq.com/openai/v1" // "memoryApiKey": "gsk_..." + // DeepSeek (with session support, apiUrl optional — defaults to https://api.deepseek.com): + // "memoryProvider": "deepseek" + // "memoryModel": "deepseek-chat" + // "memoryApiKey": "sk-..." + // Maximum iterations for multi-turn AI analysis (for openai-responses and anthropic) "autoCaptureMaxIterations": 5, diff --git a/src/services/ai/ai-provider-factory.ts b/src/services/ai/ai-provider-factory.ts index 2935927..ebb0c79 100644 --- a/src/services/ai/ai-provider-factory.ts +++ b/src/services/ai/ai-provider-factory.ts @@ -3,6 +3,7 @@ import { OpenAIChatCompletionProvider } from "./providers/openai-chat-completion import { OpenAIResponsesProvider } from "./providers/openai-responses.js"; import { AnthropicMessagesProvider } from "./providers/anthropic-messages.js"; import { GoogleGeminiProvider } from "./providers/google-gemini.js"; +import { DeepSeekProvider } from "./providers/deepseek.js"; import { aiSessionManager } from "./session/ai-session-manager.js"; import type { AIProviderType } from "./session/session-types.js"; @@ -21,13 +22,16 @@ export class AIProviderFactory { case "google-gemini": return new GoogleGeminiProvider(config, aiSessionManager); + case "deepseek": + return new DeepSeekProvider(config, aiSessionManager); + default: throw new Error(`Unknown provider type: ${providerType}`); } } static getSupportedProviders(): AIProviderType[] { - return ["openai-chat", "openai-responses", "anthropic", "google-gemini"]; + return ["openai-chat", "openai-responses", "anthropic", "google-gemini", "deepseek"]; } static cleanupExpiredSessions(): number { diff --git a/src/services/ai/providers/deepseek.ts b/src/services/ai/providers/deepseek.ts new file mode 100644 index 0000000..a44f3f4 --- /dev/null +++ b/src/services/ai/providers/deepseek.ts @@ -0,0 +1,379 @@ +import { BaseAIProvider, type ToolCallResult, applySafeExtraParams } from "./base-provider.js"; +import { AISessionManager } from "../session/ai-session-manager.js"; +import type { ChatCompletionTool } from "../tools/tool-schema.js"; +import { log } from "../../logger.js"; +import { UserProfileValidator } from "../validators/user-profile-validator.js"; + +interface ToolCallResponse { + choices: Array<{ + message: { + content?: string; + tool_calls?: Array<{ + id: string; + type: string; + function: { + name: string; + arguments: string; + }; + }>; + }; + finish_reason?: string; + }>; +} + +export class DeepSeekProvider extends BaseAIProvider { + private aiSessionManager: AISessionManager; + + constructor(config: any, aiSessionManager: AISessionManager) { + super(config); + this.aiSessionManager = aiSessionManager; + } + + getProviderName(): string { + return "deepseek"; + } + + supportsSession(): boolean { + return true; + } + + private addToolResponse( + sessionId: string, + messages: any[], + toolCallId: string, + content: string + ): void { + const sequence = this.aiSessionManager.getLastSequence(sessionId) + 1; + this.aiSessionManager.addMessage({ + aiSessionId: sessionId, + sequence, + role: "tool", + content, + toolCallId, + }); + messages.push({ + role: "tool", + tool_call_id: toolCallId, + content, + }); + } + + private filterIncompleteToolCallSequences(messages: any[]): any[] { + const result: any[] = []; + let i = 0; + + while (i < messages.length) { + const msg = messages[i]; + + if (msg.role === "assistant" && msg.toolCalls && msg.toolCalls.length > 0) { + const toolCallIds = new Set(msg.toolCalls.map((tc: any) => tc.id)); + const toolResponses: any[] = []; + let j = i + 1; + + while (j < messages.length && messages[j].role === "tool") { + if (toolCallIds.has(messages[j].toolCallId)) { + toolResponses.push(messages[j]); + toolCallIds.delete(messages[j].toolCallId); + } + j++; + } + + if (toolCallIds.size === 0) { + result.push(msg); + toolResponses.forEach((tr) => result.push(tr)); + i = j; + } else { + break; + } + } else { + result.push(msg); + i++; + } + } + + return result; + } + + async executeToolCall( + systemPrompt: string, + userPrompt: string, + toolSchema: ChatCompletionTool, + sessionId: string + ): Promise { + let session = this.aiSessionManager.getSession(sessionId, "deepseek"); + + if (!session) { + session = this.aiSessionManager.createSession({ + provider: "deepseek", + sessionId, + }); + } + + const existingMessages = this.aiSessionManager.getMessages(session.id); + const messages: any[] = []; + + const validatedMessages = this.filterIncompleteToolCallSequences(existingMessages); + + for (const msg of validatedMessages) { + const apiMsg: any = { + role: msg.role, + content: msg.content, + }; + + if (msg.toolCalls) { + apiMsg.tool_calls = msg.toolCalls; + } + + if (msg.toolCallId) { + apiMsg.tool_call_id = msg.toolCallId; + } + + messages.push(apiMsg); + } + + if (messages.length === 0) { + const sequence = this.aiSessionManager.getLastSequence(session.id) + 1; + this.aiSessionManager.addMessage({ + aiSessionId: session.id, + sequence, + role: "system", + content: systemPrompt, + }); + + messages.push({ role: "system", content: systemPrompt }); + } + + const userSequence = this.aiSessionManager.getLastSequence(session.id) + 1; + this.aiSessionManager.addMessage({ + aiSessionId: session.id, + sequence: userSequence, + role: "user", + content: userPrompt, + }); + + messages.push({ role: "user", content: userPrompt }); + + let iterations = 0; + const maxIterations = this.config.maxIterations ?? 5; + const iterationTimeout = this.config.iterationTimeout ?? 30000; + + const apiUrl = this.config.apiUrl ?? "https://api.deepseek.com"; + + while (iterations < maxIterations) { + iterations++; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), iterationTimeout); + + try { + const requestBody: any = { + model: this.config.model, + messages, + tools: [toolSchema], + tool_choice: "auto", + }; + + if (this.config.memoryTemperature !== false) { + requestBody.temperature = this.config.memoryTemperature ?? 0.3; + } + + if (this.config.extraParams) { + applySafeExtraParams(requestBody, this.config.extraParams); + } + + const headers: Record = { + "Content-Type": "application/json", + }; + + if (this.config.apiKey) { + headers.Authorization = `Bearer ${this.config.apiKey}`; + } + + const response = await fetch(`${apiUrl}/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify(requestBody), + signal: controller.signal, + }); + + clearTimeout(timeout); + + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + log("DeepSeek API error", { + provider: this.getProviderName(), + model: this.config.model, + status: response.status, + error: errorText, + iteration: iterations, + }); + + let errorMessage = `API error: ${response.status} - ${errorText}`; + + if ( + response.status === 400 && + errorText.includes("unsupported_value") && + errorText.includes("temperature") + ) { + errorMessage = + 'Your model does not support the temperature parameter. Add "memoryTemperature": false to your config file to disable it.'; + } + + return { + success: false, + error: errorMessage, + iterations, + }; + } + + const data = (await response.json()) as any; + + if (data.status && data.msg) { + log("DeepSeek API returned error in response body", { + provider: this.getProviderName(), + model: this.config.model, + status: data.status, + msg: data.msg, + }); + return { + success: false, + error: `API error: ${data.status} - ${data.msg}`, + iterations, + }; + } + + if (!data.choices || !data.choices[0]) { + log("Invalid DeepSeek API response format", { + provider: this.getProviderName(), + model: this.config.model, + response: JSON.stringify(data).slice(0, 1000), + hasChoices: !!data.choices, + choicesLength: data.choices?.length, + }); + return { + success: false, + error: "Invalid API response format", + iterations, + }; + } + + const choice = data.choices[0]; + + const assistantSequence = this.aiSessionManager.getLastSequence(session.id) + 1; + const assistantMsg: any = { + aiSessionId: session.id, + sequence: assistantSequence, + role: "assistant", + content: choice.message.content || "", + }; + + if (choice.message.tool_calls) { + assistantMsg.toolCalls = choice.message.tool_calls; + } + + this.aiSessionManager.addMessage(assistantMsg); + messages.push(choice.message); + + if (choice.message.tool_calls && choice.message.tool_calls.length > 0) { + for (const toolCall of choice.message.tool_calls) { + const toolCallId = toolCall.id; + + if (toolCall.function.name === toolSchema.function.name) { + try { + const parsed = JSON.parse(toolCall.function.arguments); + const result = UserProfileValidator.validate(parsed); + if (!result.valid) { + throw new Error(result.errors.join(", ")); + } + + this.addToolResponse( + session.id, + messages, + toolCallId, + JSON.stringify({ success: true }) + ); + + return { + success: true, + data: result.data, + iterations, + }; + } catch (validationError) { + const errorStack = + validationError instanceof Error ? validationError.stack : undefined; + log("DeepSeek tool response validation failed", { + error: String(validationError), + stack: errorStack, + errorType: + validationError instanceof Error + ? validationError.constructor.name + : typeof validationError, + toolName: toolSchema.function.name, + iteration: iterations, + rawArguments: toolCall.function.arguments.slice(0, 500), + }); + + const errorMessage = `Validation failed: ${String(validationError)}`; + this.addToolResponse( + session.id, + messages, + toolCallId, + JSON.stringify({ success: false, error: errorMessage }) + ); + + return { + success: false, + error: errorMessage, + iterations, + }; + } + } + + const wrongToolMessage = `Wrong tool called. Please use ${toolSchema.function.name} instead.`; + this.addToolResponse( + session.id, + messages, + toolCallId, + JSON.stringify({ success: false, error: wrongToolMessage }) + ); + + break; + } + } + + const retrySequence = this.aiSessionManager.getLastSequence(session.id) + 1; + const retryPrompt = + "Please use the save_memories tool to extract and save the memories from the conversation as instructed."; + + this.aiSessionManager.addMessage({ + aiSessionId: session.id, + sequence: retrySequence, + role: "user", + content: retryPrompt, + }); + + messages.push({ role: "user", content: retryPrompt }); + } catch (error) { + clearTimeout(timeout); + if (error instanceof Error && error.name === "AbortError") { + return { + success: false, + error: `API request timeout (${this.config.iterationTimeout}ms)`, + iterations, + }; + } + return { + success: false, + error: String(error), + iterations, + }; + } + } + + return { + success: false, + error: `Max iterations (${this.config.maxIterations}) reached without tool call`, + iterations, + }; + } +} diff --git a/src/services/ai/session/session-types.ts b/src/services/ai/session/session-types.ts index 64c6cd0..072c6b0 100644 --- a/src/services/ai/session/session-types.ts +++ b/src/services/ai/session/session-types.ts @@ -1,4 +1,9 @@ -export type AIProviderType = "openai-chat" | "openai-responses" | "anthropic" | "google-gemini"; +export type AIProviderType = + | "openai-chat" + | "openai-responses" + | "anthropic" + | "google-gemini" + | "deepseek"; export interface AIMessage { id?: number; diff --git a/tests/deepseek-provider.test.ts b/tests/deepseek-provider.test.ts new file mode 100644 index 0000000..4c9c3e3 --- /dev/null +++ b/tests/deepseek-provider.test.ts @@ -0,0 +1,325 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { DeepSeekProvider } from "../src/services/ai/providers/deepseek.js"; +import type { ChatCompletionTool } from "../src/services/ai/tools/tool-schema.js"; + +const toolSchema: ChatCompletionTool = { + type: "function", + function: { + name: "save_memories", + description: "Save memories", + parameters: { + type: "object", + properties: {}, + required: [], + }, + }, +}; + +class FakeSessionManager { + private readonly session = { id: "session-1" }; + private readonly messages: any[] = []; + + getSession(): any { + return null; + } + + createSession(): any { + return this.session; + } + + getMessages(): any[] { + return this.messages; + } + + getLastSequence(): number { + return this.messages.length - 1; + } + + addMessage(message: any): void { + this.messages.push(message); + } +} + +function makeProvider(config: Record = {}) { + return new DeepSeekProvider( + { model: "deepseek-chat", apiKey: "test-key", ...config }, + new FakeSessionManager() as any + ); +} + +function makeFetch(response: { + ok?: boolean; + status?: number; + statusText?: string; + body?: unknown; +}) { + const textBody = + typeof response.body === "string" ? response.body : JSON.stringify(response.body ?? "error"); + const jsonBody = typeof response.body === "string" ? {} : (response.body ?? {}); + return (async (_input: RequestInfo | URL, _init?: RequestInit) => { + return { + ok: response.ok ?? false, + status: response.status ?? 400, + statusText: response.statusText ?? "Bad Request", + text: async () => textBody, + json: async () => jsonBody, + } as Response; + }) as typeof fetch; +} + +describe("DeepSeekProvider", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("getProviderName returns deepseek", () => { + expect(makeProvider().getProviderName()).toBe("deepseek"); + }); + + it("supportsSession returns true", () => { + expect(makeProvider().supportsSession()).toBe(true); + }); + + it("uses https://api.deepseek.com as default apiUrl", async () => { + let capturedUrl = ""; + globalThis.fetch = (async (input: RequestInfo | URL, _init?: RequestInit) => { + capturedUrl = String(input); + return { ok: false, status: 400, statusText: "Bad", text: async () => "err" } as Response; + }) as typeof fetch; + + await makeProvider().executeToolCall("system", "user", toolSchema, "session-id"); + + expect(capturedUrl).toBe("https://api.deepseek.com/chat/completions"); + }); + + it("respects custom apiUrl when provided", async () => { + let capturedUrl = ""; + globalThis.fetch = (async (input: RequestInfo | URL, _init?: RequestInit) => { + capturedUrl = String(input); + return { ok: false, status: 400, statusText: "Bad", text: async () => "err" } as Response; + }) as typeof fetch; + + await makeProvider({ apiUrl: "https://custom.example.com/v1" }).executeToolCall( + "system", + "user", + toolSchema, + "session-id" + ); + + expect(capturedUrl).toBe("https://custom.example.com/v1/chat/completions"); + }); + + it("sends Authorization Bearer header", async () => { + let capturedHeaders: Record | undefined; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedHeaders = init?.headers as Record; + return { ok: false, status: 400, statusText: "Bad", text: async () => "err" } as Response; + }) as typeof fetch; + + await makeProvider({ apiKey: "sk-mykey" }).executeToolCall( + "system", + "user", + toolSchema, + "session-id" + ); + + expect(capturedHeaders?.Authorization).toBe("Bearer sk-mykey"); + }); + + it("omits Authorization header when apiKey is not set", async () => { + let capturedHeaders: Record | undefined; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedHeaders = init?.headers as Record; + return { ok: false, status: 400, statusText: "Bad", text: async () => "err" } as Response; + }) as typeof fetch; + + await makeProvider({ apiKey: undefined }).executeToolCall( + "system", + "user", + toolSchema, + "session-id" + ); + + expect(capturedHeaders?.Authorization).toBeUndefined(); + }); + + it("sends model, messages, tools, tool_choice in request body", async () => { + let capturedBody: Record | undefined; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedBody = JSON.parse(String(init?.body ?? "{}")); + return { ok: false, status: 400, statusText: "Bad", text: async () => "err" } as Response; + }) as typeof fetch; + + await makeProvider({ model: "deepseek-reasoner" }).executeToolCall( + "system", + "user", + toolSchema, + "session-id" + ); + + expect(capturedBody?.model).toBe("deepseek-reasoner"); + expect(Array.isArray(capturedBody?.messages)).toBe(true); + expect(Array.isArray(capturedBody?.tools)).toBe(true); + expect(capturedBody?.tool_choice).toBe("auto"); + }); + + it("includes temperature 0.3 by default", async () => { + let capturedBody: Record | undefined; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedBody = JSON.parse(String(init?.body ?? "{}")); + return { ok: false, status: 400, statusText: "Bad", text: async () => "err" } as Response; + }) as typeof fetch; + + await makeProvider().executeToolCall("system", "user", toolSchema, "session-id"); + + expect(capturedBody?.temperature).toBe(0.3); + }); + + it("omits temperature when memoryTemperature is false", async () => { + let capturedBody: Record | undefined; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedBody = JSON.parse(String(init?.body ?? "{}")); + return { ok: false, status: 400, statusText: "Bad", text: async () => "err" } as Response; + }) as typeof fetch; + + await makeProvider({ memoryTemperature: false }).executeToolCall( + "system", + "user", + toolSchema, + "session-id" + ); + + expect(capturedBody?.temperature).toBeUndefined(); + }); + + it("returns success: false with error message on API error response", async () => { + globalThis.fetch = makeFetch({ ok: false, status: 401, body: "Unauthorized" }); + + const result = await makeProvider().executeToolCall("system", "user", toolSchema, "session-id"); + + expect(result.success).toBe(false); + expect(result.error).toContain("401"); + }); + + it("returns friendly message on temperature unsupported error", async () => { + globalThis.fetch = makeFetch({ + ok: false, + status: 400, + body: '{"error": {"type": "unsupported_value", "param": "temperature"}}', + }); + + const result = await makeProvider().executeToolCall("system", "user", toolSchema, "session-id"); + + expect(result.success).toBe(false); + expect(result.error).toContain("memoryTemperature"); + }); + + it("returns success: false when response has no choices", async () => { + globalThis.fetch = makeFetch({ ok: true, body: { choices: [] } } as any); + + const result = await makeProvider().executeToolCall("system", "user", toolSchema, "session-id"); + + expect(result.success).toBe(false); + expect(result.error).toContain("Invalid API response format"); + }); + + it("returns success: false when API returns error in response body", async () => { + globalThis.fetch = makeFetch({ + ok: true, + body: { status: "error", msg: "quota exceeded" }, + } as any); + + const result = await makeProvider().executeToolCall("system", "user", toolSchema, "session-id"); + + expect(result.success).toBe(false); + expect(result.error).toContain("quota exceeded"); + }); + + it("returns success: false after max iterations with no tool call", async () => { + globalThis.fetch = makeFetch({ + ok: true, + body: { + choices: [{ message: { content: "I will not use a tool", tool_calls: undefined } }], + }, + } as any); + + const result = await makeProvider({ maxIterations: 2 }).executeToolCall( + "system", + "user", + toolSchema, + "session-id" + ); + + expect(result.success).toBe(false); + expect(result.error).toContain("Max iterations"); + expect(result.iterations).toBe(2); + }); + + it("returns success: true when model calls the correct tool", async () => { + const validArguments = JSON.stringify({ + preferences: [], + patterns: [], + workflows: [], + codingStyle: {}, + domainKnowledge: [], + }); + + globalThis.fetch = makeFetch({ + ok: true, + body: { + choices: [ + { + message: { + content: null, + tool_calls: [ + { + id: "call-1", + type: "function", + function: { name: "save_memories", arguments: validArguments }, + }, + ], + }, + }, + ], + }, + } as any); + + const result = await makeProvider().executeToolCall("system", "user", toolSchema, "session-id"); + + expect(result.success).toBe(true); + expect(result.iterations).toBe(1); + }); + + it("returns success: false when model calls wrong tool name", async () => { + globalThis.fetch = makeFetch({ + ok: true, + body: { + choices: [ + { + message: { + content: null, + tool_calls: [ + { + id: "call-1", + type: "function", + function: { name: "wrong_tool", arguments: "{}" }, + }, + ], + }, + }, + ], + }, + } as any); + + const result = await makeProvider({ maxIterations: 1 }).executeToolCall( + "system", + "user", + toolSchema, + "session-id" + ); + + expect(result.success).toBe(false); + }); +}); From 860487eb3d8a86376449baf488466307d37be6c4 Mon Sep 17 00:00:00 2001 From: machen Date: Wed, 18 Mar 2026 18:12:38 +0800 Subject: [PATCH 02/12] fix(ai): address code review feedback in DeepSeek provider - Use local variable `iterationTimeout` instead of `this.config.iterationTimeout` in timeout error message - Use local variable `maxIterations` instead of `this.config.maxIterations` in max iterations error message - Use `toolSchema.function.name` instead of hardcoded 'save_memories' in retry prompt --- src/services/ai/providers/deepseek.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/services/ai/providers/deepseek.ts b/src/services/ai/providers/deepseek.ts index a44f3f4..3618095 100644 --- a/src/services/ai/providers/deepseek.ts +++ b/src/services/ai/providers/deepseek.ts @@ -342,8 +342,7 @@ export class DeepSeekProvider extends BaseAIProvider { } const retrySequence = this.aiSessionManager.getLastSequence(session.id) + 1; - const retryPrompt = - "Please use the save_memories tool to extract and save the memories from the conversation as instructed."; + const retryPrompt = `Please use the ${toolSchema.function.name} tool to extract and save the memories from the conversation as instructed.`; this.aiSessionManager.addMessage({ aiSessionId: session.id, @@ -358,7 +357,7 @@ export class DeepSeekProvider extends BaseAIProvider { if (error instanceof Error && error.name === "AbortError") { return { success: false, - error: `API request timeout (${this.config.iterationTimeout}ms)`, + error: `API request timeout (${iterationTimeout}ms)`, iterations, }; } @@ -372,7 +371,7 @@ export class DeepSeekProvider extends BaseAIProvider { return { success: false, - error: `Max iterations (${this.config.maxIterations}) reached without tool call`, + error: `Max iterations (${maxIterations}) reached without tool call`, iterations, }; } From 031bf7a060463aab03d3e3b50eb616d01edee83c Mon Sep 17 00:00:00 2001 From: machen Date: Wed, 18 Mar 2026 18:28:40 +0800 Subject: [PATCH 03/12] refactor(ai): replace ToolCallResponse with typed DeepSeek interfaces Replace the unused ToolCallResponse interface with three focused interfaces: - DeepSeekToolCall: tool call structure - DeepSeekErrorResponse: error body shape - DeepSeekResponse: success response shape Eliminates 'as any' cast and gives full type coverage to the response path. --- src/services/ai/providers/deepseek.ts | 30 +++++++++++++++++---------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/services/ai/providers/deepseek.ts b/src/services/ai/providers/deepseek.ts index 3618095..3b07d73 100644 --- a/src/services/ai/providers/deepseek.ts +++ b/src/services/ai/providers/deepseek.ts @@ -4,18 +4,25 @@ import type { ChatCompletionTool } from "../tools/tool-schema.js"; import { log } from "../../logger.js"; import { UserProfileValidator } from "../validators/user-profile-validator.js"; -interface ToolCallResponse { +interface DeepSeekToolCall { + id: string; + type: string; + function: { + name: string; + arguments: string; + }; +} + +interface DeepSeekErrorResponse { + status?: unknown; + msg?: unknown; +} + +interface DeepSeekResponse { choices: Array<{ message: { content?: string; - tool_calls?: Array<{ - id: string; - type: string; - function: { - name: string; - arguments: string; - }; - }>; + tool_calls?: DeepSeekToolCall[]; }; finish_reason?: string; }>; @@ -226,7 +233,7 @@ export class DeepSeekProvider extends BaseAIProvider { }; } - const data = (await response.json()) as any; + const data = (await response.json()) as DeepSeekErrorResponse & Partial; if (data.status && data.msg) { log("DeepSeek API returned error in response body", { @@ -257,7 +264,8 @@ export class DeepSeekProvider extends BaseAIProvider { }; } - const choice = data.choices[0]; + const response_data = data as DeepSeekResponse; + const choice = response_data.choices[0] as DeepSeekResponse["choices"][0]; const assistantSequence = this.aiSessionManager.getLastSequence(session.id) + 1; const assistantMsg: any = { From 005ebbb664b1aa8284214bbcee758b16e79606fd Mon Sep 17 00:00:00 2001 From: machen Date: Wed, 18 Mar 2026 18:41:39 +0800 Subject: [PATCH 04/12] fix(scripts): only show '(default)' label when DEEPSEEK_API_URL is unset --- scripts/test-deepseek.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test-deepseek.ts b/scripts/test-deepseek.ts index 983cb2c..2f11460 100644 --- a/scripts/test-deepseek.ts +++ b/scripts/test-deepseek.ts @@ -60,7 +60,7 @@ const provider = new DeepSeekProvider(config as any, new FakeSessionManager() as console.log(`Provider : ${provider.getProviderName()}`); console.log(`Model : ${model}`); -console.log(`API URL : ${apiUrl ?? "https://api.deepseek.com"} (default)`); +console.log(`API URL : ${apiUrl ?? "https://api.deepseek.com"}${apiUrl ? "" : " (default)"}`); console.log("---"); console.log("Calling DeepSeek API...\n"); From 7af684115ffcf86c50e8c1f95a74483f60a172c7 Mon Sep 17 00:00:00 2001 From: machen Date: Wed, 18 Mar 2026 19:08:51 +0800 Subject: [PATCH 05/12] fix(config): add 'deepseek' to memoryProvider type union --- src/config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index 8bb607f..819ab52 100644 --- a/src/config.ts +++ b/src/config.ts @@ -481,7 +481,8 @@ function buildConfig(fileConfig: OpenCodeMemConfig) { memoryProvider: (fileConfig.memoryProvider ?? "openai-chat") as | "openai-chat" | "openai-responses" - | "anthropic", + | "anthropic" + | "deepseek", memoryModel: fileConfig.memoryModel, memoryApiUrl: fileConfig.memoryApiUrl, memoryApiKey: resolveSecretValue(fileConfig.memoryApiKey), From a8b9f6739b48bf119128a5a05154643493b54c01 Mon Sep 17 00:00:00 2001 From: machen Date: Wed, 18 Mar 2026 19:24:58 +0800 Subject: [PATCH 06/12] fix(ai): require explicit memoryApiUrl for DeepSeek provider --- src/config.ts | 3 ++- src/services/ai/providers/deepseek.ts | 4 +--- tests/deepseek-provider.test.ts | 9 +++++++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/config.ts b/src/config.ts index 819ab52..4980fc8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -305,9 +305,10 @@ const CONFIG_TEMPLATE = `{ // "memoryApiUrl": "https://api.groq.com/openai/v1" // "memoryApiKey": "gsk_..." - // DeepSeek (with session support, apiUrl optional — defaults to https://api.deepseek.com): + // DeepSeek (with session support): // "memoryProvider": "deepseek" // "memoryModel": "deepseek-chat" + // "memoryApiUrl": "https://api.deepseek.com" // "memoryApiKey": "sk-..." // Maximum iterations for multi-turn AI analysis (for openai-responses and anthropic) diff --git a/src/services/ai/providers/deepseek.ts b/src/services/ai/providers/deepseek.ts index 3b07d73..c34496e 100644 --- a/src/services/ai/providers/deepseek.ts +++ b/src/services/ai/providers/deepseek.ts @@ -164,8 +164,6 @@ export class DeepSeekProvider extends BaseAIProvider { const maxIterations = this.config.maxIterations ?? 5; const iterationTimeout = this.config.iterationTimeout ?? 30000; - const apiUrl = this.config.apiUrl ?? "https://api.deepseek.com"; - while (iterations < maxIterations) { iterations++; @@ -196,7 +194,7 @@ export class DeepSeekProvider extends BaseAIProvider { headers.Authorization = `Bearer ${this.config.apiKey}`; } - const response = await fetch(`${apiUrl}/chat/completions`, { + const response = await fetch(`${this.config.apiUrl}/chat/completions`, { method: "POST", headers, body: JSON.stringify(requestBody), diff --git a/tests/deepseek-provider.test.ts b/tests/deepseek-provider.test.ts index 4c9c3e3..60efab9 100644 --- a/tests/deepseek-provider.test.ts +++ b/tests/deepseek-provider.test.ts @@ -82,14 +82,19 @@ describe("DeepSeekProvider", () => { expect(makeProvider().supportsSession()).toBe(true); }); - it("uses https://api.deepseek.com as default apiUrl", async () => { + it("uses provided apiUrl for the request", async () => { let capturedUrl = ""; globalThis.fetch = (async (input: RequestInfo | URL, _init?: RequestInit) => { capturedUrl = String(input); return { ok: false, status: 400, statusText: "Bad", text: async () => "err" } as Response; }) as typeof fetch; - await makeProvider().executeToolCall("system", "user", toolSchema, "session-id"); + await makeProvider({ apiUrl: "https://api.deepseek.com" }).executeToolCall( + "system", + "user", + toolSchema, + "session-id" + ); expect(capturedUrl).toBe("https://api.deepseek.com/chat/completions"); }); From 7babf9496fe3928f8b8c77cd97208b5b82fe26d0 Mon Sep 17 00:00:00 2001 From: machen Date: Tue, 31 Mar 2026 19:55:37 +0800 Subject: [PATCH 07/12] chore(scripts): remove manual DeepSeek integration test --- scripts/test-deepseek.ts | 88 ---------------------------------------- 1 file changed, 88 deletions(-) delete mode 100644 scripts/test-deepseek.ts diff --git a/scripts/test-deepseek.ts b/scripts/test-deepseek.ts deleted file mode 100644 index 2f11460..0000000 --- a/scripts/test-deepseek.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { DeepSeekProvider } from "../src/services/ai/providers/deepseek.js"; -import type { ChatCompletionTool } from "../src/services/ai/tools/tool-schema.js"; - -const apiKey = process.env.DEEPSEEK_API_KEY; -if (!apiKey) { - console.error("Error: DEEPSEEK_API_KEY is not set."); - process.exit(1); -} - -const model = process.env.DEEPSEEK_MODEL ?? "deepseek-chat"; -const apiUrl = process.env.DEEPSEEK_API_URL; - -class FakeSessionManager { - private readonly session = { id: "test-session-1" }; - private readonly messages: any[] = []; - - getSession(): any { - return null; - } - createSession(): any { - return this.session; - } - getMessages(): any[] { - return this.messages; - } - getLastSequence(): number { - return this.messages.length - 1; - } - addMessage(message: any): void { - this.messages.push(message); - } -} - -const echoTool: ChatCompletionTool = { - type: "function", - function: { - name: "echo_greeting", - description: "Return a greeting message", - parameters: { - type: "object", - properties: { - message: { - type: "string", - description: "The greeting message to return", - }, - }, - required: ["message"], - }, - }, -}; - -const config: Record = { - model, - apiKey, - ...(apiUrl ? { apiUrl } : {}), - maxIterations: 3, -}; - -const provider = new DeepSeekProvider(config as any, new FakeSessionManager() as any); - -console.log(`Provider : ${provider.getProviderName()}`); -console.log(`Model : ${model}`); -console.log(`API URL : ${apiUrl ?? "https://api.deepseek.com"}${apiUrl ? "" : " (default)"}`); -console.log("---"); -console.log("Calling DeepSeek API...\n"); - -const systemPrompt = - "You are a helpful assistant. When asked to greet, call the echo_greeting tool."; -const userPrompt = - "Please greet me by calling the echo_greeting tool with message 'Hello from DeepSeek!'"; - -const result = await provider.executeToolCall( - systemPrompt, - userPrompt, - echoTool, - "integration-test" -); - -if (result.success) { - console.log("SUCCESS"); - console.log(`Iterations : ${result.iterations}`); - console.log(`Result :`, JSON.stringify((result as any).data, null, 2)); -} else { - console.error("FAILED"); - console.error(`Error : ${result.error}`); - console.error(`Iterations : ${result.iterations}`); - process.exit(1); -} From ba31afab234911f559362fd3fe10d7d8a38d08c6 Mon Sep 17 00:00:00 2001 From: machen Date: Tue, 31 Mar 2026 20:31:14 +0800 Subject: [PATCH 08/12] refactor(ai): extract OpenAI chat completion base provider --- .../providers/openai-chat-completion-base.ts | 401 ++++++++++++++++++ .../ai/providers/openai-chat-completion.ts | 380 +---------------- 2 files changed, 422 insertions(+), 359 deletions(-) create mode 100644 src/services/ai/providers/openai-chat-completion-base.ts diff --git a/src/services/ai/providers/openai-chat-completion-base.ts b/src/services/ai/providers/openai-chat-completion-base.ts new file mode 100644 index 0000000..fa8e844 --- /dev/null +++ b/src/services/ai/providers/openai-chat-completion-base.ts @@ -0,0 +1,401 @@ +import { + BaseAIProvider, + type ProviderConfig, + type ToolCallResult, + applySafeExtraParams, +} from "./base-provider.js"; +import type { AISessionManager } from "../session/ai-session-manager.js"; +import type { AIMessage, AIProviderType } from "../session/session-types.js"; +import type { ChatCompletionTool } from "../tools/tool-schema.js"; +import { log } from "../../logger.js"; +import { UserProfileValidator } from "../validators/user-profile-validator.js"; + +interface ToolCallResponse { + choices: Array<{ + message: { + content?: string; + tool_calls?: Array<{ + id: string; + type: string; + function: { + name: string; + arguments: string; + }; + }>; + }; + finish_reason?: string; + }>; +} + +export abstract class OpenAIChatCompletionBaseProvider extends BaseAIProvider { + protected constructor( + config: ProviderConfig, + protected readonly aiSessionManager: AISessionManager + ) { + super(config); + } + + supportsSession(): boolean { + return true; + } + + protected abstract getSessionProviderType(): AIProviderType; + + protected abstract getApiErrorLogLabel(): string; + + protected abstract getResponseBodyErrorLogLabel(): string; + + protected abstract getInvalidResponseLogLabel(): string; + + protected abstract getToolValidationLogLabel(): string; + + protected abstract buildRetryPrompt(toolSchema: ChatCompletionTool): string; + + private addToolResponse( + sessionId: string, + messages: any[], + toolCallId: string, + content: string + ): void { + const sequence = this.aiSessionManager.getLastSequence(sessionId) + 1; + this.aiSessionManager.addMessage({ + aiSessionId: sessionId, + sequence, + role: "tool", + content, + toolCallId, + }); + messages.push({ + role: "tool", + tool_call_id: toolCallId, + content, + }); + } + + protected filterIncompleteToolCallSequences(messages: AIMessage[]): AIMessage[] { + const result: AIMessage[] = []; + let i = 0; + + while (i < messages.length) { + const msg = messages[i]; + if (!msg) { + break; + } + + if (msg.role === "assistant" && msg.toolCalls && msg.toolCalls.length > 0) { + const toolCallIds = new Set(msg.toolCalls.map((tc) => tc.id)); + const toolResponses: AIMessage[] = []; + let j = i + 1; + + while (j < messages.length && messages[j]?.role === "tool") { + const toolMessage = messages[j]; + if (toolMessage?.toolCallId && toolCallIds.has(toolMessage.toolCallId)) { + toolResponses.push(toolMessage); + toolCallIds.delete(toolMessage.toolCallId); + } + j++; + } + + if (toolCallIds.size === 0) { + result.push(msg); + toolResponses.forEach((tr) => result.push(tr)); + i = j; + } else { + break; + } + } else { + result.push(msg); + i++; + } + } + + return result; + } + + async executeToolCall( + systemPrompt: string, + userPrompt: string, + toolSchema: ChatCompletionTool, + sessionId: string + ): Promise { + let session = this.aiSessionManager.getSession(sessionId, this.getSessionProviderType()); + + if (!session) { + session = this.aiSessionManager.createSession({ + provider: this.getSessionProviderType(), + sessionId, + }); + } + + const existingMessages = this.aiSessionManager.getMessages(session.id); + const messages: any[] = []; + + const validatedMessages = this.filterIncompleteToolCallSequences(existingMessages); + + for (const msg of validatedMessages) { + const apiMsg: any = { + role: msg.role, + content: msg.content, + }; + + if (msg.toolCalls) { + apiMsg.tool_calls = msg.toolCalls; + } + + if (msg.toolCallId) { + apiMsg.tool_call_id = msg.toolCallId; + } + + messages.push(apiMsg); + } + + if (messages.length === 0) { + const sequence = this.aiSessionManager.getLastSequence(session.id) + 1; + this.aiSessionManager.addMessage({ + aiSessionId: session.id, + sequence, + role: "system", + content: systemPrompt, + }); + + messages.push({ role: "system", content: systemPrompt }); + } + + const userSequence = this.aiSessionManager.getLastSequence(session.id) + 1; + this.aiSessionManager.addMessage({ + aiSessionId: session.id, + sequence: userSequence, + role: "user", + content: userPrompt, + }); + + messages.push({ role: "user", content: userPrompt }); + + let iterations = 0; + const maxIterations = this.config.maxIterations ?? 5; + const iterationTimeout = this.config.iterationTimeout ?? 30000; + + while (iterations < maxIterations) { + iterations++; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), iterationTimeout); + + try { + const requestBody: any = { + model: this.config.model, + messages, + tools: [toolSchema], + tool_choice: "auto", + }; + + if (this.config.memoryTemperature !== false) { + requestBody.temperature = this.config.memoryTemperature ?? 0.3; + } + + if (this.config.extraParams) { + applySafeExtraParams(requestBody, this.config.extraParams); + } + + const headers: Record = { + "Content-Type": "application/json", + }; + + if (this.config.apiKey) { + headers.Authorization = `Bearer ${this.config.apiKey}`; + } + + const response = await fetch(`${this.config.apiUrl}/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify(requestBody), + signal: controller.signal, + }); + + clearTimeout(timeout); + + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + log(this.getApiErrorLogLabel(), { + provider: this.getProviderName(), + model: this.config.model, + status: response.status, + error: errorText, + iteration: iterations, + }); + + let errorMessage = `API error: ${response.status} - ${errorText}`; + + if ( + response.status === 400 && + errorText.includes("unsupported_value") && + errorText.includes("temperature") + ) { + errorMessage = + 'Your model does not support the temperature parameter. Add "memoryTemperature": false to your config file to disable it.'; + } + + return { + success: false, + error: errorMessage, + iterations, + }; + } + + const data = (await response.json()) as any; + + if (data.status && data.msg) { + log(this.getResponseBodyErrorLogLabel(), { + provider: this.getProviderName(), + model: this.config.model, + status: data.status, + msg: data.msg, + }); + return { + success: false, + error: `API error: ${data.status} - ${data.msg}`, + iterations, + }; + } + + if (!data.choices || !data.choices[0]) { + log(this.getInvalidResponseLogLabel(), { + provider: this.getProviderName(), + model: this.config.model, + response: JSON.stringify(data).slice(0, 1000), + hasChoices: !!data.choices, + choicesLength: data.choices?.length, + }); + return { + success: false, + error: "Invalid API response format", + iterations, + }; + } + + const choice = (data as ToolCallResponse).choices[0]; + if (!choice) { + return { + success: false, + error: "Invalid API response format", + iterations, + }; + } + + const assistantSequence = this.aiSessionManager.getLastSequence(session.id) + 1; + const assistantMsg: any = { + aiSessionId: session.id, + sequence: assistantSequence, + role: "assistant", + content: choice.message.content || "", + }; + + if (choice.message.tool_calls) { + assistantMsg.toolCalls = choice.message.tool_calls; + } + + this.aiSessionManager.addMessage(assistantMsg); + messages.push(choice.message); + + if (choice.message.tool_calls && choice.message.tool_calls.length > 0) { + for (const toolCall of choice.message.tool_calls) { + const toolCallId = toolCall.id; + + if (toolCall.function.name === toolSchema.function.name) { + try { + const parsed = JSON.parse(toolCall.function.arguments); + const result = UserProfileValidator.validate(parsed); + if (!result.valid) { + throw new Error(result.errors.join(", ")); + } + + this.addToolResponse( + session.id, + messages, + toolCallId, + JSON.stringify({ success: true }) + ); + + return { + success: true, + data: result.data, + iterations, + }; + } catch (validationError) { + const errorStack = + validationError instanceof Error ? validationError.stack : undefined; + log(this.getToolValidationLogLabel(), { + error: String(validationError), + stack: errorStack, + errorType: + validationError instanceof Error + ? validationError.constructor.name + : typeof validationError, + toolName: toolSchema.function.name, + iteration: iterations, + rawArguments: toolCall.function.arguments.slice(0, 500), + }); + + const errorMessage = `Validation failed: ${String(validationError)}`; + this.addToolResponse( + session.id, + messages, + toolCallId, + JSON.stringify({ success: false, error: errorMessage }) + ); + + return { + success: false, + error: errorMessage, + iterations, + }; + } + } + + const wrongToolMessage = `Wrong tool called. Please use ${toolSchema.function.name} instead.`; + this.addToolResponse( + session.id, + messages, + toolCallId, + JSON.stringify({ success: false, error: wrongToolMessage }) + ); + + break; + } + } + + const retrySequence = this.aiSessionManager.getLastSequence(session.id) + 1; + const retryPrompt = this.buildRetryPrompt(toolSchema); + + this.aiSessionManager.addMessage({ + aiSessionId: session.id, + sequence: retrySequence, + role: "user", + content: retryPrompt, + }); + + messages.push({ role: "user", content: retryPrompt }); + } catch (error) { + clearTimeout(timeout); + if (error instanceof Error && error.name === "AbortError") { + return { + success: false, + error: `API request timeout (${this.config.iterationTimeout}ms)`, + iterations, + }; + } + return { + success: false, + error: String(error), + iterations, + }; + } + } + + return { + success: false, + error: `Max iterations (${this.config.maxIterations}) reached without tool call`, + iterations, + }; + } +} diff --git a/src/services/ai/providers/openai-chat-completion.ts b/src/services/ai/providers/openai-chat-completion.ts index efd461c..a283404 100644 --- a/src/services/ai/providers/openai-chat-completion.ts +++ b/src/services/ai/providers/openai-chat-completion.ts @@ -1,377 +1,39 @@ -import { BaseAIProvider, type ToolCallResult, applySafeExtraParams } from "./base-provider.js"; -import { AISessionManager } from "../session/ai-session-manager.js"; +import type { ProviderConfig } from "./base-provider.js"; +import type { AISessionManager } from "../session/ai-session-manager.js"; +import type { AIProviderType } from "../session/session-types.js"; import type { ChatCompletionTool } from "../tools/tool-schema.js"; -import { log } from "../../logger.js"; -import { UserProfileValidator } from "../validators/user-profile-validator.js"; +import { OpenAIChatCompletionBaseProvider } from "./openai-chat-completion-base.js"; -interface ToolCallResponse { - choices: Array<{ - message: { - content?: string; - tool_calls?: Array<{ - id: string; - type: string; - function: { - name: string; - arguments: string; - }; - }>; - }; - finish_reason?: string; - }>; -} - -export class OpenAIChatCompletionProvider extends BaseAIProvider { - private aiSessionManager: AISessionManager; - - constructor(config: any, aiSessionManager: AISessionManager) { - super(config); - this.aiSessionManager = aiSessionManager; +export class OpenAIChatCompletionProvider extends OpenAIChatCompletionBaseProvider { + constructor(config: ProviderConfig, aiSessionManager: AISessionManager) { + super(config, aiSessionManager); } getProviderName(): string { return "openai-chat"; } - supportsSession(): boolean { - return true; + protected getSessionProviderType(): AIProviderType { + return "openai-chat"; } - private addToolResponse( - sessionId: string, - messages: any[], - toolCallId: string, - content: string - ): void { - const sequence = this.aiSessionManager.getLastSequence(sessionId) + 1; - this.aiSessionManager.addMessage({ - aiSessionId: sessionId, - sequence, - role: "tool", - content, - toolCallId, - }); - messages.push({ - role: "tool", - tool_call_id: toolCallId, - content, - }); + protected getApiErrorLogLabel(): string { + return "OpenAI Chat Completion API error"; } - private filterIncompleteToolCallSequences(messages: any[]): any[] { - const result: any[] = []; - let i = 0; - - while (i < messages.length) { - const msg = messages[i]; - - if (msg.role === "assistant" && msg.toolCalls && msg.toolCalls.length > 0) { - const toolCallIds = new Set(msg.toolCalls.map((tc: any) => tc.id)); - const toolResponses: any[] = []; - let j = i + 1; - - while (j < messages.length && messages[j].role === "tool") { - if (toolCallIds.has(messages[j].toolCallId)) { - toolResponses.push(messages[j]); - toolCallIds.delete(messages[j].toolCallId); - } - j++; - } - - if (toolCallIds.size === 0) { - result.push(msg); - toolResponses.forEach((tr) => result.push(tr)); - i = j; - } else { - break; - } - } else { - result.push(msg); - i++; - } - } - - return result; + protected getResponseBodyErrorLogLabel(): string { + return "API returned error in response body"; } - async executeToolCall( - systemPrompt: string, - userPrompt: string, - toolSchema: ChatCompletionTool, - sessionId: string - ): Promise { - let session = this.aiSessionManager.getSession(sessionId, "openai-chat"); - - if (!session) { - session = this.aiSessionManager.createSession({ - provider: "openai-chat", - sessionId, - }); - } - - const existingMessages = this.aiSessionManager.getMessages(session.id); - const messages: any[] = []; - - const validatedMessages = this.filterIncompleteToolCallSequences(existingMessages); - - for (const msg of validatedMessages) { - const apiMsg: any = { - role: msg.role, - content: msg.content, - }; - - if (msg.toolCalls) { - apiMsg.tool_calls = msg.toolCalls; - } - - if (msg.toolCallId) { - apiMsg.tool_call_id = msg.toolCallId; - } - - messages.push(apiMsg); - } - - if (messages.length === 0) { - const sequence = this.aiSessionManager.getLastSequence(session.id) + 1; - this.aiSessionManager.addMessage({ - aiSessionId: session.id, - sequence, - role: "system", - content: systemPrompt, - }); - - messages.push({ role: "system", content: systemPrompt }); - } - - const userSequence = this.aiSessionManager.getLastSequence(session.id) + 1; - this.aiSessionManager.addMessage({ - aiSessionId: session.id, - sequence: userSequence, - role: "user", - content: userPrompt, - }); - - messages.push({ role: "user", content: userPrompt }); - - let iterations = 0; - const maxIterations = this.config.maxIterations ?? 5; - const iterationTimeout = this.config.iterationTimeout ?? 30000; - - while (iterations < maxIterations) { - iterations++; - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), iterationTimeout); - - try { - const requestBody: any = { - model: this.config.model, - messages, - tools: [toolSchema], - tool_choice: "auto", - }; - - if (this.config.memoryTemperature !== false) { - requestBody.temperature = this.config.memoryTemperature ?? 0.3; - } - - if (this.config.extraParams) { - applySafeExtraParams(requestBody, this.config.extraParams); - } - - const headers: Record = { - "Content-Type": "application/json", - }; - - if (this.config.apiKey) { - headers.Authorization = `Bearer ${this.config.apiKey}`; - } - - const response = await fetch(`${this.config.apiUrl}/chat/completions`, { - method: "POST", - headers, - body: JSON.stringify(requestBody), - signal: controller.signal, - }); - - clearTimeout(timeout); - - if (!response.ok) { - const errorText = await response.text().catch(() => response.statusText); - log("OpenAI Chat Completion API error", { - provider: this.getProviderName(), - model: this.config.model, - status: response.status, - error: errorText, - iteration: iterations, - }); - - let errorMessage = `API error: ${response.status} - ${errorText}`; - - if ( - response.status === 400 && - errorText.includes("unsupported_value") && - errorText.includes("temperature") - ) { - errorMessage = - 'Your model does not support the temperature parameter. Add "memoryTemperature": false to your config file to disable it.'; - } - - return { - success: false, - error: errorMessage, - iterations, - }; - } - - const data = (await response.json()) as any; - - if (data.status && data.msg) { - log("API returned error in response body", { - provider: this.getProviderName(), - model: this.config.model, - status: data.status, - msg: data.msg, - }); - return { - success: false, - error: `API error: ${data.status} - ${data.msg}`, - iterations, - }; - } - - if (!data.choices || !data.choices[0]) { - log("Invalid API response format", { - provider: this.getProviderName(), - model: this.config.model, - response: JSON.stringify(data).slice(0, 1000), - hasChoices: !!data.choices, - choicesLength: data.choices?.length, - }); - return { - success: false, - error: "Invalid API response format", - iterations, - }; - } - - const choice = data.choices[0]; - - const assistantSequence = this.aiSessionManager.getLastSequence(session.id) + 1; - const assistantMsg: any = { - aiSessionId: session.id, - sequence: assistantSequence, - role: "assistant", - content: choice.message.content || "", - }; - - if (choice.message.tool_calls) { - assistantMsg.toolCalls = choice.message.tool_calls; - } - - this.aiSessionManager.addMessage(assistantMsg); - messages.push(choice.message); - - if (choice.message.tool_calls && choice.message.tool_calls.length > 0) { - for (const toolCall of choice.message.tool_calls) { - const toolCallId = toolCall.id; - - if (toolCall.function.name === toolSchema.function.name) { - try { - const parsed = JSON.parse(toolCall.function.arguments); - const result = UserProfileValidator.validate(parsed); - if (!result.valid) { - throw new Error(result.errors.join(", ")); - } - - this.addToolResponse( - session.id, - messages, - toolCallId, - JSON.stringify({ success: true }) - ); - - return { - success: true, - data: result.data, - iterations, - }; - } catch (validationError) { - const errorStack = - validationError instanceof Error ? validationError.stack : undefined; - log("OpenAI tool response validation failed", { - error: String(validationError), - stack: errorStack, - errorType: - validationError instanceof Error - ? validationError.constructor.name - : typeof validationError, - toolName: toolSchema.function.name, - iteration: iterations, - rawArguments: toolCall.function.arguments.slice(0, 500), - }); - - const errorMessage = `Validation failed: ${String(validationError)}`; - this.addToolResponse( - session.id, - messages, - toolCallId, - JSON.stringify({ success: false, error: errorMessage }) - ); - - return { - success: false, - error: errorMessage, - iterations, - }; - } - } - - const wrongToolMessage = `Wrong tool called. Please use ${toolSchema.function.name} instead.`; - this.addToolResponse( - session.id, - messages, - toolCallId, - JSON.stringify({ success: false, error: wrongToolMessage }) - ); - - break; - } - } - - const retrySequence = this.aiSessionManager.getLastSequence(session.id) + 1; - const retryPrompt = - "Please use the save_memories tool to extract and save the memories from the conversation as instructed."; - - this.aiSessionManager.addMessage({ - aiSessionId: session.id, - sequence: retrySequence, - role: "user", - content: retryPrompt, - }); + protected getInvalidResponseLogLabel(): string { + return "Invalid API response format"; + } - messages.push({ role: "user", content: retryPrompt }); - } catch (error) { - clearTimeout(timeout); - if (error instanceof Error && error.name === "AbortError") { - return { - success: false, - error: `API request timeout (${this.config.iterationTimeout}ms)`, - iterations, - }; - } - return { - success: false, - error: String(error), - iterations, - }; - } - } + protected getToolValidationLogLabel(): string { + return "OpenAI tool response validation failed"; + } - return { - success: false, - error: `Max iterations (${this.config.maxIterations}) reached without tool call`, - iterations, - }; + protected buildRetryPrompt(_toolSchema: ChatCompletionTool): string { + return "Please use the save_memories tool to extract and save the memories from the conversation as instructed."; } } From b3cfb293f3594d2e910f93b225b6418cfab62fec Mon Sep 17 00:00:00 2001 From: machen Date: Wed, 8 Apr 2026 12:27:26 +0800 Subject: [PATCH 09/12] refactor(ai): make DeepSeek provider extend chat completion base --- src/services/ai/providers/deepseek.ts | 387 +----------------- .../providers/openai-chat-completion-base.ts | 4 +- 2 files changed, 23 insertions(+), 368 deletions(-) diff --git a/src/services/ai/providers/deepseek.ts b/src/services/ai/providers/deepseek.ts index c34496e..ed31fcc 100644 --- a/src/services/ai/providers/deepseek.ts +++ b/src/services/ai/providers/deepseek.ts @@ -1,384 +1,39 @@ -import { BaseAIProvider, type ToolCallResult, applySafeExtraParams } from "./base-provider.js"; -import { AISessionManager } from "../session/ai-session-manager.js"; +import type { ProviderConfig } from "./base-provider.js"; +import type { AISessionManager } from "../session/ai-session-manager.js"; +import type { AIProviderType } from "../session/session-types.js"; import type { ChatCompletionTool } from "../tools/tool-schema.js"; -import { log } from "../../logger.js"; -import { UserProfileValidator } from "../validators/user-profile-validator.js"; +import { OpenAIChatCompletionBaseProvider } from "./openai-chat-completion-base.js"; -interface DeepSeekToolCall { - id: string; - type: string; - function: { - name: string; - arguments: string; - }; -} - -interface DeepSeekErrorResponse { - status?: unknown; - msg?: unknown; -} - -interface DeepSeekResponse { - choices: Array<{ - message: { - content?: string; - tool_calls?: DeepSeekToolCall[]; - }; - finish_reason?: string; - }>; -} - -export class DeepSeekProvider extends BaseAIProvider { - private aiSessionManager: AISessionManager; - - constructor(config: any, aiSessionManager: AISessionManager) { - super(config); - this.aiSessionManager = aiSessionManager; +export class DeepSeekProvider extends OpenAIChatCompletionBaseProvider { + constructor(config: ProviderConfig, aiSessionManager: AISessionManager) { + super(config, aiSessionManager); } getProviderName(): string { return "deepseek"; } - supportsSession(): boolean { - return true; + protected getSessionProviderType(): AIProviderType { + return "deepseek"; } - private addToolResponse( - sessionId: string, - messages: any[], - toolCallId: string, - content: string - ): void { - const sequence = this.aiSessionManager.getLastSequence(sessionId) + 1; - this.aiSessionManager.addMessage({ - aiSessionId: sessionId, - sequence, - role: "tool", - content, - toolCallId, - }); - messages.push({ - role: "tool", - tool_call_id: toolCallId, - content, - }); + protected getApiErrorLogLabel(): string { + return "DeepSeek API error"; } - private filterIncompleteToolCallSequences(messages: any[]): any[] { - const result: any[] = []; - let i = 0; - - while (i < messages.length) { - const msg = messages[i]; - - if (msg.role === "assistant" && msg.toolCalls && msg.toolCalls.length > 0) { - const toolCallIds = new Set(msg.toolCalls.map((tc: any) => tc.id)); - const toolResponses: any[] = []; - let j = i + 1; - - while (j < messages.length && messages[j].role === "tool") { - if (toolCallIds.has(messages[j].toolCallId)) { - toolResponses.push(messages[j]); - toolCallIds.delete(messages[j].toolCallId); - } - j++; - } - - if (toolCallIds.size === 0) { - result.push(msg); - toolResponses.forEach((tr) => result.push(tr)); - i = j; - } else { - break; - } - } else { - result.push(msg); - i++; - } - } - - return result; + protected getResponseBodyErrorLogLabel(): string { + return "DeepSeek API returned error in response body"; } - async executeToolCall( - systemPrompt: string, - userPrompt: string, - toolSchema: ChatCompletionTool, - sessionId: string - ): Promise { - let session = this.aiSessionManager.getSession(sessionId, "deepseek"); - - if (!session) { - session = this.aiSessionManager.createSession({ - provider: "deepseek", - sessionId, - }); - } - - const existingMessages = this.aiSessionManager.getMessages(session.id); - const messages: any[] = []; - - const validatedMessages = this.filterIncompleteToolCallSequences(existingMessages); - - for (const msg of validatedMessages) { - const apiMsg: any = { - role: msg.role, - content: msg.content, - }; - - if (msg.toolCalls) { - apiMsg.tool_calls = msg.toolCalls; - } - - if (msg.toolCallId) { - apiMsg.tool_call_id = msg.toolCallId; - } - - messages.push(apiMsg); - } - - if (messages.length === 0) { - const sequence = this.aiSessionManager.getLastSequence(session.id) + 1; - this.aiSessionManager.addMessage({ - aiSessionId: session.id, - sequence, - role: "system", - content: systemPrompt, - }); - - messages.push({ role: "system", content: systemPrompt }); - } - - const userSequence = this.aiSessionManager.getLastSequence(session.id) + 1; - this.aiSessionManager.addMessage({ - aiSessionId: session.id, - sequence: userSequence, - role: "user", - content: userPrompt, - }); - - messages.push({ role: "user", content: userPrompt }); - - let iterations = 0; - const maxIterations = this.config.maxIterations ?? 5; - const iterationTimeout = this.config.iterationTimeout ?? 30000; - - while (iterations < maxIterations) { - iterations++; - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), iterationTimeout); - - try { - const requestBody: any = { - model: this.config.model, - messages, - tools: [toolSchema], - tool_choice: "auto", - }; - - if (this.config.memoryTemperature !== false) { - requestBody.temperature = this.config.memoryTemperature ?? 0.3; - } - - if (this.config.extraParams) { - applySafeExtraParams(requestBody, this.config.extraParams); - } - - const headers: Record = { - "Content-Type": "application/json", - }; - - if (this.config.apiKey) { - headers.Authorization = `Bearer ${this.config.apiKey}`; - } - - const response = await fetch(`${this.config.apiUrl}/chat/completions`, { - method: "POST", - headers, - body: JSON.stringify(requestBody), - signal: controller.signal, - }); - - clearTimeout(timeout); - - if (!response.ok) { - const errorText = await response.text().catch(() => response.statusText); - log("DeepSeek API error", { - provider: this.getProviderName(), - model: this.config.model, - status: response.status, - error: errorText, - iteration: iterations, - }); - - let errorMessage = `API error: ${response.status} - ${errorText}`; - - if ( - response.status === 400 && - errorText.includes("unsupported_value") && - errorText.includes("temperature") - ) { - errorMessage = - 'Your model does not support the temperature parameter. Add "memoryTemperature": false to your config file to disable it.'; - } - - return { - success: false, - error: errorMessage, - iterations, - }; - } - - const data = (await response.json()) as DeepSeekErrorResponse & Partial; - - if (data.status && data.msg) { - log("DeepSeek API returned error in response body", { - provider: this.getProviderName(), - model: this.config.model, - status: data.status, - msg: data.msg, - }); - return { - success: false, - error: `API error: ${data.status} - ${data.msg}`, - iterations, - }; - } - - if (!data.choices || !data.choices[0]) { - log("Invalid DeepSeek API response format", { - provider: this.getProviderName(), - model: this.config.model, - response: JSON.stringify(data).slice(0, 1000), - hasChoices: !!data.choices, - choicesLength: data.choices?.length, - }); - return { - success: false, - error: "Invalid API response format", - iterations, - }; - } - - const response_data = data as DeepSeekResponse; - const choice = response_data.choices[0] as DeepSeekResponse["choices"][0]; - - const assistantSequence = this.aiSessionManager.getLastSequence(session.id) + 1; - const assistantMsg: any = { - aiSessionId: session.id, - sequence: assistantSequence, - role: "assistant", - content: choice.message.content || "", - }; - - if (choice.message.tool_calls) { - assistantMsg.toolCalls = choice.message.tool_calls; - } - - this.aiSessionManager.addMessage(assistantMsg); - messages.push(choice.message); - - if (choice.message.tool_calls && choice.message.tool_calls.length > 0) { - for (const toolCall of choice.message.tool_calls) { - const toolCallId = toolCall.id; - - if (toolCall.function.name === toolSchema.function.name) { - try { - const parsed = JSON.parse(toolCall.function.arguments); - const result = UserProfileValidator.validate(parsed); - if (!result.valid) { - throw new Error(result.errors.join(", ")); - } - - this.addToolResponse( - session.id, - messages, - toolCallId, - JSON.stringify({ success: true }) - ); - - return { - success: true, - data: result.data, - iterations, - }; - } catch (validationError) { - const errorStack = - validationError instanceof Error ? validationError.stack : undefined; - log("DeepSeek tool response validation failed", { - error: String(validationError), - stack: errorStack, - errorType: - validationError instanceof Error - ? validationError.constructor.name - : typeof validationError, - toolName: toolSchema.function.name, - iteration: iterations, - rawArguments: toolCall.function.arguments.slice(0, 500), - }); - - const errorMessage = `Validation failed: ${String(validationError)}`; - this.addToolResponse( - session.id, - messages, - toolCallId, - JSON.stringify({ success: false, error: errorMessage }) - ); - - return { - success: false, - error: errorMessage, - iterations, - }; - } - } - - const wrongToolMessage = `Wrong tool called. Please use ${toolSchema.function.name} instead.`; - this.addToolResponse( - session.id, - messages, - toolCallId, - JSON.stringify({ success: false, error: wrongToolMessage }) - ); - - break; - } - } - - const retrySequence = this.aiSessionManager.getLastSequence(session.id) + 1; - const retryPrompt = `Please use the ${toolSchema.function.name} tool to extract and save the memories from the conversation as instructed.`; - - this.aiSessionManager.addMessage({ - aiSessionId: session.id, - sequence: retrySequence, - role: "user", - content: retryPrompt, - }); + protected getInvalidResponseLogLabel(): string { + return "Invalid DeepSeek API response format"; + } - messages.push({ role: "user", content: retryPrompt }); - } catch (error) { - clearTimeout(timeout); - if (error instanceof Error && error.name === "AbortError") { - return { - success: false, - error: `API request timeout (${iterationTimeout}ms)`, - iterations, - }; - } - return { - success: false, - error: String(error), - iterations, - }; - } - } + protected getToolValidationLogLabel(): string { + return "DeepSeek tool response validation failed"; + } - return { - success: false, - error: `Max iterations (${maxIterations}) reached without tool call`, - iterations, - }; + protected buildRetryPrompt(toolSchema: ChatCompletionTool): string { + return `Please use the ${toolSchema.function.name} tool to extract and save the memories from the conversation as instructed.`; } } diff --git a/src/services/ai/providers/openai-chat-completion-base.ts b/src/services/ai/providers/openai-chat-completion-base.ts index fa8e844..c9ac128 100644 --- a/src/services/ai/providers/openai-chat-completion-base.ts +++ b/src/services/ai/providers/openai-chat-completion-base.ts @@ -380,7 +380,7 @@ export abstract class OpenAIChatCompletionBaseProvider extends BaseAIProvider { if (error instanceof Error && error.name === "AbortError") { return { success: false, - error: `API request timeout (${this.config.iterationTimeout}ms)`, + error: `API request timeout (${iterationTimeout}ms)`, iterations, }; } @@ -394,7 +394,7 @@ export abstract class OpenAIChatCompletionBaseProvider extends BaseAIProvider { return { success: false, - error: `Max iterations (${this.config.maxIterations}) reached without tool call`, + error: `Max iterations (${maxIterations}) reached without tool call`, iterations, }; } From b879d56ee90415d431af567c2adf463e13f96e41 Mon Sep 17 00:00:00 2001 From: machen Date: Wed, 8 Apr 2026 16:01:48 +0800 Subject: [PATCH 10/12] test(ai): add coverage for incomplete tool call sequences --- tests/deepseek-provider.test.ts | 107 ++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/tests/deepseek-provider.test.ts b/tests/deepseek-provider.test.ts index 60efab9..0c772a8 100644 --- a/tests/deepseek-provider.test.ts +++ b/tests/deepseek-provider.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from "bun:test"; import { DeepSeekProvider } from "../src/services/ai/providers/deepseek.js"; import type { ChatCompletionTool } from "../src/services/ai/tools/tool-schema.js"; +import type { AIMessage } from "../src/services/ai/session/session-types.js"; const toolSchema: ChatCompletionTool = { type: "function", @@ -40,6 +41,12 @@ class FakeSessionManager { } } +class TestableDeepSeekProvider extends DeepSeekProvider { + filterMessages(messages: AIMessage[]): AIMessage[] { + return this.filterIncompleteToolCallSequences(messages); + } +} + function makeProvider(config: Record = {}) { return new DeepSeekProvider( { model: "deepseek-chat", apiKey: "test-key", ...config }, @@ -47,6 +54,13 @@ function makeProvider(config: Record = {}) { ); } +function makeTestableProvider(config: Record = {}) { + return new TestableDeepSeekProvider( + { model: "deepseek-chat", apiKey: "test-key", ...config }, + new FakeSessionManager() as any + ); +} + function makeFetch(response: { ok?: boolean; status?: number; @@ -82,6 +96,99 @@ describe("DeepSeekProvider", () => { expect(makeProvider().supportsSession()).toBe(true); }); + it("keeps complete tool call sequences", () => { + const messages: AIMessage[] = [ + { + aiSessionId: "session-1", + sequence: 0, + role: "assistant", + content: "", + toolCalls: [ + { + id: "call-1", + type: "function", + function: { name: "save_memories", arguments: "{}" }, + }, + ], + createdAt: 1, + }, + { + aiSessionId: "session-1", + sequence: 1, + role: "tool", + content: '{"success":true}', + toolCallId: "call-1", + createdAt: 2, + }, + ]; + + expect(makeTestableProvider().filterMessages(messages)).toEqual(messages); + }); + + it("drops trailing incomplete tool call sequences", () => { + const messages: AIMessage[] = [ + { + aiSessionId: "session-1", + sequence: 0, + role: "assistant", + content: "", + toolCalls: [ + { + id: "call-1", + type: "function", + function: { name: "save_memories", arguments: "{}" }, + }, + ], + createdAt: 1, + }, + ]; + + expect(makeTestableProvider().filterMessages(messages)).toEqual([]); + }); + + it("keeps complete prefix and drops later incomplete tool call sequences", () => { + const messages: AIMessage[] = [ + { + aiSessionId: "session-1", + sequence: 0, + role: "assistant", + content: "", + toolCalls: [ + { + id: "call-1", + type: "function", + function: { name: "save_memories", arguments: "{}" }, + }, + ], + createdAt: 1, + }, + { + aiSessionId: "session-1", + sequence: 1, + role: "tool", + content: '{"success":true}', + toolCallId: "call-1", + createdAt: 2, + }, + { + aiSessionId: "session-1", + sequence: 2, + role: "assistant", + content: "", + toolCalls: [ + { + id: "call-2", + type: "function", + function: { name: "save_memories", arguments: "{}" }, + }, + ], + createdAt: 3, + }, + ]; + + expect(makeTestableProvider().filterMessages(messages)).toEqual(messages.slice(0, 2)); + }); + it("uses provided apiUrl for the request", async () => { let capturedUrl = ""; globalThis.fetch = (async (input: RequestInfo | URL, _init?: RequestInit) => { From 1cfc913167cfecba9577483bc66ee7c6ef2fa379 Mon Sep 17 00:00:00 2001 From: machen Date: Wed, 8 Apr 2026 16:23:26 +0800 Subject: [PATCH 11/12] refactor(ai): tighten types in chat completion base --- .../providers/openai-chat-completion-base.ts | 70 +++++++++++++++---- 1 file changed, 57 insertions(+), 13 deletions(-) diff --git a/src/services/ai/providers/openai-chat-completion-base.ts b/src/services/ai/providers/openai-chat-completion-base.ts index c9ac128..f687062 100644 --- a/src/services/ai/providers/openai-chat-completion-base.ts +++ b/src/services/ai/providers/openai-chat-completion-base.ts @@ -16,7 +16,7 @@ interface ToolCallResponse { content?: string; tool_calls?: Array<{ id: string; - type: string; + type: "function"; function: { name: string; arguments: string; @@ -27,6 +27,42 @@ interface ToolCallResponse { }>; } +type APIMessage = { + role: AIMessage["role"]; + content: string; + tool_calls?: ToolCallResponse["choices"][number]["message"]["tool_calls"]; + tool_call_id?: string; +}; + +type RequestBody = { + model: string; + messages: APIMessage[]; + tools: ChatCompletionTool[]; + tool_choice: "auto"; + temperature?: number; + [key: string]: unknown; +}; + +type AssistantSessionMessage = Omit; + +function isErrorResponseBody(data: unknown): data is { status: string; msg: string } { + return ( + typeof data === "object" && + data !== null && + typeof (data as { status?: unknown }).status === "string" && + typeof (data as { msg?: unknown }).msg === "string" + ); +} + +function isToolCallResponse(data: unknown): data is ToolCallResponse { + return ( + typeof data === "object" && + data !== null && + Array.isArray((data as { choices?: unknown }).choices) && + (data as { choices: unknown[] }).choices.length > 0 + ); +} + export abstract class OpenAIChatCompletionBaseProvider extends BaseAIProvider { protected constructor( config: ProviderConfig, @@ -53,7 +89,7 @@ export abstract class OpenAIChatCompletionBaseProvider extends BaseAIProvider { private addToolResponse( sessionId: string, - messages: any[], + messages: APIMessage[], toolCallId: string, content: string ): void { @@ -128,12 +164,12 @@ export abstract class OpenAIChatCompletionBaseProvider extends BaseAIProvider { } const existingMessages = this.aiSessionManager.getMessages(session.id); - const messages: any[] = []; + const messages: APIMessage[] = []; const validatedMessages = this.filterIncompleteToolCallSequences(existingMessages); for (const msg of validatedMessages) { - const apiMsg: any = { + const apiMsg: APIMessage = { role: msg.role, content: msg.content, }; @@ -182,7 +218,7 @@ export abstract class OpenAIChatCompletionBaseProvider extends BaseAIProvider { const timeout = setTimeout(() => controller.abort(), iterationTimeout); try { - const requestBody: any = { + const requestBody: RequestBody = { model: this.config.model, messages, tools: [toolSchema], @@ -242,9 +278,9 @@ export abstract class OpenAIChatCompletionBaseProvider extends BaseAIProvider { }; } - const data = (await response.json()) as any; + const data: unknown = await response.json(); - if (data.status && data.msg) { + if (isErrorResponseBody(data)) { log(this.getResponseBodyErrorLogLabel(), { provider: this.getProviderName(), model: this.config.model, @@ -258,13 +294,17 @@ export abstract class OpenAIChatCompletionBaseProvider extends BaseAIProvider { }; } - if (!data.choices || !data.choices[0]) { + if (!isToolCallResponse(data)) { + const choices = + typeof data === "object" && data !== null + ? (data as { choices?: unknown }).choices + : undefined; log(this.getInvalidResponseLogLabel(), { provider: this.getProviderName(), model: this.config.model, response: JSON.stringify(data).slice(0, 1000), - hasChoices: !!data.choices, - choicesLength: data.choices?.length, + hasChoices: Array.isArray(choices), + choicesLength: Array.isArray(choices) ? choices.length : undefined, }); return { success: false, @@ -273,7 +313,7 @@ export abstract class OpenAIChatCompletionBaseProvider extends BaseAIProvider { }; } - const choice = (data as ToolCallResponse).choices[0]; + const choice = data.choices[0]; if (!choice) { return { success: false, @@ -283,7 +323,7 @@ export abstract class OpenAIChatCompletionBaseProvider extends BaseAIProvider { } const assistantSequence = this.aiSessionManager.getLastSequence(session.id) + 1; - const assistantMsg: any = { + const assistantMsg: AssistantSessionMessage = { aiSessionId: session.id, sequence: assistantSequence, role: "assistant", @@ -295,7 +335,11 @@ export abstract class OpenAIChatCompletionBaseProvider extends BaseAIProvider { } this.aiSessionManager.addMessage(assistantMsg); - messages.push(choice.message); + messages.push({ + role: "assistant", + content: choice.message.content || "", + tool_calls: choice.message.tool_calls, + }); if (choice.message.tool_calls && choice.message.tool_calls.length > 0) { for (const toolCall of choice.message.tool_calls) { From b423b0f7c7e08dd4c9e39169705a80f3dcedbc93 Mon Sep 17 00:00:00 2001 From: machen Date: Thu, 9 Apr 2026 15:11:12 +0800 Subject: [PATCH 12/12] refactor(ai): remove dedicated DeepSeek provider surface Consolidate OpenAI-compatible usage under the generic openai-chat path and update config guidance accordingly. Preserve shared provider behavior coverage by renaming and adapting tests to the OpenAI chat completion provider. --- src/config.ts | 27 ++-- src/services/ai/ai-provider-factory.ts | 6 +- src/services/ai/providers/deepseek.ts | 39 ----- src/services/ai/session/session-types.ts | 7 +- ...> openai-chat-completion-provider.test.ts} | 149 +++++++++--------- 5 files changed, 95 insertions(+), 133 deletions(-) delete mode 100644 src/services/ai/providers/deepseek.ts rename tests/{deepseek-provider.test.ts => openai-chat-completion-provider.test.ts} (81%) diff --git a/src/config.ts b/src/config.ts index 4980fc8..038f3e3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -36,7 +36,7 @@ interface OpenCodeMemConfig { autoCaptureMaxIterations?: number; autoCaptureIterationTimeout?: number; autoCaptureLanguage?: string; - memoryProvider?: "openai-chat" | "openai-responses" | "anthropic" | "deepseek"; + memoryProvider?: "openai-chat" | "openai-responses" | "anthropic"; memoryModel?: string; memoryApiUrl?: string; memoryApiKey?: string; @@ -99,7 +99,7 @@ const DEFAULTS: Required< memoryModel?: string; memoryApiUrl?: string; memoryApiKey?: string; - memoryProvider?: "openai-chat" | "openai-responses" | "anthropic" | "deepseek"; + memoryProvider?: "openai-chat" | "openai-responses" | "anthropic"; memoryTemperature?: number | false; memoryExtraParams?: Record; opencodeProvider?: string; @@ -267,7 +267,9 @@ const CONFIG_TEMPLATE = `{ "autoCaptureEnabled": true, - // Provider type: "openai-chat" | "openai-responses" | "anthropic" | "deepseek" + // Provider type: "openai-chat" | "openai-responses" | "anthropic" + // Note: "openai-chat" is a generic OpenAI API-compatible mode. + // Any service that follows the OpenAI Chat Completions API can use it via custom "memoryApiUrl". "memoryProvider": "openai-chat", // REQUIRED for auto-capture (all 3 must be set): @@ -281,11 +283,21 @@ const CONFIG_TEMPLATE = `{ // From env variable: "env://LITELLM_API_KEY" // Examples for different providers: + // Any OpenAI-compatible endpoint can use the "openai-chat" provider pattern below. + // Common examples: DeepSeek, Qwen (via Alibaba Cloud ModelStudio), + // Zhipu GLM (BigModel platform), and Kimi (Moonshot AI platform). + // OpenAI Chat Completion (default, backward compatible): // "memoryProvider": "openai-chat" // "memoryModel": "gpt-4o-mini" // "memoryApiUrl": "https://api.openai.com/v1" // "memoryApiKey": "sk-..." + + // DeepSeek (OpenAI-compatible example): + // "memoryProvider": "openai-chat" + // "memoryModel": "deepseek-chat" + // "memoryApiUrl": "https://api.deepseek.com/v1" + // "memoryApiKey": "sk-..." // OpenAI Responses API (recommended, with session support): // "memoryProvider": "openai-responses" @@ -305,12 +317,6 @@ const CONFIG_TEMPLATE = `{ // "memoryApiUrl": "https://api.groq.com/openai/v1" // "memoryApiKey": "gsk_..." - // DeepSeek (with session support): - // "memoryProvider": "deepseek" - // "memoryModel": "deepseek-chat" - // "memoryApiUrl": "https://api.deepseek.com" - // "memoryApiKey": "sk-..." - // Maximum iterations for multi-turn AI analysis (for openai-responses and anthropic) "autoCaptureMaxIterations": 5, @@ -482,8 +488,7 @@ function buildConfig(fileConfig: OpenCodeMemConfig) { memoryProvider: (fileConfig.memoryProvider ?? "openai-chat") as | "openai-chat" | "openai-responses" - | "anthropic" - | "deepseek", + | "anthropic", memoryModel: fileConfig.memoryModel, memoryApiUrl: fileConfig.memoryApiUrl, memoryApiKey: resolveSecretValue(fileConfig.memoryApiKey), diff --git a/src/services/ai/ai-provider-factory.ts b/src/services/ai/ai-provider-factory.ts index ebb0c79..2935927 100644 --- a/src/services/ai/ai-provider-factory.ts +++ b/src/services/ai/ai-provider-factory.ts @@ -3,7 +3,6 @@ import { OpenAIChatCompletionProvider } from "./providers/openai-chat-completion import { OpenAIResponsesProvider } from "./providers/openai-responses.js"; import { AnthropicMessagesProvider } from "./providers/anthropic-messages.js"; import { GoogleGeminiProvider } from "./providers/google-gemini.js"; -import { DeepSeekProvider } from "./providers/deepseek.js"; import { aiSessionManager } from "./session/ai-session-manager.js"; import type { AIProviderType } from "./session/session-types.js"; @@ -22,16 +21,13 @@ export class AIProviderFactory { case "google-gemini": return new GoogleGeminiProvider(config, aiSessionManager); - case "deepseek": - return new DeepSeekProvider(config, aiSessionManager); - default: throw new Error(`Unknown provider type: ${providerType}`); } } static getSupportedProviders(): AIProviderType[] { - return ["openai-chat", "openai-responses", "anthropic", "google-gemini", "deepseek"]; + return ["openai-chat", "openai-responses", "anthropic", "google-gemini"]; } static cleanupExpiredSessions(): number { diff --git a/src/services/ai/providers/deepseek.ts b/src/services/ai/providers/deepseek.ts deleted file mode 100644 index ed31fcc..0000000 --- a/src/services/ai/providers/deepseek.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { ProviderConfig } from "./base-provider.js"; -import type { AISessionManager } from "../session/ai-session-manager.js"; -import type { AIProviderType } from "../session/session-types.js"; -import type { ChatCompletionTool } from "../tools/tool-schema.js"; -import { OpenAIChatCompletionBaseProvider } from "./openai-chat-completion-base.js"; - -export class DeepSeekProvider extends OpenAIChatCompletionBaseProvider { - constructor(config: ProviderConfig, aiSessionManager: AISessionManager) { - super(config, aiSessionManager); - } - - getProviderName(): string { - return "deepseek"; - } - - protected getSessionProviderType(): AIProviderType { - return "deepseek"; - } - - protected getApiErrorLogLabel(): string { - return "DeepSeek API error"; - } - - protected getResponseBodyErrorLogLabel(): string { - return "DeepSeek API returned error in response body"; - } - - protected getInvalidResponseLogLabel(): string { - return "Invalid DeepSeek API response format"; - } - - protected getToolValidationLogLabel(): string { - return "DeepSeek tool response validation failed"; - } - - protected buildRetryPrompt(toolSchema: ChatCompletionTool): string { - return `Please use the ${toolSchema.function.name} tool to extract and save the memories from the conversation as instructed.`; - } -} diff --git a/src/services/ai/session/session-types.ts b/src/services/ai/session/session-types.ts index 072c6b0..64c6cd0 100644 --- a/src/services/ai/session/session-types.ts +++ b/src/services/ai/session/session-types.ts @@ -1,9 +1,4 @@ -export type AIProviderType = - | "openai-chat" - | "openai-responses" - | "anthropic" - | "google-gemini" - | "deepseek"; +export type AIProviderType = "openai-chat" | "openai-responses" | "anthropic" | "google-gemini"; export interface AIMessage { id?: number; diff --git a/tests/deepseek-provider.test.ts b/tests/openai-chat-completion-provider.test.ts similarity index 81% rename from tests/deepseek-provider.test.ts rename to tests/openai-chat-completion-provider.test.ts index 0c772a8..a660876 100644 --- a/tests/deepseek-provider.test.ts +++ b/tests/openai-chat-completion-provider.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { DeepSeekProvider } from "../src/services/ai/providers/deepseek.js"; +import { OpenAIChatCompletionProvider } from "../src/services/ai/providers/openai-chat-completion.js"; import type { ChatCompletionTool } from "../src/services/ai/tools/tool-schema.js"; import type { AIMessage } from "../src/services/ai/session/session-types.js"; @@ -41,22 +41,22 @@ class FakeSessionManager { } } -class TestableDeepSeekProvider extends DeepSeekProvider { +class TestableOpenAIChatCompletionProvider extends OpenAIChatCompletionProvider { filterMessages(messages: AIMessage[]): AIMessage[] { return this.filterIncompleteToolCallSequences(messages); } } function makeProvider(config: Record = {}) { - return new DeepSeekProvider( - { model: "deepseek-chat", apiKey: "test-key", ...config }, + return new OpenAIChatCompletionProvider( + { model: "gpt-4o-mini", apiKey: "test-key", ...config }, new FakeSessionManager() as any ); } function makeTestableProvider(config: Record = {}) { - return new TestableDeepSeekProvider( - { model: "deepseek-chat", apiKey: "test-key", ...config }, + return new TestableOpenAIChatCompletionProvider( + { model: "gpt-4o-mini", apiKey: "test-key", ...config }, new FakeSessionManager() as any ); } @@ -81,15 +81,15 @@ function makeFetch(response: { }) as typeof fetch; } -describe("DeepSeekProvider", () => { +describe("OpenAIChatCompletionProvider", () => { const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; }); - it("getProviderName returns deepseek", () => { - expect(makeProvider().getProviderName()).toBe("deepseek"); + it("getProviderName returns openai-chat", () => { + expect(makeProvider().getProviderName()).toBe("openai-chat"); }); it("supportsSession returns true", () => { @@ -189,38 +189,21 @@ describe("DeepSeekProvider", () => { expect(makeTestableProvider().filterMessages(messages)).toEqual(messages.slice(0, 2)); }); - it("uses provided apiUrl for the request", async () => { + it("uses custom apiUrl for the request", async () => { let capturedUrl = ""; globalThis.fetch = (async (input: RequestInfo | URL, _init?: RequestInit) => { capturedUrl = String(input); return { ok: false, status: 400, statusText: "Bad", text: async () => "err" } as Response; }) as typeof fetch; - await makeProvider({ apiUrl: "https://api.deepseek.com" }).executeToolCall( + await makeProvider({ apiUrl: "https://compatible.example.com/v1" }).executeToolCall( "system", "user", toolSchema, "session-id" ); - expect(capturedUrl).toBe("https://api.deepseek.com/chat/completions"); - }); - - it("respects custom apiUrl when provided", async () => { - let capturedUrl = ""; - globalThis.fetch = (async (input: RequestInfo | URL, _init?: RequestInit) => { - capturedUrl = String(input); - return { ok: false, status: 400, statusText: "Bad", text: async () => "err" } as Response; - }) as typeof fetch; - - await makeProvider({ apiUrl: "https://custom.example.com/v1" }).executeToolCall( - "system", - "user", - toolSchema, - "session-id" - ); - - expect(capturedUrl).toBe("https://custom.example.com/v1/chat/completions"); + expect(capturedUrl).toBe("https://compatible.example.com/v1/chat/completions"); }); it("sends Authorization Bearer header", async () => { @@ -230,7 +213,7 @@ describe("DeepSeekProvider", () => { return { ok: false, status: 400, statusText: "Bad", text: async () => "err" } as Response; }) as typeof fetch; - await makeProvider({ apiKey: "sk-mykey" }).executeToolCall( + await makeProvider({ apiKey: "sk-mykey", apiUrl: "https://api.openai.com/v1" }).executeToolCall( "system", "user", toolSchema, @@ -247,7 +230,7 @@ describe("DeepSeekProvider", () => { return { ok: false, status: 400, statusText: "Bad", text: async () => "err" } as Response; }) as typeof fetch; - await makeProvider({ apiKey: undefined }).executeToolCall( + await makeProvider({ apiKey: undefined, apiUrl: "https://api.openai.com/v1" }).executeToolCall( "system", "user", toolSchema, @@ -264,14 +247,12 @@ describe("DeepSeekProvider", () => { return { ok: false, status: 400, statusText: "Bad", text: async () => "err" } as Response; }) as typeof fetch; - await makeProvider({ model: "deepseek-reasoner" }).executeToolCall( - "system", - "user", - toolSchema, - "session-id" - ); + await makeProvider({ + model: "gpt-4o-mini", + apiUrl: "https://api.openai.com/v1", + }).executeToolCall("system", "user", toolSchema, "session-id"); - expect(capturedBody?.model).toBe("deepseek-reasoner"); + expect(capturedBody?.model).toBe("gpt-4o-mini"); expect(Array.isArray(capturedBody?.messages)).toBe(true); expect(Array.isArray(capturedBody?.tools)).toBe(true); expect(capturedBody?.tool_choice).toBe("auto"); @@ -284,7 +265,12 @@ describe("DeepSeekProvider", () => { return { ok: false, status: 400, statusText: "Bad", text: async () => "err" } as Response; }) as typeof fetch; - await makeProvider().executeToolCall("system", "user", toolSchema, "session-id"); + await makeProvider({ apiUrl: "https://api.openai.com/v1" }).executeToolCall( + "system", + "user", + toolSchema, + "session-id" + ); expect(capturedBody?.temperature).toBe(0.3); }); @@ -296,12 +282,10 @@ describe("DeepSeekProvider", () => { return { ok: false, status: 400, statusText: "Bad", text: async () => "err" } as Response; }) as typeof fetch; - await makeProvider({ memoryTemperature: false }).executeToolCall( - "system", - "user", - toolSchema, - "session-id" - ); + await makeProvider({ + memoryTemperature: false, + apiUrl: "https://api.openai.com/v1", + }).executeToolCall("system", "user", toolSchema, "session-id"); expect(capturedBody?.temperature).toBeUndefined(); }); @@ -309,7 +293,12 @@ describe("DeepSeekProvider", () => { it("returns success: false with error message on API error response", async () => { globalThis.fetch = makeFetch({ ok: false, status: 401, body: "Unauthorized" }); - const result = await makeProvider().executeToolCall("system", "user", toolSchema, "session-id"); + const result = await makeProvider({ apiUrl: "https://api.openai.com/v1" }).executeToolCall( + "system", + "user", + toolSchema, + "session-id" + ); expect(result.success).toBe(false); expect(result.error).toContain("401"); @@ -322,7 +311,12 @@ describe("DeepSeekProvider", () => { body: '{"error": {"type": "unsupported_value", "param": "temperature"}}', }); - const result = await makeProvider().executeToolCall("system", "user", toolSchema, "session-id"); + const result = await makeProvider({ apiUrl: "https://api.openai.com/v1" }).executeToolCall( + "system", + "user", + toolSchema, + "session-id" + ); expect(result.success).toBe(false); expect(result.error).toContain("memoryTemperature"); @@ -331,7 +325,12 @@ describe("DeepSeekProvider", () => { it("returns success: false when response has no choices", async () => { globalThis.fetch = makeFetch({ ok: true, body: { choices: [] } } as any); - const result = await makeProvider().executeToolCall("system", "user", toolSchema, "session-id"); + const result = await makeProvider({ apiUrl: "https://api.openai.com/v1" }).executeToolCall( + "system", + "user", + toolSchema, + "session-id" + ); expect(result.success).toBe(false); expect(result.error).toContain("Invalid API response format"); @@ -343,21 +342,7 @@ describe("DeepSeekProvider", () => { body: { status: "error", msg: "quota exceeded" }, } as any); - const result = await makeProvider().executeToolCall("system", "user", toolSchema, "session-id"); - - expect(result.success).toBe(false); - expect(result.error).toContain("quota exceeded"); - }); - - it("returns success: false after max iterations with no tool call", async () => { - globalThis.fetch = makeFetch({ - ok: true, - body: { - choices: [{ message: { content: "I will not use a tool", tool_calls: undefined } }], - }, - } as any); - - const result = await makeProvider({ maxIterations: 2 }).executeToolCall( + const result = await makeProvider({ apiUrl: "https://api.openai.com/v1" }).executeToolCall( "system", "user", toolSchema, @@ -365,8 +350,7 @@ describe("DeepSeekProvider", () => { ); expect(result.success).toBe(false); - expect(result.error).toContain("Max iterations"); - expect(result.iterations).toBe(2); + expect(result.error).toContain("quota exceeded"); }); it("returns success: true when model calls the correct tool", async () => { @@ -398,12 +382,35 @@ describe("DeepSeekProvider", () => { }, } as any); - const result = await makeProvider().executeToolCall("system", "user", toolSchema, "session-id"); + const result = await makeProvider({ apiUrl: "https://api.openai.com/v1" }).executeToolCall( + "system", + "user", + toolSchema, + "session-id" + ); expect(result.success).toBe(true); expect(result.iterations).toBe(1); }); + it("returns success: false after max iterations with no tool call", async () => { + globalThis.fetch = makeFetch({ + ok: true, + body: { + choices: [{ message: { content: "I will not use a tool", tool_calls: undefined } }], + }, + } as any); + + const result = await makeProvider({ + maxIterations: 2, + apiUrl: "https://api.openai.com/v1", + }).executeToolCall("system", "user", toolSchema, "session-id"); + + expect(result.success).toBe(false); + expect(result.error).toContain("Max iterations"); + expect(result.iterations).toBe(2); + }); + it("returns success: false when model calls wrong tool name", async () => { globalThis.fetch = makeFetch({ ok: true, @@ -425,12 +432,10 @@ describe("DeepSeekProvider", () => { }, } as any); - const result = await makeProvider({ maxIterations: 1 }).executeToolCall( - "system", - "user", - toolSchema, - "session-id" - ); + const result = await makeProvider({ + maxIterations: 1, + apiUrl: "https://api.openai.com/v1", + }).executeToolCall("system", "user", toolSchema, "session-id"); expect(result.success).toBe(false); });