Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,8 @@ const CONFIG_TEMPLATE = `{
"autoCaptureEnabled": true,

// 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):
Expand All @@ -300,11 +302,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"
Expand Down
125 changes: 97 additions & 28 deletions src/services/ai/providers/openai-chat-completion.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import { BaseAIProvider, type ToolCallResult, applySafeExtraParams } from "./base-provider.js";
import { AISessionManager } from "../session/ai-session-manager.js";
import {
BaseAIProvider,
type ProviderConfig,
type ToolCallResult,
applySafeExtraParams,
} from "./base-provider.js";
import type { AISessionManager } from "../session/ai-session-manager.js";
import type { AIMessage } 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;
content?: string | null;
tool_calls?: Array<{
id: string;
type: string;
type: "function";
function: {
name: string;
arguments: string;
Expand All @@ -21,10 +27,53 @@ interface ToolCallResponse {
}>;
}

type APIMessage = {
role: AIMessage["role"];
content: string | null;
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<AIMessage, "id" | "createdAt">;

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 hasNonEmptyChoices(data: unknown): data is ToolCallResponse {
if (typeof data !== "object" || data === null) return false;
const { choices } = data as { choices?: unknown };
if (!Array.isArray(choices) || choices.length === 0) return false;

const first = choices[0] as { message?: unknown };
if (typeof first !== "object" || first === null) return false;
if (typeof first.message !== "object" || first.message === null) return false;

const { content, tool_calls } = first.message as { content?: unknown; tool_calls?: unknown };
if (content !== undefined && content !== null && typeof content !== "string") return false;
if (tool_calls !== undefined && !Array.isArray(tool_calls)) return false;

return true;
}

export class OpenAIChatCompletionProvider extends BaseAIProvider {
private aiSessionManager: AISessionManager;
private readonly aiSessionManager: AISessionManager;

constructor(config: any, aiSessionManager: AISessionManager) {
constructor(config: ProviderConfig, aiSessionManager: AISessionManager) {
super(config);
this.aiSessionManager = aiSessionManager;
}
Expand All @@ -39,7 +88,7 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider {

private addToolResponse(
sessionId: string,
messages: any[],
messages: APIMessage[],
toolCallId: string,
content: string
): void {
Expand All @@ -58,22 +107,26 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider {
});
}

private filterIncompleteToolCallSequences(messages: any[]): any[] {
const result: any[] = [];
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: any) => tc.id));
const toolResponses: any[] = [];
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") {
if (toolCallIds.has(messages[j].toolCallId)) {
toolResponses.push(messages[j]);
toolCallIds.delete(messages[j].toolCallId);
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++;
}
Expand Down Expand Up @@ -110,12 +163,12 @@ export class OpenAIChatCompletionProvider 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,
};
Expand Down Expand Up @@ -164,7 +217,7 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider {
const timeout = setTimeout(() => controller.abort(), iterationTimeout);

try {
const requestBody: any = {
const requestBody: RequestBody = {
model: this.config.model,
messages,
tools: [toolSchema],
Expand Down Expand Up @@ -224,9 +277,9 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider {
};
}

const data = (await response.json()) as any;
const data: unknown = await response.json();

if (data.status && data.msg) {
if (isErrorResponseBody(data)) {
log("API returned error in response body", {
provider: this.getProviderName(),
model: this.config.model,
Expand All @@ -240,13 +293,18 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider {
};
}

if (!data.choices || !data.choices[0]) {
if (!hasNonEmptyChoices(data)) {
const choices =
typeof data === "object" && data !== null
? (data as { choices?: unknown }).choices
: undefined;

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,
hasChoices: Array.isArray(choices),
choicesLength: Array.isArray(choices) ? choices.length : undefined,
});
return {
success: false,
Expand All @@ -256,21 +314,32 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider {
}

const choice = data.choices[0];
if (!choice) {
return {
success: false,
error: "Invalid API response format",
iterations,
};
}

const assistantSequence = this.aiSessionManager.getLastSequence(session.id) + 1;
const assistantMsg: any = {
const assistantMsg: AssistantSessionMessage = {
aiSessionId: session.id,
sequence: assistantSequence,
role: "assistant",
content: choice.message.content || "",
content: choice.message.content ?? "",
};

if (choice.message.tool_calls) {
assistantMsg.toolCalls = choice.message.tool_calls;
}

this.aiSessionManager.addMessage(assistantMsg);
messages.push(choice.message);
messages.push({
role: "assistant",
content: choice.message.content ?? null,
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) {
Expand Down Expand Up @@ -356,7 +425,7 @@ export class OpenAIChatCompletionProvider 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,
};
}
Expand All @@ -370,7 +439,7 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider {

return {
success: false,
error: `Max iterations (${this.config.maxIterations}) reached without tool call`,
error: `Max iterations (${maxIterations}) reached without tool call`,
iterations,
};
}
Expand Down
Loading