From d7eefae936d465f31517cd3e628d9b04c92e87b2 Mon Sep 17 00:00:00 2001 From: Andrew Beal Date: Fri, 14 Nov 2025 10:24:46 +0000 Subject: [PATCH] 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. --- AIClasses/OpenAI/OpenAI.ts | 296 +++++++++--------- .../OpenAI/OpenAIConversationNamingService.ts | 30 +- AIClasses/OpenAI/OpenAITypes.ts | 58 ++++ Enums/ApiProvider.ts | 3 +- Enums/Copy.ts | 1 - VaultkeeperAISettingTab.ts | 4 - __tests__/AIClasses/OpenAI.test.ts | 296 +++++++++--------- .../OpenAIConversationNamingService.test.ts | 115 ++++++- 8 files changed, 476 insertions(+), 327 deletions(-) create mode 100644 AIClasses/OpenAI/OpenAITypes.ts diff --git a/AIClasses/OpenAI/OpenAI.ts b/AIClasses/OpenAI/OpenAI.ts index 900719d..fa2bf08 100644 --- a/AIClasses/OpenAI/OpenAI.ts +++ b/AIClasses/OpenAI/OpenAI.ts @@ -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(Services.IPrompt); - private readonly plugin: VaultkeeperAIPlugin = Resolve(Services.VaultkeeperAIPlugin); private readonly settingsService: SettingsService = Resolve(Services.SettingsService); private readonly streamingService: StreamingService = Resolve(Services.StreamingService); private readonly aiFunctionDefinitions: AIFunctionDefinitions = Resolve(Services.AIFunctionDefinitions); - // OpenAI can have multiple tool calls, so we track them by index - private accumulatedToolCalls: Map = 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 { - // 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; + functionCall = new AIFunctionCall( + aiFunctionFromString(toolCall.function.name), + args as Record, + 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; - functionCall = new AIFunctionCall( - aiFunctionFromString(firstToolCall.name), - args as Record, - 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 })); } -} +} \ No newline at end of file diff --git a/AIClasses/OpenAI/OpenAIConversationNamingService.ts b/AIClasses/OpenAI/OpenAIConversationNamingService.ts index d11ab4f..203683f 100644 --- a/AIClasses/OpenAI/OpenAIConversationNamingService.ts +++ b/AIClasses/OpenAI/OpenAIConversationNamingService.ts @@ -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"); diff --git a/AIClasses/OpenAI/OpenAITypes.ts b/AIClasses/OpenAI/OpenAITypes.ts new file mode 100644 index 0000000..37b4c2d --- /dev/null +++ b/AIClasses/OpenAI/OpenAITypes.ts @@ -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"]; +} \ No newline at end of file diff --git a/Enums/ApiProvider.ts b/Enums/ApiProvider.ts index ef8de83..720ba41 100644 --- a/Enums/ApiProvider.ts +++ b/Enums/ApiProvider.ts @@ -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" } \ No newline at end of file diff --git a/Enums/Copy.ts b/Enums/Copy.ts index beb0a9b..13735af 100644 --- a/Enums/Copy.ts +++ b/Enums/Copy.ts @@ -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", diff --git a/VaultkeeperAISettingTab.ts b/VaultkeeperAISettingTab.ts index 5446f18..11de8fe 100644 --- a/VaultkeeperAISettingTab.ts +++ b/VaultkeeperAISettingTab.ts @@ -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 } }); diff --git a/__tests__/AIClasses/OpenAI.test.ts b/__tests__/AIClasses/OpenAI.test.ts index 4f726f8..d582328 100644 --- a/__tests__/AIClasses/OpenAI.test.ts +++ b/__tests__/AIClasses/OpenAI.test.ts @@ -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); }); }); }); diff --git a/__tests__/AIClasses/OpenAIConversationNamingService.test.ts b/__tests__/AIClasses/OpenAIConversationNamingService.test.ts index 285ce62..812cea9 100644 --- a/__tests__/AIClasses/OpenAIConversationNamingService.test.ts +++ b/__tests__/AIClasses/OpenAIConversationNamingService.test.ts @@ -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 }) });