mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
refactor: migrate OpenAI integration to Responses API. Add web search capabilities.
Remove 'gpt-4.1-nano' which does not support web search. Replace Chat Completions API with OpenAI Responses API throughout codebase. Update event parsing from delta-based streaming to event-based model, restructure request format to use instructions/input instead of messages array, and revise conversation naming service to handle new response structure.
This commit is contained in:
parent
a6adec6018
commit
d7eefae936
8 changed files with 476 additions and 327 deletions
|
|
@ -4,38 +4,26 @@ import type { IAIClass } from "AIClasses/IAIClass";
|
|||
import type { IPrompt } from "AIClasses/IPrompt";
|
||||
import { StreamingService, type IStreamChunk } from "Services/StreamingService";
|
||||
import type { Conversation } from "Conversations/Conversation";
|
||||
import type { ConversationContent } from "Conversations/ConversationContent";
|
||||
import { AIProvider, AIProviderURL } from "Enums/ApiProvider";
|
||||
import { AIFunctionCall } from "AIClasses/AIFunctionCall";
|
||||
import { fromString as aiFunctionFromString } from "Enums/AIFunction";
|
||||
import type { IAIFunctionDefinition } from "AIClasses/FunctionDefinitions/IAIFunctionDefinition";
|
||||
import type VaultkeeperAIPlugin from "main";
|
||||
import type { AIFunctionDefinitions } from "AIClasses/FunctionDefinitions/AIFunctionDefinitions";
|
||||
import { Role } from "Enums/Role";
|
||||
import type { SettingsService } from "Services/SettingsService";
|
||||
import type { StoredFunctionCall, StoredFunctionResponse } from "AIClasses/Schemas/AIFunctionTypes";
|
||||
import type { ChatCompletionChunk, ChatCompletionTool } from "openai/resources/chat/completions";
|
||||
import { StringTools } from "Helpers/StringTools";
|
||||
|
||||
interface IToolCallAccumulator {
|
||||
id: string | null;
|
||||
name: string | null;
|
||||
arguments: string;
|
||||
}
|
||||
import type { ResponseEvent, ResponseOutputTextDelta, ResponseFunctionCallArgumentsDone, ResponseDone, OpenAIFunctionTool } from "./OpenAITypes";
|
||||
|
||||
export class OpenAI implements IAIClass {
|
||||
|
||||
private readonly STOP_REASON_TOOL_CALLS: string = "tool_calls";
|
||||
|
||||
private readonly apiKey: string;
|
||||
private readonly aiPrompt: IPrompt = Resolve<IPrompt>(Services.IPrompt);
|
||||
private readonly plugin: VaultkeeperAIPlugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
|
||||
private readonly settingsService: SettingsService = Resolve<SettingsService>(Services.SettingsService);
|
||||
private readonly streamingService: StreamingService = Resolve<StreamingService>(Services.StreamingService);
|
||||
private readonly aiFunctionDefinitions: AIFunctionDefinitions = Resolve<AIFunctionDefinitions>(Services.AIFunctionDefinitions);
|
||||
|
||||
// OpenAI can have multiple tool calls, so we track them by index
|
||||
private accumulatedToolCalls: Map<number, IToolCallAccumulator> = new Map();
|
||||
|
||||
public constructor() {
|
||||
this.apiKey = this.settingsService.getApiKeyForProvider(AIProvider.OpenAI);
|
||||
}
|
||||
|
|
@ -43,20 +31,152 @@ export class OpenAI implements IAIClass {
|
|||
public async* streamRequest(
|
||||
conversation: Conversation, allowDestructiveActions: boolean, abortSignal?: AbortSignal
|
||||
): AsyncGenerator<IStreamChunk, void, unknown> {
|
||||
// Reset tool call accumulation state for new request
|
||||
this.accumulatedToolCalls.clear();
|
||||
|
||||
const systemPrompt = [
|
||||
this.aiPrompt.systemInstruction(),
|
||||
await this.aiPrompt.userInstruction()
|
||||
].filter(s => s).join("\n\n");
|
||||
|
||||
const messages = [
|
||||
{
|
||||
role: Role.System,
|
||||
content: systemPrompt
|
||||
},
|
||||
...conversation.contents
|
||||
const input = this.extractContents(conversation.contents);
|
||||
|
||||
const tools = [{
|
||||
type: "web_search"
|
||||
}, ...this.mapFunctionDefinitions(
|
||||
this.aiFunctionDefinitions.getQueryActions(allowDestructiveActions)
|
||||
)];
|
||||
|
||||
const requestBody = {
|
||||
model: this.settingsService.settings.model,
|
||||
instructions: systemPrompt,
|
||||
input: input,
|
||||
tools: tools,
|
||||
stream: true
|
||||
};
|
||||
|
||||
const headers = {
|
||||
"Authorization": `Bearer ${this.apiKey}`,
|
||||
"Content-Type": "application/json"
|
||||
};
|
||||
|
||||
yield* this.streamingService.streamRequest(
|
||||
AIProviderURL.OpenAI,
|
||||
requestBody,
|
||||
(chunk: string) => this.parseStreamChunk(chunk),
|
||||
abortSignal,
|
||||
headers
|
||||
);
|
||||
}
|
||||
|
||||
private parseStreamChunk(chunk: string): IStreamChunk {
|
||||
try {
|
||||
// OpenAI Responses API sends "[DONE]" as the final message, which is not valid JSON
|
||||
if (chunk.trim() === "[DONE]") {
|
||||
return { content: "", isComplete: true };
|
||||
}
|
||||
|
||||
const event = JSON.parse(chunk) as ResponseEvent;
|
||||
|
||||
let text = "";
|
||||
let functionCall: AIFunctionCall | undefined = undefined;
|
||||
let isComplete = false;
|
||||
let shouldContinue = false;
|
||||
|
||||
// Handle different event types
|
||||
switch (event.type) {
|
||||
case "response.output_text.delta": {
|
||||
// Text content streaming
|
||||
const deltaEvent = event as ResponseOutputTextDelta;
|
||||
text = deltaEvent.delta;
|
||||
break;
|
||||
}
|
||||
|
||||
case "response.refusal.delta": {
|
||||
// Model refused to respond - treat as text for now
|
||||
const refusalEvent = event as ResponseOutputTextDelta;
|
||||
text = refusalEvent.delta;
|
||||
break;
|
||||
}
|
||||
|
||||
case "response.error": {
|
||||
// Error occurred during response generation
|
||||
isComplete = true;
|
||||
console.error("Response error:", event);
|
||||
break;
|
||||
}
|
||||
|
||||
case "response.function_call_arguments.delta": {
|
||||
// Function call arguments streaming - we can ignore these
|
||||
// as we'll get the complete call in the "done" event
|
||||
break;
|
||||
}
|
||||
|
||||
case "response.function_call_arguments.done": {
|
||||
// Complete function call received
|
||||
const doneEvent = event as ResponseFunctionCallArgumentsDone;
|
||||
const toolCall = doneEvent.call;
|
||||
|
||||
if (toolCall.type === "function" && toolCall.function) {
|
||||
try {
|
||||
const args = JSON.parse(toolCall.function.arguments) as Record<string, unknown>;
|
||||
functionCall = new AIFunctionCall(
|
||||
aiFunctionFromString(toolCall.function.name),
|
||||
args as Record<string, object>,
|
||||
toolCall.id
|
||||
);
|
||||
// When we receive a function call, we should continue the conversation
|
||||
shouldContinue = true;
|
||||
} catch (error) {
|
||||
console.error("Failed to parse function call arguments:", error);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "response.completed":
|
||||
case "response.done": {
|
||||
// Response completed
|
||||
isComplete = true;
|
||||
const doneEvent = event as ResponseDone;
|
||||
|
||||
// Check if the response contains tool calls that should trigger continuation
|
||||
if (doneEvent.response?.output) {
|
||||
for (const outputItem of doneEvent.response.output) {
|
||||
if (outputItem.tool_calls && outputItem.tool_calls.length > 0) {
|
||||
shouldContinue = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "response.output_item.added":
|
||||
case "response.output_item.done":
|
||||
// These events can be used for more granular tracking if needed
|
||||
// For now, we handle content through the delta events
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown event type - log but don't error
|
||||
console.debug("Unknown event type:", event.type);
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
content: text,
|
||||
isComplete: isComplete,
|
||||
functionCall: functionCall,
|
||||
shouldContinue: shouldContinue,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown parsing error";
|
||||
console.error("Failed to parse stream chunk:", message, "Chunk:", chunk);
|
||||
return { content: "", isComplete: false, error: `Failed to parse chunk: ${message}` };
|
||||
}
|
||||
}
|
||||
|
||||
private extractContents(conversationContent: ConversationContent[]) {
|
||||
return conversationContent
|
||||
.filter(content => content.content.trim() !== "" || content.functionCall.trim() !== "")
|
||||
.map(content => {
|
||||
const contentToExtract = content.role === Role.User ? content.promptContent : content.content;
|
||||
|
|
@ -129,135 +249,15 @@ export class OpenAI implements IAIClass {
|
|||
content: contentToExtract
|
||||
};
|
||||
})
|
||||
.filter(message => message.content !== "" || message.tool_calls || message.tool_call_id)
|
||||
];
|
||||
|
||||
const tools = this.mapFunctionDefinitions(
|
||||
this.aiFunctionDefinitions.getQueryActions(allowDestructiveActions)
|
||||
);
|
||||
|
||||
const requestBody = {
|
||||
model: this.settingsService.settings.model,
|
||||
messages: messages,
|
||||
tools: tools,
|
||||
stream: true
|
||||
};
|
||||
|
||||
const headers = {
|
||||
"Authorization": `Bearer ${this.apiKey}`,
|
||||
"Content-Type": "application/json"
|
||||
};
|
||||
|
||||
yield* this.streamingService.streamRequest(
|
||||
AIProviderURL.OpenAI,
|
||||
requestBody,
|
||||
(chunk: string) => this.parseStreamChunk(chunk),
|
||||
abortSignal,
|
||||
headers
|
||||
);
|
||||
.filter(message => message.content !== "" || message.tool_calls || message.tool_call_id);
|
||||
}
|
||||
|
||||
private parseStreamChunk(chunk: string): IStreamChunk {
|
||||
try {
|
||||
// OpenAI sends "[DONE]" as the final message, which is not valid JSON
|
||||
if (chunk.trim() === "[DONE]") {
|
||||
return { content: "", isComplete: true };
|
||||
}
|
||||
|
||||
const data = JSON.parse(chunk) as ChatCompletionChunk;
|
||||
|
||||
let text = "";
|
||||
let functionCall: AIFunctionCall | undefined = undefined;
|
||||
let isComplete = false;
|
||||
let shouldContinue = false;
|
||||
|
||||
const choice = data.choices?.[0];
|
||||
if (!choice) {
|
||||
return { content: "", isComplete: false };
|
||||
}
|
||||
|
||||
const delta = choice.delta;
|
||||
|
||||
// Handle text content
|
||||
if (delta?.content) {
|
||||
text = delta.content;
|
||||
}
|
||||
|
||||
// Handle tool calls - OpenAI streams them incrementally with an index
|
||||
if (delta?.tool_calls) {
|
||||
for (const toolCall of delta.tool_calls) {
|
||||
const index = toolCall.index;
|
||||
|
||||
// Get or create accumulator for this tool call index
|
||||
if (!this.accumulatedToolCalls.has(index)) {
|
||||
this.accumulatedToolCalls.set(index, {
|
||||
id: null,
|
||||
name: null,
|
||||
arguments: ""
|
||||
});
|
||||
}
|
||||
|
||||
const accumulator = this.accumulatedToolCalls.get(index)!;
|
||||
|
||||
// Accumulate tool call data
|
||||
if (toolCall.id) {
|
||||
accumulator.id = toolCall.id;
|
||||
}
|
||||
if (toolCall.function?.name) {
|
||||
accumulator.name = toolCall.function.name;
|
||||
}
|
||||
if (toolCall.function?.arguments) {
|
||||
accumulator.arguments += toolCall.function.arguments;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for completion
|
||||
if (choice.finish_reason) {
|
||||
isComplete = true;
|
||||
shouldContinue = choice.finish_reason === this.STOP_REASON_TOOL_CALLS;
|
||||
|
||||
// If we're finishing with a tool call, create the function call object
|
||||
// For now, we'll handle the first tool call (OpenAI can have multiple)
|
||||
if (shouldContinue && this.accumulatedToolCalls.size > 0) {
|
||||
// Get the first accumulated tool call
|
||||
const firstToolCall = this.accumulatedToolCalls.get(0);
|
||||
if (firstToolCall && firstToolCall.name && firstToolCall.arguments) {
|
||||
try {
|
||||
const args = JSON.parse(firstToolCall.arguments) as Record<string, unknown>;
|
||||
functionCall = new AIFunctionCall(
|
||||
aiFunctionFromString(firstToolCall.name),
|
||||
args as Record<string, object>,
|
||||
firstToolCall.id || undefined
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to parse accumulated tool call arguments:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: text,
|
||||
isComplete: isComplete,
|
||||
functionCall: functionCall,
|
||||
shouldContinue: shouldContinue,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown parsing error";
|
||||
console.error("Failed to parse stream chunk:", message, "Chunk:", chunk);
|
||||
return { content: "", isComplete: false, error: `Failed to parse chunk: ${message}` };
|
||||
}
|
||||
}
|
||||
|
||||
private mapFunctionDefinitions(aiFunctionDefinitions: IAIFunctionDefinition[]): ChatCompletionTool[] {
|
||||
private mapFunctionDefinitions(aiFunctionDefinitions: IAIFunctionDefinition[]): OpenAIFunctionTool[] {
|
||||
return aiFunctionDefinitions.map((functionDefinition) => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: functionDefinition.name,
|
||||
description: functionDefinition.description,
|
||||
parameters: functionDefinition.parameters
|
||||
}
|
||||
name: functionDefinition.name,
|
||||
description: functionDefinition.description,
|
||||
parameters: functionDefinition.parameters
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import { AIProvider, AIProviderURL, AIProviderModel } from "Enums/ApiProvider";
|
|||
import { Role } from "Enums/Role";
|
||||
import { NamePrompt } from "AIClasses/NamePrompt";
|
||||
import type { SettingsService } from "Services/SettingsService";
|
||||
import type { ChatCompletion } from "openai/resources/chat/completions";
|
||||
import type OpenAI from "openai";
|
||||
|
||||
export class OpenAIConversationNamingService implements IConversationNamingService {
|
||||
|
||||
|
|
@ -20,17 +20,15 @@ export class OpenAIConversationNamingService implements IConversationNamingServi
|
|||
|
||||
const requestBody = {
|
||||
model: AIProviderModel.OpenAINamer,
|
||||
max_tokens: 100,
|
||||
messages: [
|
||||
{
|
||||
role: Role.System,
|
||||
content: NamePrompt
|
||||
},
|
||||
max_output_tokens: 100,
|
||||
instructions: NamePrompt,
|
||||
input: [
|
||||
{
|
||||
role: Role.User,
|
||||
content: userPrompt
|
||||
}
|
||||
]
|
||||
],
|
||||
stream: false
|
||||
};
|
||||
|
||||
const response = await fetch(AIProviderURL.OpenAI, {
|
||||
|
|
@ -47,8 +45,20 @@ export class OpenAIConversationNamingService implements IConversationNamingServi
|
|||
throw new Error(`OpenAI API error: ${response.status} ${response.statusText} - ${await response.text()}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as ChatCompletion;
|
||||
const generatedName = data.choices?.[0]?.message?.content;
|
||||
const data = await response.json() as OpenAI.Responses.Response;
|
||||
|
||||
// Try to get the name from output_text first (most common case)
|
||||
if (data.output_text && data.output_text.trim()) {
|
||||
return data.output_text.trim();
|
||||
}
|
||||
|
||||
// Fall back to checking the output array
|
||||
const firstOutput = data.output?.[0];
|
||||
const generatedName = firstOutput && 'content' in firstOutput
|
||||
? firstOutput.content?.[0]?.type === 'output_text'
|
||||
? firstOutput.content[0].text
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
if (!generatedName) {
|
||||
throw new Error("Failed to generate conversation name");
|
||||
|
|
|
|||
58
AIClasses/OpenAI/OpenAITypes.ts
Normal file
58
AIClasses/OpenAI/OpenAITypes.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { IAIFunctionDefinition } from "AIClasses/FunctionDefinitions/IAIFunctionDefinition";
|
||||
|
||||
/* SDK types are a bit complicated and verbose so define simpler interfaces */
|
||||
|
||||
export interface ResponseEvent {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ResponseOutputTextDelta extends ResponseEvent {
|
||||
type: "response.output_text.delta";
|
||||
delta: string;
|
||||
}
|
||||
|
||||
export interface ResponseFunctionCallArgumentsDone extends ResponseEvent {
|
||||
type: "response.function_call_arguments.done";
|
||||
call: {
|
||||
id: string;
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ResponseDone extends ResponseEvent {
|
||||
type: "response.done";
|
||||
response: {
|
||||
id: string;
|
||||
status: string;
|
||||
output: Array<{
|
||||
role: string;
|
||||
content?: string;
|
||||
tool_calls?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
output_text?: string;
|
||||
usage?: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpenAIFunctionTool {
|
||||
type: "function";
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: IAIFunctionDefinition["parameters"];
|
||||
}
|
||||
|
|
@ -39,7 +39,6 @@ export enum AIProviderModel {
|
|||
GPT_4o_Mini = "gpt-4o-mini",
|
||||
GPT_4_1 = "gpt-4.1",
|
||||
GPT_4_1_Mini = "gpt-4.1-mini",
|
||||
GPT_4_1_Nano = "gpt-4.1-nano",
|
||||
|
||||
// Conversation naming models (aliases to existing models)
|
||||
ClaudeNamer = ClaudeHaiku_4_5,
|
||||
|
|
@ -50,5 +49,5 @@ export enum AIProviderModel {
|
|||
export enum AIProviderURL {
|
||||
Claude = "https://api.anthropic.com/v1/messages",
|
||||
Gemini = "https://generativelanguage.googleapis.com/v1beta/models",
|
||||
OpenAI = "https://api.openai.com/v1/chat/completions"
|
||||
OpenAI = "https://api.openai.com/v1/responses"
|
||||
}
|
||||
|
|
@ -26,7 +26,6 @@ export enum Copy {
|
|||
GPT_4o_Mini = "GPT-4o Mini",
|
||||
GPT_4_1 = "GPT-4.1",
|
||||
GPT_4_1_Mini = "GPT-4.1 Mini",
|
||||
GPT_4_1_Nano = "GPT-4.1 Nano",
|
||||
|
||||
// AI Provider Groups
|
||||
ProviderClaude = "Claude",
|
||||
|
|
|
|||
|
|
@ -93,10 +93,6 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
|
|||
value: AIProviderModel.GPT_4_1_Mini,
|
||||
text: Copy.GPT_4_1_Mini
|
||||
});
|
||||
openaiGroup.createEl("option", {
|
||||
value: AIProviderModel.GPT_4_1_Nano,
|
||||
text: Copy.GPT_4_1_Nano
|
||||
});
|
||||
|
||||
// Gemini models group
|
||||
const geminiGroup = select.createEl("optgroup", { attr: { label: Copy.ProviderGemini } });
|
||||
|
|
|
|||
|
|
@ -117,11 +117,8 @@ describe('OpenAI', () => {
|
|||
|
||||
it('should parse text delta chunks', () => {
|
||||
const chunk = JSON.stringify({
|
||||
choices: [{
|
||||
delta: {
|
||||
content: 'Hello world'
|
||||
}
|
||||
}]
|
||||
type: 'response.output_text.delta',
|
||||
delta: 'Hello world'
|
||||
});
|
||||
|
||||
const result = (openai as any).parseStreamChunk(chunk);
|
||||
|
|
@ -130,52 +127,23 @@ describe('OpenAI', () => {
|
|||
expect(result.isComplete).toBe(false);
|
||||
});
|
||||
|
||||
it('should accumulate tool calls by index', () => {
|
||||
// First chunk - start tool call at index 0
|
||||
const chunk1 = JSON.stringify({
|
||||
choices: [{
|
||||
delta: {
|
||||
tool_calls: [{
|
||||
index: 0,
|
||||
id: 'call_123',
|
||||
function: {
|
||||
name: 'search_vault_files',
|
||||
arguments: '{"qu'
|
||||
}
|
||||
}]
|
||||
it('should handle complete function call in done event', () => {
|
||||
// Responses API provides the complete function call in one event
|
||||
const chunk = JSON.stringify({
|
||||
type: 'response.function_call_arguments.done',
|
||||
call: {
|
||||
id: 'call_123',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search_vault_files',
|
||||
arguments: '{"query":"test"}'
|
||||
}
|
||||
}]
|
||||
}
|
||||
});
|
||||
|
||||
(openai as any).parseStreamChunk(chunk1);
|
||||
const result = (openai as any).parseStreamChunk(chunk);
|
||||
|
||||
// Second chunk - continue accumulating
|
||||
const chunk2 = JSON.stringify({
|
||||
choices: [{
|
||||
delta: {
|
||||
tool_calls: [{
|
||||
index: 0,
|
||||
function: {
|
||||
arguments: 'ery":"test"}'
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
(openai as any).parseStreamChunk(chunk2);
|
||||
|
||||
// Third chunk - finish with tool_calls reason
|
||||
const chunk3 = JSON.stringify({
|
||||
choices: [{
|
||||
delta: {},
|
||||
finish_reason: 'tool_calls'
|
||||
}]
|
||||
});
|
||||
|
||||
const result = (openai as any).parseStreamChunk(chunk3);
|
||||
|
||||
expect(result.isComplete).toBe(true);
|
||||
expect(result.isComplete).toBe(false);
|
||||
expect(result.shouldContinue).toBe(true);
|
||||
expect(result.functionCall).toBeDefined();
|
||||
expect(result.functionCall?.name).toBe('search_vault_files');
|
||||
|
|
@ -183,75 +151,72 @@ describe('OpenAI', () => {
|
|||
expect(result.functionCall?.toolId).toBe('call_123');
|
||||
});
|
||||
|
||||
it('should handle multiple concurrent tool calls but only return first', () => {
|
||||
// OpenAI can send multiple tool calls with different indices
|
||||
it('should handle response.done event with tool calls', () => {
|
||||
// response.done event indicates completion and may contain tool calls
|
||||
const chunk = JSON.stringify({
|
||||
choices: [{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: 'call_1',
|
||||
function: {
|
||||
name: 'search_vault_files',
|
||||
arguments: '{"a":1}'
|
||||
type: 'response.done',
|
||||
response: {
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search_vault_files',
|
||||
arguments: '{"a":1}'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
id: 'call_2',
|
||||
function: {
|
||||
name: 'read_vault_files',
|
||||
arguments: '{"b":2}'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
(openai as any).parseStreamChunk(chunk);
|
||||
const result = (openai as any).parseStreamChunk(chunk);
|
||||
|
||||
// Finish
|
||||
const finishChunk = JSON.stringify({
|
||||
choices: [{
|
||||
delta: {},
|
||||
finish_reason: 'tool_calls'
|
||||
}]
|
||||
});
|
||||
|
||||
const result = (openai as any).parseStreamChunk(finishChunk);
|
||||
|
||||
// Should only return the first tool call (index 0)
|
||||
expect(result.functionCall).toBeDefined();
|
||||
expect(result.functionCall?.name).toBe('search_vault_files');
|
||||
expect((openai as any).accumulatedToolCalls.size).toBe(2);
|
||||
expect(result.isComplete).toBe(true);
|
||||
expect(result.shouldContinue).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle missing choices gracefully', () => {
|
||||
it('should handle unknown event types gracefully', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
|
||||
const chunk = JSON.stringify({
|
||||
choices: []
|
||||
type: 'response.unknown_event',
|
||||
data: 'some data'
|
||||
});
|
||||
|
||||
const result = (openai as any).parseStreamChunk(chunk);
|
||||
|
||||
expect(result.content).toBe('');
|
||||
expect(result.isComplete).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Unknown event type:', 'response.unknown_event');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle finish_reason without tool calls', () => {
|
||||
it('should handle response.done without tool calls', () => {
|
||||
const chunk = JSON.stringify({
|
||||
choices: [{
|
||||
delta: {
|
||||
content: 'Done'
|
||||
},
|
||||
finish_reason: 'stop'
|
||||
}]
|
||||
type: 'response.done',
|
||||
response: {
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Done'
|
||||
}
|
||||
],
|
||||
output_text: 'Done'
|
||||
}
|
||||
});
|
||||
|
||||
const result = (openai as any).parseStreamChunk(chunk);
|
||||
|
||||
expect(result.content).toBe('Done');
|
||||
expect(result.isComplete).toBe(true);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
});
|
||||
|
|
@ -259,23 +224,20 @@ describe('OpenAI', () => {
|
|||
it('should handle invalid JSON in tool call arguments', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
// Setup invalid arguments
|
||||
(openai as any).accumulatedToolCalls.set(0, {
|
||||
id: 'call_123',
|
||||
name: 'search_vault_files',
|
||||
arguments: 'invalid json {'
|
||||
});
|
||||
|
||||
const chunk = JSON.stringify({
|
||||
choices: [{
|
||||
delta: {},
|
||||
finish_reason: 'tool_calls'
|
||||
}]
|
||||
type: 'response.function_call_arguments.done',
|
||||
call: {
|
||||
id: 'call_123',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search_vault_files',
|
||||
arguments: 'invalid json {'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const result = (openai as any).parseStreamChunk(chunk);
|
||||
|
||||
expect(result.isComplete).toBe(true);
|
||||
expect(result.functionCall).toBeUndefined();
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
|
||||
|
|
@ -295,23 +257,67 @@ describe('OpenAI', () => {
|
|||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle null content in delta', () => {
|
||||
it('should handle function call arguments delta events', () => {
|
||||
// These events are sent during streaming but we can ignore them
|
||||
const chunk = JSON.stringify({
|
||||
choices: [{
|
||||
delta: {
|
||||
content: null
|
||||
}
|
||||
}]
|
||||
type: 'response.function_call_arguments.delta',
|
||||
delta: '{"que'
|
||||
});
|
||||
|
||||
const result = (openai as any).parseStreamChunk(chunk);
|
||||
|
||||
expect(result.content).toBe('');
|
||||
expect(result.isComplete).toBe(false);
|
||||
expect(result.functionCall).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle response.refusal.delta events', () => {
|
||||
const chunk = JSON.stringify({
|
||||
type: 'response.refusal.delta',
|
||||
delta: 'I cannot help with that.'
|
||||
});
|
||||
|
||||
const result = (openai as any).parseStreamChunk(chunk);
|
||||
|
||||
expect(result.content).toBe('I cannot help with that.');
|
||||
expect(result.isComplete).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle response.error events', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const chunk = JSON.stringify({
|
||||
type: 'response.error',
|
||||
error: { message: 'Something went wrong' }
|
||||
});
|
||||
|
||||
const result = (openai as any).parseStreamChunk(chunk);
|
||||
|
||||
expect(result.isComplete).toBe(true);
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle response.completed event', () => {
|
||||
const chunk = JSON.stringify({
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: []
|
||||
}
|
||||
});
|
||||
|
||||
const result = (openai as any).parseStreamChunk(chunk);
|
||||
|
||||
expect(result.isComplete).toBe(true);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Format Conversion', () => {
|
||||
it('should include system prompt in messages array', async () => {
|
||||
it('should include system prompt in instructions field', async () => {
|
||||
const conversation = new Conversation();
|
||||
conversation.contents.push(new ConversationContent(Role.User, 'Hello'));
|
||||
|
||||
|
|
@ -329,10 +335,9 @@ describe('OpenAI', () => {
|
|||
const callArgs = mockStreamingService.streamRequest.mock.calls[0];
|
||||
const requestBody = callArgs[1];
|
||||
|
||||
expect(requestBody.messages[0]).toEqual({
|
||||
role: Role.System,
|
||||
content: 'System instruction\n\nUser instruction'
|
||||
});
|
||||
expect(requestBody.instructions).toBe('System instruction\n\nUser instruction');
|
||||
expect(requestBody.input).toBeDefined();
|
||||
expect(requestBody.messages).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should convert function call to OpenAI tool_calls format', async () => {
|
||||
|
|
@ -362,7 +367,7 @@ describe('OpenAI', () => {
|
|||
|
||||
const callArgs = mockStreamingService.streamRequest.mock.calls[0];
|
||||
const requestBody = callArgs[1];
|
||||
const assistantMessage = requestBody.messages.find((m: any) => m.role === Role.Assistant);
|
||||
const assistantMessage = requestBody.input.find((m: any) => m.role === Role.Assistant);
|
||||
|
||||
expect(assistantMessage).toBeDefined();
|
||||
expect(assistantMessage.tool_calls).toHaveLength(1);
|
||||
|
|
@ -401,7 +406,7 @@ describe('OpenAI', () => {
|
|||
|
||||
const callArgs = mockStreamingService.streamRequest.mock.calls[0];
|
||||
const requestBody = callArgs[1];
|
||||
const toolMessage = requestBody.messages.find((m: any) => m.role === 'tool');
|
||||
const toolMessage = requestBody.input.find((m: any) => m.role === 'tool');
|
||||
|
||||
expect(toolMessage).toBeDefined();
|
||||
expect(toolMessage.tool_call_id).toBe('call_123');
|
||||
|
|
@ -431,7 +436,7 @@ describe('OpenAI', () => {
|
|||
|
||||
const callArgs = mockStreamingService.streamRequest.mock.calls[0];
|
||||
const requestBody = callArgs[1];
|
||||
const message = requestBody.messages.find((m: any) => m.role === Role.Assistant);
|
||||
const message = requestBody.input.find((m: any) => m.role === Role.Assistant);
|
||||
|
||||
expect(message.content).toBe('Error parsing function call');
|
||||
expect(message.tool_calls).toBeUndefined();
|
||||
|
|
@ -462,7 +467,7 @@ describe('OpenAI', () => {
|
|||
|
||||
const callArgs = mockStreamingService.streamRequest.mock.calls[0];
|
||||
const requestBody = callArgs[1];
|
||||
const message = requestBody.messages.find((m: any) => m.role === Role.User);
|
||||
const message = requestBody.input.find((m: any) => m.role === Role.User);
|
||||
|
||||
expect(message.content).toBe('invalid json {');
|
||||
expect(message.role).toBe(Role.User); // Falls back to original role
|
||||
|
|
@ -487,13 +492,13 @@ describe('OpenAI', () => {
|
|||
const callArgs = mockStreamingService.streamRequest.mock.calls[0];
|
||||
const requestBody = callArgs[1];
|
||||
|
||||
// Should have system + 2 user messages (empty one filtered out)
|
||||
expect(requestBody.messages).toHaveLength(3);
|
||||
// Should have 2 user messages in input (empty one filtered out)
|
||||
expect(requestBody.input).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapFunctionDefinitions', () => {
|
||||
it('should map function definitions to OpenAI tool format', () => {
|
||||
it('should map function definitions to OpenAI Responses API tool format', () => {
|
||||
const definitions = [
|
||||
{
|
||||
name: 'search_vault_files',
|
||||
|
|
@ -523,19 +528,15 @@ describe('OpenAI', () => {
|
|||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search_vault_files',
|
||||
description: 'Search for files',
|
||||
parameters: definitions[0].parameters
|
||||
}
|
||||
name: 'search_vault_files',
|
||||
description: 'Search for files',
|
||||
parameters: definitions[0].parameters
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'read_file',
|
||||
description: 'Read a file',
|
||||
parameters: definitions[1].parameters
|
||||
}
|
||||
name: 'read_file',
|
||||
description: 'Read a file',
|
||||
parameters: definitions[1].parameters
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -566,7 +567,8 @@ describe('OpenAI', () => {
|
|||
expect.any(String), // URL
|
||||
expect.objectContaining({
|
||||
model: 'gpt-4o',
|
||||
messages: expect.any(Array),
|
||||
instructions: expect.any(String),
|
||||
input: expect.any(Array),
|
||||
tools: expect.any(Array),
|
||||
stream: true
|
||||
}),
|
||||
|
|
@ -579,14 +581,7 @@ describe('OpenAI', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should clear accumulated tool calls at start of streamRequest', async () => {
|
||||
// Set some accumulated state
|
||||
(openai as any).accumulatedToolCalls.set(0, {
|
||||
id: 'old_id',
|
||||
name: 'old_func',
|
||||
arguments: 'old_args'
|
||||
});
|
||||
|
||||
it('should include name field in web_search tool', async () => {
|
||||
const conversation = new Conversation();
|
||||
conversation.contents.push(new ConversationContent(Role.User, 'Test'));
|
||||
|
||||
|
|
@ -594,11 +589,16 @@ describe('OpenAI', () => {
|
|||
yield { content: 'done', isComplete: true };
|
||||
});
|
||||
|
||||
const generator = openai.streamRequest(conversation, false);
|
||||
await generator.next();
|
||||
const generator = openai.streamRequest(conversation, true);
|
||||
for await (const chunk of generator) {}
|
||||
|
||||
// State should be cleared
|
||||
expect((openai as any).accumulatedToolCalls.size).toBe(0);
|
||||
const callArgs = mockStreamingService.streamRequest.mock.calls[0];
|
||||
const requestBody = callArgs[1];
|
||||
const webSearchTool = requestBody.tools.find((t: any) => t.type === 'web_search');
|
||||
|
||||
expect(webSearchTool).toBeDefined();
|
||||
expect(webSearchTool.type).toBe('web_search');
|
||||
expect(webSearchTool.name).toBe(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -50,11 +50,25 @@ describe('OpenAIConversationNamingService', () => {
|
|||
});
|
||||
|
||||
describe('generateName', () => {
|
||||
it('should make request with correct format', async () => {
|
||||
it('should make request with correct Responses API format', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{ message: { content: 'Test Conversation' } }]
|
||||
id: 'resp_123',
|
||||
created_at: 1234567890,
|
||||
output_text: 'Test Conversation',
|
||||
error: null,
|
||||
incomplete_details: null,
|
||||
instructions: null,
|
||||
metadata: null,
|
||||
model: AIProviderModel.OpenAINamer,
|
||||
object: 'response',
|
||||
parallel_tool_calls: true,
|
||||
temperature: null,
|
||||
tool_choice: 'auto',
|
||||
tools: [],
|
||||
top_p: null,
|
||||
output: []
|
||||
})
|
||||
});
|
||||
|
||||
|
|
@ -74,19 +88,75 @@ describe('OpenAIConversationNamingService', () => {
|
|||
|
||||
const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(requestBody.model).toBe(AIProviderModel.OpenAINamer);
|
||||
expect(requestBody.max_tokens).toBe(100);
|
||||
expect(requestBody.messages).toHaveLength(2);
|
||||
expect(requestBody.messages[0].role).toBe(Role.System);
|
||||
expect(requestBody.messages[0].content).toBeDefined();
|
||||
expect(requestBody.messages[1].role).toBe(Role.User);
|
||||
expect(requestBody.messages[1].content).toBe('User prompt');
|
||||
expect(requestBody.max_output_tokens).toBe(100);
|
||||
expect(requestBody.instructions).toBeDefined();
|
||||
expect(requestBody.input).toHaveLength(1);
|
||||
expect(requestBody.input[0].role).toBe(Role.User);
|
||||
expect(requestBody.input[0].content).toBe('User prompt');
|
||||
expect(requestBody.stream).toBe(false);
|
||||
expect(requestBody.messages).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return generated name from response', async () => {
|
||||
it('should return generated name from output_text', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{ message: { content: 'Generated Name' } }]
|
||||
id: 'resp_123',
|
||||
created_at: 1234567890,
|
||||
output_text: 'Generated Name',
|
||||
error: null,
|
||||
incomplete_details: null,
|
||||
instructions: null,
|
||||
metadata: null,
|
||||
model: AIProviderModel.OpenAINamer,
|
||||
object: 'response',
|
||||
parallel_tool_calls: true,
|
||||
temperature: null,
|
||||
tool_choice: 'auto',
|
||||
tools: [],
|
||||
top_p: null,
|
||||
output: []
|
||||
})
|
||||
});
|
||||
|
||||
const result = await service.generateName('Test prompt', undefined);
|
||||
|
||||
expect(result).toBe('Generated Name');
|
||||
});
|
||||
|
||||
it('should return generated name from output array', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: 'resp_123',
|
||||
created_at: 1234567890,
|
||||
output_text: '',
|
||||
error: null,
|
||||
incomplete_details: null,
|
||||
instructions: null,
|
||||
metadata: null,
|
||||
model: AIProviderModel.OpenAINamer,
|
||||
object: 'response',
|
||||
parallel_tool_calls: true,
|
||||
temperature: null,
|
||||
tool_choice: 'auto',
|
||||
tools: [],
|
||||
top_p: null,
|
||||
output: [
|
||||
{
|
||||
id: 'msg_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'Generated Name',
|
||||
annotations: []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
|
|
@ -111,7 +181,9 @@ describe('OpenAIConversationNamingService', () => {
|
|||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: []
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: []
|
||||
})
|
||||
});
|
||||
|
||||
|
|
@ -125,7 +197,21 @@ describe('OpenAIConversationNamingService', () => {
|
|||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{ message: { content: 'Name' } }]
|
||||
id: 'resp_123',
|
||||
created_at: 1234567890,
|
||||
output_text: 'Name',
|
||||
error: null,
|
||||
incomplete_details: null,
|
||||
instructions: null,
|
||||
metadata: null,
|
||||
model: AIProviderModel.OpenAINamer,
|
||||
object: 'response',
|
||||
parallel_tool_calls: true,
|
||||
temperature: null,
|
||||
tool_choice: 'auto',
|
||||
tools: [],
|
||||
top_p: null,
|
||||
output: []
|
||||
})
|
||||
});
|
||||
|
||||
|
|
@ -143,8 +229,9 @@ describe('OpenAIConversationNamingService', () => {
|
|||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
// Missing choices array
|
||||
other: 'data'
|
||||
id: 'resp_123',
|
||||
status: 'completed'
|
||||
// Missing output_text and output array
|
||||
})
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue