Files
openmaic/OpenMAIC/app/api/chat/pi/route.ts
2026-08-16 14:58:47 +08:00

216 lines
6.5 KiB
TypeScript

/**
* Pi Director Chat API Endpoint
*
* POST /api/chat/pi - parallel PoC path for running the in-class multi-agent
* chain as a single server-side pi agent loop.
*/
import { NextRequest } from 'next/server';
import { isProviderKeyRequired } from '@/lib/ai/providers';
import { isPiChatEnabled, isPiWebSearchEnabled } from '@/lib/config/feature-flags';
import { createLogger } from '@/lib/logger';
import {
getPiMaxActionsPerAgent,
getPiMaxAgentTurns,
resolveAgentConfigs,
} from '@/lib/chat/pi/config';
import { runPiDirectorLoop } from '@/lib/chat/pi/director-loop';
import type { SendEvent } from '@/lib/chat/pi/types';
import { resolveModel } from '@/lib/server/resolve-model';
import { apiError } from '@/lib/server/api-response';
import type { ThinkingConfig } from '@/lib/types/provider';
import type { StatelessChatRequest } from '@/lib/types/chat';
const log = createLogger('Pi Chat API');
export const maxDuration = 300;
export async function POST(req: NextRequest) {
if (!isPiChatEnabled()) {
return apiError('INVALID_REQUEST', 404, 'Pi chat runtime is disabled');
}
const encoder = new TextEncoder();
let chatModel: string | undefined;
let chatMessageCount: number | undefined;
try {
const body: StatelessChatRequest = await req.json();
chatModel = body.model;
chatMessageCount = body.messages?.length;
if (!body.messages || !Array.isArray(body.messages)) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing required field: messages');
}
if (!body.storeState) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing required field: storeState');
}
if (!body.config || body.config.agentIds == null) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing required field: config.agentIds');
}
const agentIds = body.config.agentIds;
if (
!Array.isArray(agentIds) ||
agentIds.length === 0 ||
agentIds.some((id) => typeof id !== 'string' || id.trim().length === 0 || id !== id.trim()) ||
new Set(agentIds).size !== agentIds.length
) {
return apiError(
'INVALID_REQUEST',
400,
'config.agentIds must be a non-empty array of unique, non-empty strings',
);
}
const {
model: languageModel,
apiKey: resolvedApiKey,
providerId,
modelInfo,
thinkingConfig: resolvedThinkingConfig,
} = await resolveModel({
modelString: body.model,
stage: 'chat-adapter',
apiKey: body.apiKey,
baseUrl: body.baseUrl,
providerType: body.providerType,
// Let resolveModel arbitrate thinking too: a routed chat-adapter's thinking
// wins, an unrouted one honors this client thinking (see resolve-model.ts).
thinkingConfig: body.thinkingConfig ?? body.thinking,
});
if (isProviderKeyRequired(providerId) && !resolvedApiKey) {
return apiError('MISSING_API_KEY', 401, 'API Key is required');
}
const agentConfigs = resolveAgentConfigs(body);
const resolvedAgentIds = new Set(agentConfigs.map((agent) => agent.id));
const unresolvedAgentIds = agentIds.filter((id) => !resolvedAgentIds.has(id));
if (unresolvedAgentIds.length > 0) {
return apiError(
'INVALID_REQUEST',
400,
`Unknown classroom agents in config.agentIds: ${unresolvedAgentIds.join(', ')}`,
);
}
if (agentConfigs.length === 0) {
return apiError(
'INVALID_REQUEST',
400,
`No valid classroom agents found for config.agentIds: ${body.config.agentIds.join(', ')}`,
);
}
const signal = req.signal;
const abortController = new AbortController();
signal.addEventListener('abort', () => abortController.abort(), { once: true });
const { readable, writable } = new TransformStream();
const writer = writable.getWriter();
const send: SendEvent = async (event) => {
await writer.write(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
};
const thinkingConfig: ThinkingConfig = resolvedThinkingConfig ?? {
mode: 'disabled',
enabled: false,
};
const maxAgentTurns = getPiMaxAgentTurns(body);
const maxActionsPerAgent = getPiMaxActionsPerAgent(body);
const enableWhiteboardTools = body.config.piEnableWhiteboardTools === true;
log.info(
`Pi request agents=${body.config.agentIds.join(', ')} messages=${body.messages.length} maxAgentTurns=${maxAgentTurns}`,
);
(async () => {
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
const startHeartbeat = () => {
heartbeatTimer = setInterval(() => {
writer.write(encoder.encode(`:heartbeat\n\n`)).catch(() => stopHeartbeat());
}, 15_000);
};
const stopHeartbeat = () => {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
};
try {
startHeartbeat();
await runPiDirectorLoop({
body,
agentConfigs,
send,
languageModel,
thinkingConfig,
maxOutputTokens: modelInfo?.outputWindow,
contextWindow: modelInfo?.contextWindow,
abortSignal: abortController.signal,
signal,
maxAgentTurns,
maxActionsPerAgent,
enableWhiteboardTools,
enableWebSearch: isPiWebSearchEnabled(),
});
if (signal.aborted) {
stopHeartbeat();
await writer.close();
return;
}
stopHeartbeat();
await writer.close();
} catch (error) {
stopHeartbeat();
if (signal.aborted) {
try {
await writer.close();
} catch {
/* already closed */
}
return;
}
log.error('Pi chat stream error:', error);
try {
await send({
type: 'error',
data: { message: error instanceof Error ? error.message : String(error) },
});
await writer.close();
} catch {
/* writer may already be closed */
}
}
})();
return new Response(readable, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
} catch (error) {
log.error(
`Pi chat request failed [model=${chatModel ?? 'unknown'}, messages=${chatMessageCount ?? 0}]:`,
error,
);
return apiError(
'INTERNAL_ERROR',
500,
error instanceof Error ? error.message : 'Failed to process request',
);
}
}