AI Feature Refactoring: Vercel AI SDK Integration
Context
The current AI chat implementation uses a custom NDJSON streaming approach:
- Custom
useAIStream/useChatHistoryhooks on the frontend - A Next.js proxy route at
/api/ai-conversations/chat/stream - A NestJS
AiConversationControllerthat streams NDJSON viaAiFacadeService
This works but has limitations:
- No tool calling support in the chat UI (AiOperations doesn't forward tools to LangChain)
- Custom streaming protocol instead of industry-standard SSE (Server-Sent Events)
- Duplicated message management logic that Vercel AI SDK handles out of the box
- No structured message parts (text, tool calls, tool results) in the UI
Goal
Replace the custom implementation with Vercel AI SDK (ai v6), enabling:
- Tool calling throughout the full stack
- Standard
useChathook for the frontend - OpenAI-compatible API endpoint on the NestJS backend
- Custom Vercel AI SDK provider wrapping our plugin system
Architecture
Current Flow
ChatInterface -> useAIStream() -> POST /api/ai-conversations/chat/stream (Next.js NDJSON proxy)
-> POST /api/ai-conversations/chat/stream (NestJS) -> AiFacadeService -> Plugin -> LangChain
New Flow
ChatInterface -> useChat() -> POST /api/chat (Next.js route handler)
-> streamText() with custom provider -> POST /api/v1/chat/completions (NestJS, OpenAI-compat)
-> OpenAiCompatService -> AiFacadeService -> Plugin -> LangChain (with tools)
Key Design Decisions
-
OpenAI-compatible endpoint (
POST /api/v1/chat/completions) on NestJS backend- Standard wire format understood by
@ai-sdk/openai-compatible - Supports both streaming (SSE) and non-streaming responses
- Reuses existing
AiFacadeServicefor provider resolution, settings, routing - Provider selection via
X-Provider-Overrideheader (keeps request body clean)
- Standard wire format understood by
-
Custom provider via
@ai-sdk/openai-compatible- Points to NestJS backend's
/api/v1/base URL - Auth token injected server-side in the Next.js route handler
- No JWT exposed to the client
- Points to NestJS backend's
-
useChatfrom@ai-sdk/reactreplaces bothuseAIStreamanduseChatHistory- Built-in message management, streaming state, error handling
- Native tool call/result rendering via message
parts - Provider selection via custom headers
-
Extend existing
AiConversationModule(not a new module)- Add
OpenAiCompatController+OpenAiCompatServicealongside existing controller - Share
WorkRepositoryandAiFacadeServiceimports
- Add
Phase 1: Enable Tool Calling in AiOperations
File: packages/plugin/src/ai/ai-operations.ts
1.1 Update toLangChainMessages() (line 387)
Add support for tool role messages and assistant messages with tool calls:
import { ToolMessage } from '@langchain/core/messages';
private toLangChainMessages(messages: readonly ChatMessage[]) {
return messages.map((msg) => {
const content = typeof msg.content === 'string' ? msg.content : '';
switch (msg.role) {
case 'system':
return new SystemMessage(content);
case 'assistant': {
const aiMsg = new AIMessage(content);
if (msg.toolCalls?.length) {
aiMsg.tool_calls = msg.toolCalls.map((tc) => ({
id: tc.id,
name: tc.function.name,
args: JSON.parse(tc.function.arguments),
type: 'tool_call' as const,
}));
}
return aiMsg;
}
case 'tool':
return new ToolMessage({
content,
tool_call_id: (msg as any).toolCallId ?? '',
});
case 'user':
default:
return new HumanMessage(content);
}
});
}
1.2 Update createChatModel() (line 316)
Bind tools to the LangChain model when present:
private createChatModel(
config: AiOperationsConfig,
model: string,
options?: ChatCompletionOptions,
skip?: Set<string>
): ChatOpenAI {
// ... existing code ...
const llm = new ChatOpenAI({ ... });
// Bind tools if provided
if (options?.tools?.length && !skip?.has('tools')) {
return llm.bind({
tools: options.tools.map((t) => ({
type: 'function' as const,
function: t.function,
})),
...(options.toolChoice && { tool_choice: options.toolChoice }),
}) as unknown as ChatOpenAI;
}
return llm;
}
1.3 Update createChatCompletion() (line 44)
Parse tool calls from LangChain response:
const response = await llm.invoke(messages, { callbacks: [tracker] });
const content = typeof response.content === 'string' ? response.content : '';
// Extract tool calls from response
const toolCalls = response.tool_calls?.map((tc) => ({
id: tc.id ?? `call_${Date.now()}`,
type: 'function' as const,
function: {
name: tc.name,
arguments: JSON.stringify(tc.args)
}
}));
const hasToolCalls = toolCalls && toolCalls.length > 0;
return {
id: `chatcmpl-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
model,
created: Date.now(),
choices: [
{
index: 0,
message: {
role: 'assistant',
content,
...(hasToolCalls && { toolCalls })
},
finishReason: hasToolCalls ? 'tool_calls' : 'stop'
}
],
usage: this.mapTokenUsage(tracker)
};
1.4 Update createStreamingChatCompletion() (line 109)
Parse tool call chunks from streaming response:
for await (const chunk of stream) {
const content = typeof chunk.content === 'string' ? chunk.content : '';
// Extract tool call chunks
const toolCallChunks = chunk.tool_call_chunks?.map((tc) => ({
id: tc.id ?? '',
type: 'function' as const,
function: {
name: tc.name ?? '',
arguments: tc.args ?? '',
},
}));
yield {
id: `chatcmpl-${Date.now()}`,
model,
created: Date.now(),
choices: [{
index: 0,
delta: {
role: 'assistant',
content,
...(toolCallChunks?.length && { toolCalls: toolCallChunks }),
},
finishReason: null,
}],
};
}
1.5 Add 'tools' to parseRejectedParam() (line 377)
if (msg.includes("'tools'") || msg.includes('"tools"') || msg.includes('tool_choice')) return 'tools';
1.6 Update ChatMessage interface
Add toolCallId field for tool result messages:
File: packages/plugin/src/contracts/capabilities/ai-provider.interface.ts
export interface ChatMessage {
readonly role: ChatMessageRole;
readonly content: string | readonly ChatMessageContent[];
readonly name?: string;
readonly functionCall?: FunctionCall;
readonly toolCalls?: readonly ToolCall[];
readonly toolCallId?: string; // NEW: For tool result messages
}
Phase 2: OpenAI-Compatible NestJS Endpoint
2.1 New DTO: apps/api/src/ai-conversation/dto/openai-compat.dto.ts
import { IsArray, IsBoolean, IsNumber, IsObject, IsOptional, IsString } from 'class-validator';
export class OpenAiChatCompletionRequestDto {
@IsString() @IsOptional() model?: string;
@IsArray() messages: OpenAiMessage[];
@IsNumber() @IsOptional() temperature?: number;
@IsNumber() @IsOptional() max_tokens?: number;
@IsNumber() @IsOptional() top_p?: number;
@IsNumber() @IsOptional() frequency_penalty?: number;
@IsNumber() @IsOptional() presence_penalty?: number;
@IsArray() @IsOptional() stop?: string[];
@IsBoolean() @IsOptional() stream?: boolean;
@IsArray() @IsOptional() tools?: OpenAiToolDefinition[];
@IsOptional() tool_choice?: string | { type: string; function: { name: string } };
@IsObject() @IsOptional() response_format?: { type: string };
@IsString() @IsOptional() user?: string;
}
// Supporting types for clarity
export interface OpenAiMessage {
role: string;
content: string | null;
name?: string;
tool_calls?: Array<{
id: string;
type: 'function';
function: { name: string; arguments: string };
}>;
tool_call_id?: string;
}
export interface OpenAiToolDefinition {
type: 'function';
function: {
name: string;
description?: string;
parameters?: Record<string, unknown>;
};
}
2.2 New Service: apps/api/src/ai-conversation/openai-compat.service.ts
Responsibilities:
- Map OpenAI wire format -> internal
ChatCompletionOptions - Map internal
ChatCompletionResponse/ChatCompletionChunk-> OpenAI wire format - Handle both streaming (SSE) and non-streaming paths
- Reuse
AiFacadeServicefor provider resolution
Key methods:
handleCompletion(dto, facadeOptions)- Non-streaming pathhandleStreamingCompletion(dto, facadeOptions, res)- Streaming SSE pathmapToInternalOptions(dto)- Convert OpenAI format to internal formatmapToOpenAiResponse(response)- Convert internal response to OpenAI formatmapToOpenAiChunk(chunk, index)- Convert internal chunk to OpenAI SSE chunkresolveWorkContext(options)- Reused from existing service
Message mapping (OpenAI -> Internal):
{ role: "user", content: "..." } -> { role: "user", content: "..." }
{ role: "assistant", tool_calls: [...] } -> { role: "assistant", toolCalls: [...] }
{ role: "tool", tool_call_id: "...", content: "..." } -> { role: "tool", toolCallId: "...", content: "..." }
Response mapping (Internal -> OpenAI):
{ choices: [{ message: { toolCalls } }] } -> { choices: [{ message: { tool_calls } }] }
{ choices: [{ delta: { toolCalls } }] } -> { choices: [{ delta: { tool_calls } }] }
2.3 New Controller: apps/api/src/ai-conversation/openai-compat.controller.ts
@ApiTags('AI - OpenAI Compatible')
@ApiBearerAuth('JWT-auth')
@Controller('api/v1')
export class OpenAiCompatController {
constructor(private readonly service: OpenAiCompatService) {}
@Post('chat/completions')
@HttpCode(200)
async chatCompletions(
@CurrentUser() auth: AuthenticatedUser,
@Headers('x-provider-override') providerOverride: string | undefined,
@Headers('x-work-id') workId: string | undefined,
@Body() body: OpenAiChatCompletionRequestDto,
@Res() res: Response
): Promise<void> {
const facadeOptions = {
userId: auth.userId,
workId,
providerOverride
};
if (body.stream) {
// SSE streaming
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
await this.service.handleStreamingCompletion(body, facadeOptions, res);
} else {
// JSON response
const result = await this.service.handleCompletion(body, facadeOptions);
res.setHeader('Content-Type', 'application/json');
res.json(result);
}
}
}
2.4 SSE Streaming Format
Each chunk must be exactly:
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1234,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}\n\n
Final message:
data: [DONE]\n\n
2.5 Update Module: apps/api/src/ai-conversation/ai-conversation.module.ts
@Module({
imports: [FacadesModule, DatabaseModule],
controllers: [AiConversationController, OpenAiCompatController],
providers: [AiConversationService, OpenAiCompatService]
})
export class AiConversationModule {}
Phase 3: Install Dependencies
apps/web/package.json - Add:
cd /home/ubuntu/projects/ever/ever-works && pnpm add ai @ai-sdk/openai-compatible @ai-sdk/react --filter ever-works-web
Packages:
ai- Core Vercel AI SDK (streamText, convertToModelMessages, tool, etc.)@ai-sdk/openai-compatible- OpenAI-compatible provider factory@ai-sdk/react- React hooks (useChat)
Version alignment: ai@^6.0.85 matches the version already used in packages/plugins/agent-pipeline/.
Phase 4: Custom Vercel AI SDK Provider
Provider Selection Requirements
X-Provider-Overrideheader is always passed — the frontend always has an active AI provider selected- Default provider:
openrouter(auto-selected viaresolveEffectiveDefault()fromChatProvider) - Users can switch providers from the chat UI (provider selector buttons)
- The backend
AiFacadeServiceuses the override to resolve the correct plugin + settings
Auth Pattern
- Next.js route handler reads the encrypted JWT from the
everworks_auth_tokencookie viagetAuthAccessCookie() - On 401, it attempts a single token refresh via
refreshAccessToken()(same pattern asserverFetch) - The JWT is passed as
apiKeyto@ai-sdk/openai-compatible, which sends it asAuthorization: Bearer <token> - No JWT is ever exposed to the client browser
URL Resolution
API_URLfrom constants already includes/apisuffix (e.g.,http://localhost:3100/api)- The OpenAI-compat endpoint is at
POST /api/v1/chat/completions - So
baseURL=${API_URL}/v1which resolves tohttp://localhost:3100/api/v1
New File: apps/web/src/lib/ai/provider.ts
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
export interface BackendProviderOptions {
baseURL: string;
authToken: string;
providerOverride: string; // Always required — user always has an active AI provider
workId?: string;
}
export function createBackendProvider(options: BackendProviderOptions) {
return createOpenAICompatible({
name: 'ever-works',
baseURL: options.baseURL,
apiKey: options.authToken,
headers: {
'X-Provider-Override': options.providerOverride,
...(options.workId && { 'X-Work-Id': options.workId })
}
});
}
New File: apps/web/src/lib/ai/index.ts
export { createBackendProvider, type BackendProviderOptions } from './provider';
Note: @ai-sdk/openai-compatible automatically sends apiKey as Authorization: Bearer <apiKey>. This matches our JWT auth pattern exactly.
Phase 5: Next.js Route Handler
New File: apps/web/src/app/api/chat/route.ts
import { convertToModelMessages, streamText, UIMessage } from 'ai';
import { createBackendProvider } from '@/lib/ai/provider';
import { getAuthAccessCookie } from '@/lib/auth/cookies';
import { refreshAccessToken } from '@/lib/auth/refresh';
import { API_URL } from '@/lib/constants';
export const maxDuration = 60;
export async function POST(request: Request) {
// 1. Auth - same pattern as serverFetch
let token = await getAuthAccessCookie();
if (!token) {
const refreshed = await refreshAccessToken();
if (refreshed) token = await getAuthAccessCookie();
}
if (!token) {
return new Response('Unauthorized', { status: 401 });
}
// 2. Parse request
const body = await request.json();
const { messages, ...rest } = body as {
messages: UIMessage[];
providerOverride?: string;
workId?: string;
};
// 3. Create provider (API_URL already includes /api suffix)
const provider = createBackendProvider({
baseURL: `${API_URL}/v1`,
authToken: token,
providerOverride: rest.providerOverride ?? 'openrouter',
workId: rest.workId
});
// 4. Stream with Vercel AI SDK
const result = streamText({
model: provider('default'),
messages: await convertToModelMessages(messages)
});
return result.toUIMessageStreamResponse();
}
Key details:
maxDuration = 60allows long streaming responses (Vercel default is 10s)- Auth follows the same
getAuthAccessCookie+ refresh pattern asserverFetch - Provider/work selection passed from the client as body fields
convertToModelMessages()convertsUIMessage[]to model-compatible formattoUIMessageStreamResponse()returns the proper streaming response foruseChat
Phase 6: Frontend Chat UI Refactor
6.1 Update ChatProvider.tsx
'use client';
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { useChat } from '@ai-sdk/react';
import type { UIMessage } from 'ai';
import { useTranslations } from 'next-intl';
import type { ProviderOption } from '@/lib/api/types-only';
import { getGlobalFormSchema } from '@/app/actions/dashboard/generator-form';
import { resolveEffectiveDefault } from '@ever-works/plugin';
import { toast } from 'sonner';
interface ChatContextValue {
// From useChat
messages: UIMessage[];
setMessages: (messages: UIMessage[] | ((messages: UIMessage[]) => UIMessage[])) => void;
input: string;
setInput: (input: string) => void;
handleSubmit: (e?: { preventDefault?: () => void }) => void;
status: 'submitted' | 'streaming' | 'ready' | 'error';
error: Error | undefined;
stop: () => void;
reload: () => void;
// Provider selection
providers: ProviderOption[];
selectedProvider: string | null;
setSelectedProvider: (id: string | null) => void;
// Custom
resetChat: () => void;
}
const ChatContext = createContext<ChatContextValue | null>(null);
export function ChatProvider({ children }: { children: React.ReactNode }) {
const t = useTranslations('dashboard.aiChat');
const [providers, setProviders] = useState<ProviderOption[]>([]);
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const welcomeMessage: UIMessage = {
id: 'welcome',
role: 'assistant',
parts: [{ type: 'text', text: t('welcomeMessage') }],
createdAt: new Date(),
};
const chat = useChat({
api: '/api/chat',
body: {
providerOverride: selectedProvider,
},
initialMessages: [welcomeMessage],
});
// ... provider fetching (same as current) ...
const resetChat = useCallback(() => {
chat.setMessages([welcomeMessage]);
}, [chat.setMessages, welcomeMessage]);
const value: ChatContextValue = {
messages: chat.messages,
setMessages: chat.setMessages,
input: chat.input,
setInput: chat.setInput,
handleSubmit: chat.handleSubmit,
status: chat.status,
error: chat.error,
stop: chat.stop,
reload: chat.reload,
providers,
selectedProvider,
setSelectedProvider,
resetChat,
};
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
}
export function useChatContext(): ChatContextValue {
const context = useContext(ChatContext);
if (!context) throw new Error('useChatContext must be used within a ChatProvider');
return context;
}
6.2 Update ChatInterface.tsx
Major simplification - remove:
useAIStreamimport and all manual streaming logicpendingMessageRefandupdatePendingMessageuseChatHistory(now handled byuseChat)- Manual message creation in
handleSubmit - Custom NDJSON error handling
Replace with:
useChatContext()for all state (messages, input, status, etc.)handleSubmitfromuseChat(pass directly to form)status === 'streaming'instead ofisStreamingmessage.partsiteration for rendering (handles text + tool invocations)chat.reload()for message editing (set messages then reload)
Message rendering changes:
// Old: message.content (plain string)
// New: message.parts (array of typed parts)
{
message.parts.map((part, i) => {
if (part.type === 'text') {
return (
<p key={i} className="text-xs leading-relaxed whitespace-pre-wrap">
{part.text}
</p>
);
}
if (part.type === 'tool-invocation') {
return <ToolCallDisplay key={i} toolInvocation={part.toolInvocation} />;
}
return null;
});
}
Message editing approach:
const handleSaveEdit = async () => {
if (!editingId || !editingContent.trim()) return;
const editIndex = messages.findIndex((m) => m.id === editingId);
if (editIndex === -1) return;
// Truncate to edited message and update content
const updatedMessages = messages.slice(0, editIndex + 1);
updatedMessages[editIndex] = {
...updatedMessages[editIndex],
parts: [{ type: 'text', text: editingContent.trim() }]
};
setMessages(updatedMessages);
setEditingId(null);
setEditingContent('');
reload(); // Re-generate from the edited message
};
Phase 7: Cleanup
Files to Remove
| File | Reason |
|---|---|
apps/web/src/lib/hooks/use-ai-stream.ts | Replaced by useChat |
apps/web/src/lib/hooks/use-chat-history.ts | Replaced by useChat |
apps/web/src/lib/api/ai-conversation.ts | Replaced by custom provider |
apps/web/src/app/api/ai-conversations/chat/stream/route.ts | Replaced by /api/chat/route.ts |
apps/web/src/lib/utils/next-api.ts | Only used by removed route |
Files to Update
| File | Change |
|---|---|
apps/web/src/lib/api/index.ts | Remove export * from './ai-conversation' |
apps/web/src/lib/constants.ts | Remove API_AI_CONVERSATIONS_CHAT_STREAM, add API_CHAT: '/api/chat' |
apps/api/src/ai-conversation/ai-conversation.controller.ts | Mark @Deprecated(), keep for now |
apps/api/src/ai-conversation/ai-conversation.service.ts | Keep (shared resolveWorkContext logic) |
Verification Checklist
- Search for
use-ai-streamimports -> should find none - Search for
use-chat-historyimports -> should find none - Search for
aiConversationAPIimports -> should find none - Search for
nextApiResponseStreamingimports -> should find none (after removing next-api.ts) - Search for
API_AI_CONVERSATIONS_CHAT_STREAM-> should find none
Testing & Verification
Manual Testing
-
Start API server:
pnpm dev:api -
Test OpenAI-compat endpoint directly with curl:
# Non-streamingcurl -X POST http://localhost:3100/api/v1/chat/completions \-H "Authorization: Bearer <jwt>" \-H "Content-Type: application/json" \-d '{"model":"default","messages":[{"role":"user","content":"Hello"}]}'# Streamingcurl -X POST http://localhost:3100/api/v1/chat/completions \-H "Authorization: Bearer <jwt>" \-H "Content-Type: application/json" \-d '{"model":"default","messages":[{"role":"user","content":"Hello"}],"stream":true}' -
Start web:
pnpm dev:web -
Test chat UI: send messages, verify streaming works
-
Test provider switching
-
Test message editing
-
Test new chat / reset
Automated Testing
cd packages/agent && pnpm test- Verify no regression in facade/pipeline testscd packages/plugin && pnpm test- Verify AiOperations changes don't break testspnpm type-check- Full TypeScript check across monorepopnpm lint- ESLint check
Edge Cases
- Auth token expiry during streaming
- Provider not configured (should show error)
- Network interruption during stream
- Empty messages / whitespace-only input
- Long conversations (context window)
- Provider that doesn't support tools (graceful degradation)
Risk Mitigation
-
LangChain tool binding compatibility: LangChain's
ChatOpenAIsupports OpenAI-format tools natively. Non-OpenAI providers (Anthropic, Google) using ChatOpenAI with custom baseURL should also work since the tools are passed in OpenAI format. ThewithParamRetrypattern will catch providers that reject tools. -
SSE format strictness:
@ai-sdk/openai-compatibleexpects exact OpenAI SSE format. Test with curl before connecting frontend. -
Message format migration:
useChatusesUIMessagewithparts[]array, not plaincontentstring. The ChatInterface rendering must handle this properly. -
Parallel operation: Old and new endpoints can coexist during development. No flag-day cutover required.