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:
Andrew Beal 2025-11-14 10:24:46 +00:00
parent a6adec6018
commit d7eefae936
8 changed files with 476 additions and 327 deletions

View file

@ -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
}));
}
}
}

View file

@ -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");

View 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"];
}

View file

@ -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"
}

View file

@ -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",

View file

@ -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 } });

View file

@ -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);
});
});
});

View file

@ -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
})
});