Synced from private repo (feature/ai-sdk-6-upgrade): - Upgrade AI SDK 5 → 6 (packages + API changes) - Add sqliteQuery tool for flexible read-only queries - New WorkflowExecutor with workflow-specific tools - 83% token reduction for workflow execution - Remove mini-rah dead code (simplified architecture) - Add context management usage endpoint - Fix Connect workflow instructions - Clickable node references in workflow UI Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
466 lines
17 KiB
TypeScript
466 lines
17 KiB
TypeScript
import { NextRequest } from 'next/server';
|
|
import { streamText, convertToModelMessages } from 'ai';
|
|
import { createAnthropic } from '@ai-sdk/anthropic';
|
|
import { createOpenAI } from '@ai-sdk/openai';
|
|
import { getHelperTools, getDefaultToolNamesForRole } from '@/tools/infrastructure/registry';
|
|
import { buildSystemPromptBlocks } from '@/services/helpers/contextBuilder';
|
|
import { helperLogger } from '@/services/helpers/logger';
|
|
import { withChatLogging } from '@/services/chat/middleware';
|
|
import { AgentRegistry } from '@/services/agents/registry';
|
|
import { calculateCost } from '@/services/analytics/pricing';
|
|
import { UsageData } from '@/types/analytics';
|
|
import type { CacheStats } from '@/types/prompts';
|
|
import { randomUUID } from 'crypto';
|
|
import { RequestContext } from '@/services/context/requestContext';
|
|
import { isLocalMode } from '@/config/runtime';
|
|
|
|
export const maxDuration = 900; // 15 minutes (for workflows)
|
|
|
|
if (isLocalMode()) {
|
|
// TODO: add any special local-mode setup if needed later
|
|
}
|
|
|
|
const ANTHROPIC_MODEL_MAP: Record<string, string> = {
|
|
'claude-sonnet-4.5': 'claude-sonnet-4-5-20250929',
|
|
'claude-3-5-sonnet': 'claude-3-5-sonnet-20241022',
|
|
};
|
|
|
|
type ApiKeyOverrides = {
|
|
openai?: string;
|
|
anthropic?: string;
|
|
};
|
|
|
|
function resolveModel(modelId: string, apiKeys?: ApiKeyOverrides) {
|
|
if (modelId.startsWith('anthropic/')) {
|
|
const rawName = modelId.split('/')[1];
|
|
const mapped = ANTHROPIC_MODEL_MAP[rawName] || rawName;
|
|
|
|
const orchestratorKey =
|
|
apiKeys?.anthropic ||
|
|
process.env.RAH_ORCHESTRATOR_ANTHROPIC_API_KEY ||
|
|
process.env.ANTHROPIC_API_KEY;
|
|
if (!orchestratorKey) {
|
|
throw new Error('RAH_ORCHESTRATOR_ANTHROPIC_API_KEY (or ANTHROPIC_API_KEY) is not set.');
|
|
}
|
|
const provider = createAnthropic({
|
|
apiKey: orchestratorKey,
|
|
headers: {
|
|
'anthropic-beta': 'prompt-caching-2024-07-31',
|
|
},
|
|
});
|
|
return provider(mapped);
|
|
}
|
|
if (modelId.startsWith('openai/')) {
|
|
const name = modelId.split('/')[1];
|
|
|
|
const delegateKey =
|
|
apiKeys?.openai ||
|
|
process.env.RAH_DELEGATE_OPENAI_API_KEY ||
|
|
process.env.OPENAI_API_KEY;
|
|
if (!delegateKey) {
|
|
throw new Error('RAH_DELEGATE_OPENAI_API_KEY (or OPENAI_API_KEY) is not set.');
|
|
}
|
|
const provider = createOpenAI({ apiKey: delegateKey });
|
|
return provider(name);
|
|
}
|
|
throw new Error(`Unsupported model id: ${modelId}`);
|
|
}
|
|
|
|
// Global cache stats storage for monitoring
|
|
declare global {
|
|
// eslint-disable-next-line no-var
|
|
var lastCacheStats: CacheStats | undefined;
|
|
}
|
|
|
|
type AnthropicUsageLike = {
|
|
inputTokens?: number;
|
|
outputTokens?: number;
|
|
promptTokens?: number;
|
|
completionTokens?: number;
|
|
cacheCreationInputTokens?: number;
|
|
cachedInputTokens?: number;
|
|
totalTokens?: number;
|
|
cache_write_input_tokens?: number;
|
|
cache_read_input_tokens?: number;
|
|
cacheWriteInputTokens?: number;
|
|
cacheReadInputTokens?: number;
|
|
};
|
|
|
|
function normaliseUsage(entry: AnthropicUsageLike | undefined | null) {
|
|
if (!entry) {
|
|
return { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 };
|
|
}
|
|
|
|
const toNumber = (value: unknown): number => {
|
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
if (typeof value === 'string' && value.trim().length > 0) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
}
|
|
return 0;
|
|
};
|
|
|
|
const input = toNumber(entry.inputTokens ?? entry.promptTokens ?? (entry as any).input_tokens ?? 0);
|
|
const output = toNumber(entry.outputTokens ?? entry.completionTokens ?? (entry as any).output_tokens ?? 0);
|
|
const cacheWrite = toNumber(
|
|
entry.cacheCreationInputTokens ??
|
|
(entry as any).cache_creation_input_tokens ??
|
|
entry.cacheWriteInputTokens ??
|
|
entry.cache_write_input_tokens ??
|
|
0
|
|
);
|
|
const cacheRead = toNumber(
|
|
entry.cachedInputTokens ??
|
|
entry.cacheReadInputTokens ??
|
|
(entry as any).cache_read_input_tokens ??
|
|
0
|
|
);
|
|
|
|
return { input, output, cacheWrite, cacheRead };
|
|
}
|
|
|
|
function aggregateAnthropicUsage(usage: AnthropicUsageLike | undefined, providerMetadata: any) {
|
|
const totals = { inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0 };
|
|
let dataSources = 0;
|
|
|
|
const add = (entry: AnthropicUsageLike | undefined | null) => {
|
|
if (!entry) return;
|
|
const { input, output, cacheWrite, cacheRead } = normaliseUsage(entry);
|
|
if (input || output || cacheWrite || cacheRead) {
|
|
totals.inputTokens += input;
|
|
totals.outputTokens += output;
|
|
totals.cacheWriteTokens += cacheWrite;
|
|
totals.cacheReadTokens += cacheRead;
|
|
dataSources += 1;
|
|
}
|
|
};
|
|
|
|
const anthropicMeta = providerMetadata?.anthropic;
|
|
|
|
if (anthropicMeta) {
|
|
if (Array.isArray(anthropicMeta.requests)) {
|
|
anthropicMeta.requests.forEach((req: any) => {
|
|
add(req?.usage ?? req);
|
|
});
|
|
}
|
|
|
|
if (anthropicMeta.response?.usage) {
|
|
add(anthropicMeta.response.usage);
|
|
}
|
|
|
|
if (anthropicMeta.usage && dataSources === 0) {
|
|
add(anthropicMeta.usage);
|
|
}
|
|
}
|
|
|
|
if (dataSources === 0) {
|
|
add(usage);
|
|
}
|
|
|
|
// Fallback to usage totals if metadata did not provide cache info
|
|
if (totals.cacheReadTokens === 0 && usage?.cachedInputTokens) {
|
|
totals.cacheReadTokens = normaliseUsage(usage).cacheRead;
|
|
}
|
|
if (totals.cacheWriteTokens === 0 && usage?.cacheCreationInputTokens) {
|
|
totals.cacheWriteTokens = normaliseUsage(usage).cacheWrite;
|
|
}
|
|
|
|
return totals;
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
let helperKey = 'ra-h';
|
|
try {
|
|
const {
|
|
messages = [],
|
|
openTabs = [],
|
|
activeTabId = null,
|
|
currentView = 'nodes',
|
|
sessionId,
|
|
traceId,
|
|
mode: requestedMode = 'easy',
|
|
apiKeys: rawApiKeys
|
|
} = await request.json();
|
|
const apiKeys: ApiKeyOverrides | undefined = rawApiKeys
|
|
? {
|
|
openai: typeof rawApiKeys.openai === 'string' && rawApiKeys.openai.trim().length > 0
|
|
? rawApiKeys.openai.trim()
|
|
: undefined,
|
|
anthropic: typeof rawApiKeys.anthropic === 'string' && rawApiKeys.anthropic.trim().length > 0
|
|
? rawApiKeys.anthropic.trim()
|
|
: undefined,
|
|
}
|
|
: undefined;
|
|
|
|
const mode: 'easy' | 'hard' = requestedMode === 'hard' ? 'hard' : 'easy';
|
|
helperKey = mode === 'hard' ? 'ra-h' : 'ra-h-easy';
|
|
|
|
const conversationTraceId = traceId || randomUUID();
|
|
|
|
RequestContext.set({
|
|
traceId: conversationTraceId,
|
|
openTabs,
|
|
activeTabId,
|
|
mode,
|
|
apiKeys,
|
|
});
|
|
|
|
// Filter messages to only valid roles
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const sanitizedMessages = messages.filter((msg: any) =>
|
|
msg && ['user', 'assistant', 'system'].includes(msg.role)
|
|
);
|
|
|
|
const helperConfig = await AgentRegistry.orchestratorForMode(mode);
|
|
if (!helperConfig) {
|
|
throw new Error(`No orchestrator definition found for mode '${mode}'`);
|
|
}
|
|
|
|
helperKey = helperConfig.key || helperKey;
|
|
|
|
helperLogger.logUserMessage(helperKey, messages, openTabs, activeTabId);
|
|
|
|
const { blocks: systemBlocks, cacheHit } = await buildSystemPromptBlocks(
|
|
{ nodes: openTabs, activeNodeId: activeTabId },
|
|
helperKey
|
|
);
|
|
|
|
const systemPromptPreview = systemBlocks.map(b => b.text).join('\n').substring(0, 200) + '...';
|
|
helperLogger.logSystemPrompt(helperKey, systemPromptPreview, cacheHit);
|
|
|
|
console.log('🔧 [Prompt Caching] System blocks structure:', {
|
|
helperKey,
|
|
totalBlocks: systemBlocks.length,
|
|
cachedBlocks: systemBlocks.filter(b => b.cache_control).length,
|
|
blockLengths: systemBlocks.map((b, i) => ({
|
|
index: i,
|
|
length: b.text.length,
|
|
cached: !!b.cache_control
|
|
}))
|
|
});
|
|
|
|
const toolNames = helperConfig.availableTools?.length
|
|
? helperConfig.availableTools
|
|
: getDefaultToolNamesForRole(helperConfig.role);
|
|
const tools = getHelperTools(toolNames);
|
|
const modelId = helperConfig.model || 'anthropic/claude-sonnet-4.5';
|
|
const model = resolveModel(modelId, apiKeys);
|
|
|
|
const rawModelId = modelId.split('/')[1] || modelId;
|
|
const fullModelId = ANTHROPIC_MODEL_MAP[rawModelId] || rawModelId;
|
|
|
|
const isAnthropicModel = modelId.startsWith('anthropic/');
|
|
const isOpenAIModel = modelId.startsWith('openai/');
|
|
|
|
const toolsUsedInSession: string[] = [];
|
|
|
|
// Convert system blocks to messages with providerOptions for caching
|
|
const systemMessages = systemBlocks.map((block) => ({
|
|
role: 'system' as const,
|
|
content: block.text,
|
|
...(isAnthropicModel && block.cache_control ? {
|
|
providerOptions: {
|
|
anthropic: { cacheControl: block.cache_control }
|
|
}
|
|
} : {})
|
|
}));
|
|
|
|
const coreMessages = await convertToModelMessages(sanitizedMessages);
|
|
const allMessages = [...systemMessages, ...coreMessages];
|
|
|
|
// Debug logging (can be removed in production)
|
|
if (process.env.DEBUG_CACHE === 'true') {
|
|
console.log('🔍 [Debug] System messages with cache control:');
|
|
systemMessages.forEach((msg, i) => {
|
|
console.log(` Block ${i}:`, {
|
|
hasContent: !!msg.content,
|
|
contentLength: msg.content?.length || 0,
|
|
hasProviderOptions: !!msg.providerOptions,
|
|
cacheControl: msg.providerOptions?.anthropic?.cacheControl
|
|
});
|
|
});
|
|
}
|
|
|
|
const streamConfig = {
|
|
model,
|
|
messages: allMessages,
|
|
tools,
|
|
stopWhen: [],
|
|
maxSteps: 10,
|
|
...(isOpenAIModel ? { reasoning: { effort: 'light' as const } } : {}),
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
onToolCall: ({ toolCall }: any) => {
|
|
helperLogger.logToolCall(helperKey, toolCall.toolName, toolCall.args);
|
|
if (!toolsUsedInSession.includes(toolCall.toolName)) {
|
|
toolsUsedInSession.push(toolCall.toolName);
|
|
}
|
|
},
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
onToolResult: ({ toolCall, result }: any) => {
|
|
helperLogger.logToolResult(helperKey, toolCall.toolName, result);
|
|
},
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
onFinish: (result: any) => {
|
|
helperLogger.logAssistantResponse(helperKey, result.text);
|
|
|
|
// Debug logging (can be removed in production)
|
|
if (process.env.DEBUG_CACHE === 'true') {
|
|
console.log('🔍 [Debug] Full onFinish result keys:', Object.keys(result));
|
|
console.log('🔍 [Debug] Raw usage object:', JSON.stringify(result.usage, null, 2));
|
|
console.log('🔍 [Debug] Provider metadata:', JSON.stringify(result.providerMetadata, null, 2));
|
|
console.log('🔍 [Debug] Steps array length:', result.steps?.length || 0);
|
|
if (result.steps && result.steps.length > 0) {
|
|
result.steps.forEach((step: any, i: number) => {
|
|
console.log(`🔍 [Debug] Step ${i} usage:`, JSON.stringify(step.usage, null, 2));
|
|
console.log(`🔍 [Debug] Step ${i} providerMetadata:`, JSON.stringify(step.providerMetadata, null, 2));
|
|
});
|
|
}
|
|
}
|
|
|
|
const aggregatedUsage = isAnthropicModel
|
|
? (() => {
|
|
// Aggregate across ALL steps, not just final result
|
|
const totals = { inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0 };
|
|
|
|
if (result.steps && Array.isArray(result.steps)) {
|
|
result.steps.forEach((step: any) => {
|
|
const stepUsage = aggregateAnthropicUsage(step.usage, step.providerMetadata);
|
|
totals.inputTokens += stepUsage.inputTokens;
|
|
totals.outputTokens += stepUsage.outputTokens;
|
|
// Cache write only happens once (in first step), so take MAX not SUM
|
|
totals.cacheWriteTokens = Math.max(totals.cacheWriteTokens, stepUsage.cacheWriteTokens);
|
|
totals.cacheReadTokens += stepUsage.cacheReadTokens;
|
|
});
|
|
} else {
|
|
// Fallback to result-level usage if no steps
|
|
const resultUsage = aggregateAnthropicUsage(result.usage, result.providerMetadata);
|
|
totals.inputTokens = resultUsage.inputTokens;
|
|
totals.outputTokens = resultUsage.outputTokens;
|
|
totals.cacheWriteTokens = resultUsage.cacheWriteTokens;
|
|
totals.cacheReadTokens = resultUsage.cacheReadTokens;
|
|
}
|
|
|
|
return totals;
|
|
})()
|
|
: {
|
|
inputTokens: Number(result.usage?.inputTokens ?? result.usage?.promptTokens ?? 0),
|
|
outputTokens: Number(result.usage?.outputTokens ?? result.usage?.completionTokens ?? 0),
|
|
cacheWriteTokens: 0,
|
|
cacheReadTokens: 0,
|
|
};
|
|
|
|
const regular = aggregatedUsage.inputTokens || 0;
|
|
const cacheWrite = aggregatedUsage.cacheWriteTokens || 0;
|
|
const cacheRead = aggregatedUsage.cacheReadTokens || 0;
|
|
const outputTokens = aggregatedUsage.outputTokens || 0;
|
|
const total = regular + cacheWrite + cacheRead;
|
|
|
|
if (regular || cacheWrite || cacheRead || outputTokens) {
|
|
const savingsPercentage = total > 0 && cacheRead > 0 ? Math.round((cacheRead / total) * 100) : 0;
|
|
|
|
if (isAnthropicModel) {
|
|
const cacheStats: CacheStats = {
|
|
cacheCreationInputTokens: cacheWrite,
|
|
cacheReadInputTokens: cacheRead,
|
|
inputTokens: regular,
|
|
outputTokens,
|
|
savingsPercentage
|
|
};
|
|
|
|
console.log('\n📦 ═══════════════════════════════════════');
|
|
console.log('📦 ANTHROPIC PROMPT CACHE STATISTICS');
|
|
console.log('📦 ═══════════════════════════════════════');
|
|
console.log(`📦 Cache Write: ${cacheWrite.toLocaleString()} tokens ${cacheWrite > 0 ? '(NEW CACHE CREATED ✨)' : ''}`);
|
|
console.log(`📦 Cache Read: ${cacheRead.toLocaleString()} tokens ${cacheRead > 0 ? '(CACHE HIT 🎯)' : '(CACHE MISS ❌)'}`);
|
|
console.log(`📦 Regular: ${regular.toLocaleString()} tokens`);
|
|
console.log(`📦 Total Input: ${total.toLocaleString()} tokens`);
|
|
console.log(`📦 Output: ${cacheStats.outputTokens.toLocaleString()} tokens`);
|
|
if (cacheRead > 0) {
|
|
const costSavings = Math.round(((cacheWrite * 1.25 + regular * 1.0 + cacheRead * 0.1) / (total * 1.0)) * 100);
|
|
console.log(`📦 💰 Savings: ${savingsPercentage}% tokens, ~${100 - costSavings}% cost`);
|
|
}
|
|
console.log('📦 ═══════════════════════════════════════\n');
|
|
|
|
global.lastCacheStats = cacheStats;
|
|
}
|
|
|
|
const costResult = calculateCost({
|
|
inputTokens: regular,
|
|
outputTokens,
|
|
cacheWriteTokens: cacheWrite,
|
|
cacheReadTokens: cacheRead,
|
|
modelId: fullModelId,
|
|
});
|
|
|
|
usageData = {
|
|
inputTokens: regular,
|
|
outputTokens,
|
|
totalTokens: costResult.totalTokens,
|
|
cacheWriteTokens: cacheWrite,
|
|
cacheReadTokens: cacheRead,
|
|
cacheHit: cacheRead > 0,
|
|
cacheSavingsPct: savingsPercentage,
|
|
estimatedCostUsd: costResult.totalCostUsd,
|
|
modelUsed: fullModelId,
|
|
provider: isAnthropicModel ? 'anthropic' : 'openai',
|
|
toolsUsed: toolsUsedInSession.length > 0 ? toolsUsedInSession : undefined,
|
|
toolCallsCount: toolsUsedInSession.length > 0 ? toolsUsedInSession.length : undefined,
|
|
traceId: conversationTraceId,
|
|
workflowKey: currentContext.workflowKey,
|
|
workflowNodeId: currentContext.workflowNodeId,
|
|
mode,
|
|
};
|
|
}
|
|
}
|
|
};
|
|
|
|
let usageData: UsageData | undefined;
|
|
|
|
const systemMessageText = systemBlocks.map(b => b.text).join('\n\n');
|
|
const currentContext = RequestContext.get();
|
|
|
|
const chatMetadata = {
|
|
helperName: helperKey,
|
|
openTabs,
|
|
activeTabId,
|
|
currentView,
|
|
sessionId: sessionId || `session_${Date.now()}`,
|
|
agentType: 'orchestrator' as const,
|
|
traceId: conversationTraceId,
|
|
mode,
|
|
modelUsed: fullModelId,
|
|
systemMessage: systemMessageText,
|
|
workflowKey: currentContext.workflowKey,
|
|
workflowNodeId: currentContext.workflowNodeId,
|
|
get usageData() {
|
|
return usageData;
|
|
},
|
|
};
|
|
|
|
const result = await streamText(withChatLogging(streamConfig, chatMetadata, messages));
|
|
|
|
return result.toUIMessageStreamResponse();
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
const errorStack = error instanceof Error ? error.stack : undefined;
|
|
|
|
console.error(`❌ [${helperKey}] Route error:`, {
|
|
error: errorMessage,
|
|
stack: errorStack,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
helperLogger.logError(helperKey, error instanceof Error ? error : String(error));
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: errorMessage,
|
|
details: 'Check server logs for full error details'
|
|
}),
|
|
{
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
}
|
|
);
|
|
}
|
|
}
|