Skip to content

Commit e17a02a

Browse files
committed
chore: apply code formatting and finalize AI SDK migration
1 parent 985a12b commit e17a02a

8 files changed

Lines changed: 307 additions & 501 deletions

File tree

backend/src/lib/ai/assistant.ts

Lines changed: 78 additions & 151 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,20 @@
44
* This replaces the Python MCP server by implementing function calling
55
* directly in Node.js with direct access to your database and APIs.
66
*
7-
* Uses OpenAI's Responses API with automatic function execution.
7+
* Uses Vercel AI SDK with streaming and automatic function execution.
88
*/
99

10-
import OpenAI from 'openai'
10+
import { openai } from '@ai-sdk/openai'
11+
import { streamText, tool } from 'ai'
12+
import { z } from 'zod'
1113
import { createToolExecutor, type ToolMetadata } from './tool-registry'
1214

13-
// Lazy initialization - only create client when needed
14-
let _openai: OpenAI | null = null
15-
16-
function getOpenAI(): OpenAI {
17-
if (!_openai) {
18-
if (!process.env.OPENAI_API_KEY) {
19-
throw new Error('OPENAI_API_KEY environment variable is required for AI assistant functionality')
20-
}
21-
_openai = new OpenAI({
22-
apiKey: process.env.OPENAI_API_KEY,
23-
})
15+
// Helper to ensure API key is configured
16+
function ensureAPIKey(): string {
17+
if (!process.env.OPENAI_API_KEY) {
18+
throw new Error('OPENAI_API_KEY environment variable is required for AI assistant functionality')
2419
}
25-
return _openai
20+
return process.env.OPENAI_API_KEY
2621
}
2722

2823
export interface AIContext {
@@ -42,7 +37,7 @@ export interface StreamChunk {
4237
}
4338

4439
/**
45-
* Generate AI response with automatic function calling using Responses API
40+
* Generate AI response with automatic function calling using AI SDK
4641
*/
4742
export async function* generateAIResponse(
4843
message: string,
@@ -51,178 +46,110 @@ export async function* generateAIResponse(
5146
conversationId?: string,
5247
pageContext?: string
5348
): AsyncGenerator<StreamChunk> {
49+
ensureAPIKey()
50+
5451
// Create tool executor with user context
5552
const executor = createToolExecutor(tools, context)
5653
const toolDefinitions = executor.getToolDefinitions()
5754

58-
// Build system prompt (instructions in Responses API)
59-
const instructions = buildSystemPrompt(pageContext)
55+
// Build system prompt
56+
const systemPrompt = buildSystemPrompt(pageContext)
57+
58+
// Convert tool definitions to AI SDK format with execute functions
59+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
60+
const aiSdkTools: Record<string, any> = {}
6061

61-
// Build input for Responses API
62-
const input = [{ role: 'user' as const, content: message }]
62+
for (const toolDef of toolDefinitions) {
63+
const td = toolDef as unknown as { name: string; description: string; input_schema: unknown }
64+
65+
// Use passthrough schema to accept any properties
66+
const schema = z.object({}).passthrough()
67+
68+
// Create tool with execute function
69+
const toolName = td.name
70+
aiSdkTools[toolName] = tool({
71+
description: td.description,
72+
parameters: schema,
73+
// @ts-expect-error - AI SDK types are complex, but this is the correct usage
74+
execute: async (args: Record<string, unknown>) => {
75+
return await executor.execute(toolName, args)
76+
},
77+
})
78+
}
6379

6480
let usedTools = false
65-
const maxIterations = 5 // Prevent infinite loops
66-
let iteration = 0
6781

6882
try {
69-
// Track all output items from first turn (needed for reasoning models)
70-
let allOutputItems: unknown[] = []
71-
72-
while (iteration < maxIterations) {
73-
iteration++
74-
75-
// Build API parameters
76-
const apiParams: {
77-
model: string
78-
instructions: string
79-
input: unknown[]
80-
stream: boolean
81-
tools?: unknown[]
82-
reasoning?: { effort?: string; summary?: string }
83-
} = {
84-
model: process.env.OPENAI_MODEL || 'gpt-5-mini',
85-
instructions,
86-
input: iteration === 1 ? input : allOutputItems.concat([{ role: 'user' as const, content: message }]),
87-
stream: true,
88-
}
89-
90-
// Add tools if available
91-
if (toolDefinitions.length > 0) {
92-
apiParams.tools = toolDefinitions
93-
}
94-
95-
// Add reasoning config for GPT-5 models
96-
if (apiParams.model.toLowerCase().includes('gpt-5') || apiParams.model.toLowerCase().includes('o1')) {
97-
apiParams.reasoning = {
98-
effort: process.env.OPENAI_REASONING_EFFORT || 'medium',
83+
const modelName = process.env.OPENAI_MODEL || 'gpt-5-mini'
84+
85+
const result = await streamText({
86+
model: openai(modelName),
87+
system: systemPrompt,
88+
messages: [
89+
{
90+
role: 'user',
91+
content: message
9992
}
100-
if (process.env.OPENAI_REASONING_SUMMARY !== 'none') {
101-
apiParams.reasoning.summary = process.env.OPENAI_REASONING_SUMMARY || 'auto'
93+
],
94+
tools: aiSdkTools,
95+
maxRetries: 5,
96+
onStepFinish: async (step) => {
97+
// Track if tools were used
98+
if (step.toolCalls && step.toolCalls.length > 0) {
99+
usedTools = true
102100
}
103101
}
102+
})
104103

105-
// Create streaming response
106-
const openai = getOpenAI()
107-
const stream = await openai.responses.create(apiParams as Parameters<typeof openai.responses.create>[0]) as AsyncIterable<unknown>
108-
109-
const functionCalls: Record<string, { name: string; call_id: string; arguments: string }> = {}
110-
allOutputItems = []
111-
112-
// Process stream
113-
for await (const rawEvent of stream) {
114-
const event = rawEvent as { type?: string; [key: string]: unknown }
115-
if (!event.type) continue
116-
117-
const eventType = event.type
118-
119-
// Collect all output items for next turn
120-
if (eventType === 'response.output_item.added' && 'item' in event) {
121-
// Store item for next API call (required for reasoning models)
122-
allOutputItems.push(event.item)
123-
}
124-
125-
// Stream reasoning summaries
126-
if (eventType === 'response.reasoning.summary.delta' && 'delta' in event) {
104+
// Stream the response
105+
for await (const chunk of result.fullStream) {
106+
switch (chunk.type) {
107+
case 'text-delta':
127108
yield {
128109
type: 'content',
129-
content: `[Reasoning: ${event.delta}]`,
110+
content: chunk.text
130111
}
131-
}
132-
133-
// Handle output item done
134-
if (eventType === 'response.output_item.done' && 'item' in event) {
135-
const item = event.item as { type?: string; name?: string; call_id?: string; arguments?: string }
112+
break
136113

137-
// Track function calls
138-
if (item.type === 'function_call' && item.name && item.call_id) {
139-
functionCalls[item.call_id] = {
140-
name: item.name,
141-
call_id: item.call_id,
142-
arguments: item.arguments || '{}',
143-
}
144-
145-
yield {
146-
type: 'tool_call_started',
147-
toolName: item.name,
148-
toolCallId: item.call_id,
149-
}
150-
}
151-
}
152-
153-
// Stream text deltas
154-
if (eventType === 'response.output_text.delta' && 'delta' in event) {
114+
case 'tool-call':
155115
yield {
156-
type: 'content',
157-
content: event.delta as string,
116+
type: 'tool_call_started',
117+
toolName: chunk.toolName,
118+
toolCallId: chunk.toolCallId
158119
}
159-
}
160-
161-
// Response complete
162-
if (eventType === 'response.completed') {
163120
break
164-
}
165-
}
166-
167-
// If no function calls, we're done
168-
if (Object.keys(functionCalls).length === 0) {
169-
break
170-
}
171-
172-
usedTools = true
173-
174-
// Execute function calls
175-
const functionOutputs: unknown[] = []
176-
for (const [callId, fc] of Object.entries(functionCalls)) {
177-
try {
178-
const args = JSON.parse(fc.arguments)
179-
const result = await executor.execute(fc.name, args)
180-
181-
functionOutputs.push({
182-
type: 'function_call_output',
183-
call_id: callId,
184-
output: JSON.stringify(result),
185-
})
186-
121+
122+
case 'tool-result':
187123
yield {
188124
type: 'tool_call_completed',
189-
toolName: fc.name,
190-
toolCallId: callId,
191-
success: true,
125+
toolName: chunk.toolName,
126+
toolCallId: chunk.toolCallId,
127+
success: true // AI SDK handles errors internally
192128
}
193-
} catch (error) {
194-
const errorMessage = error instanceof Error ? error.message : String(error)
195-
196-
functionOutputs.push({
197-
type: 'function_call_output',
198-
call_id: callId,
199-
output: JSON.stringify({ error: errorMessage }),
200-
})
201-
129+
break
130+
131+
case 'error': {
132+
const errorMessage = chunk.error && typeof chunk.error === 'object' && 'message' in chunk.error
133+
? (chunk.error as Error).message
134+
: String(chunk.error)
202135
yield {
203-
type: 'tool_call_completed',
204-
toolName: fc.name,
205-
toolCallId: callId,
206-
success: false,
207-
error: errorMessage,
136+
type: 'error',
137+
error: errorMessage
208138
}
139+
break
209140
}
210141
}
211-
212-
// Build input for next iteration (second turn)
213-
// Include ALL output items from first turn (required for reasoning models)
214-
allOutputItems = allOutputItems.concat(functionOutputs)
215142
}
216143

217144
// Done
218145
yield {
219146
type: 'done',
220-
usedTools,
147+
usedTools
221148
}
222149
} catch (error) {
223150
yield {
224151
type: 'error',
225-
error: error instanceof Error ? error.message : String(error),
152+
error: error instanceof Error ? error.message : String(error)
226153
}
227154
}
228155
}

0 commit comments

Comments
 (0)