Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 81 additions & 9 deletions apps/web/src/app/api/ai/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ import {
} from '@/lib/ai/core/stream-abort-registry';
import { validateUserMessageFileParts, hasFileParts } from '@/lib/ai/core/validate-image-parts';
import { hasVisionCapability } from '@/lib/ai/core/model-capabilities';
import {
determineMessagesToInclude,
getContextWindowSize,
estimateSystemPromptTokens,
estimateToolDefinitionTokens,
} from '@pagespace/lib/ai-context-calculator';
import { isContextLengthError } from '@/lib/ai/shared/error-messages';


// Allow streaming responses up to 5 minutes for complex AI agent interactions
Expand All @@ -90,6 +97,7 @@ export async function POST(request: Request) {
let selectedProvider: string | undefined;
let selectedModel: string | undefined;
let usagePromise: Promise<LanguageModelUsage | undefined> | undefined;
let wasTruncated = false;
const usageLogger = loggers.ai.child({ module: 'page-ai-usage' });
const permissionLogger = loggers.ai.child({ module: 'page-ai-permissions' });

Expand Down Expand Up @@ -741,13 +749,10 @@ export async function POST(request: Request) {
});
}

// Convert UIMessages to ModelMessages for the AI model
// First sanitize messages to remove tool parts without results (prevents "input-available" state errors)
// Sanitize messages to remove tool parts without results (prevents "input-available" state errors)
// NOTE: We use database-loaded messages, NOT messages from client
// modelMessages is computed after system prompt is built so we can apply context truncation
const sanitizedMessages = sanitizeMessagesForModel(conversationHistory);
const modelMessages = convertToModelMessages(sanitizedMessages, {
tools: filteredTools // Use original tools - no wrapping needed
});

// Fetch user personalization for AI system prompt injection
const personalization = await getUserPersonalization(userId);
Expand Down Expand Up @@ -818,8 +823,62 @@ export async function POST(request: Request) {
}

loggers.ai.debug('AI Chat API: Tools configured for Page AI', { toolCount: Object.keys(filteredTools).length });

// Context-length guard: proactively truncate oldest messages to fit within the model's context window.
// This prevents AI_APICallError from providers when a conversation grows too long.
// We build modelMessages here (after system prompt) so we have accurate token budgeting.
const fullSystemPrompt = systemPrompt + timestampSystemPrompt + pageTreePrompt;
const contextWindow = getContextWindowSize(currentModel, currentProvider);
const systemPromptTokens = estimateSystemPromptTokens(fullSystemPrompt);
const toolTokens = estimateToolDefinitionTokens(filteredTools as Record<string, unknown>);
// Reserve 25% of context window for output tokens and tokenizer inaccuracies
const INPUT_BUDGET_RATIO = 0.75;
const inputBudget = Math.floor(contextWindow * INPUT_BUDGET_RATIO);
const truncationResult = determineMessagesToInclude(
sanitizedMessages,
inputBudget,
systemPromptTokens,
toolTokens
);
const { includedMessages } = truncationResult;
wasTruncated = truncationResult.wasTruncated;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const truncationMeta = {
model: currentModel,
provider: currentProvider,
contextWindow,
inputBudget,
systemPromptTokens,
toolTokens,
originalMessageCount: sanitizedMessages.length,
};

if (wasTruncated) {
loggers.ai.warn('AI Chat API: Conversation truncated to fit context window', {
...truncationMeta,
includedMessageCount: includedMessages.length,
});
}

// Guard: if truncation left zero messages, the latest message alone exceeds the budget
if (includedMessages.length === 0) {
loggers.ai.error('AI Chat API: No messages fit within context budget', truncationMeta);
return NextResponse.json(
{
error: 'context_length_exceeded',
message: 'Your latest message is too large to fit within this model\'s context window. Try shortening your message or starting a new conversation.',
details: 'context_length_exceeded',
},
{ status: 413 }
);
}

const modelMessages = convertToModelMessages(includedMessages as UIMessage[], {
tools: filteredTools // Use original tools - no wrapping needed
});

loggers.ai.info('AI Chat API: Starting streamText for Page AI', { model: currentModel, pageName: page.title });

// Create UI message stream with visual content injection support
// This handles the case where tools return visual content that needs to be injected into the stream
let result;
Expand Down Expand Up @@ -856,7 +915,7 @@ export async function POST(request: Request) {
// Start the AI response
const aiResult = streamText({
model,
system: systemPrompt + timestampSystemPrompt + pageTreePrompt,
system: fullSystemPrompt,
messages: modelMessages,
tools: filteredTools, // Use original tools directly
stopWhen: stepCountIs(100), // Allow up to 100 tool calls per conversation turn
Expand Down Expand Up @@ -1199,8 +1258,21 @@ export async function POST(request: Request) {
});

// Return a proper error response
return NextResponse.json({
error: 'Failed to process chat request. Please try again.'
const errorMsg = error instanceof Error ? error.message : '';
if (isContextLengthError(errorMsg)) {
return NextResponse.json(
{
error: 'context_length_exceeded',
message: wasTruncated
? 'The conversation still exceeds this model\'s context window even after trimming. Please start a new conversation.'
: 'The conversation is too long for this model\'s context window. Please start a new conversation or try a model with a larger context window.',
details: 'context_length_exceeded',
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
{ status: 413 }
);
}
return NextResponse.json({
error: 'Failed to process chat request. Please try again.'
}, { status: 500 });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import { UIMessage } from 'ai';
import { getAIErrorMessage } from '@/lib/ai/shared/error-messages';
import { usePathname } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { ChatInput, type ChatInputRef } from '@/components/ai/chat/input';
Expand Down Expand Up @@ -787,16 +788,7 @@ const SidebarChatTab: React.FC = () => {
{error && showError && (
<div className="p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-xs flex items-center justify-between">
<p className="text-red-700 dark:text-red-300">
{error.message?.includes('Unauthorized') || error.message?.includes('401')
? 'Authentication failed. Please refresh the page and try again.'
: (error.message?.toLowerCase().includes('rate') ||
error.message?.toLowerCase().includes('limit') ||
error.message?.includes('429') ||
error.message?.includes('402') ||
error.message?.includes('Failed after') ||
error.message?.includes('Provider returned error'))
? 'Free tier rate limit hit. Please try again in a few seconds or subscribe for premium models and access.'
: 'Something went wrong. Please try again.'}
{getAIErrorMessage(error.message)}
</p>
<button
onClick={() => setShowError(false)}
Expand Down
70 changes: 57 additions & 13 deletions apps/web/src/lib/ai/shared/error-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,48 @@
* User-friendly error messages for AI chat errors
*/

/**
* Check if error is a request payload size error (distinct from context-window limits)
*/
export function isPayloadSizeError(errorMessage: string | undefined): boolean {
if (!errorMessage) return false;
const msg = errorMessage.toLowerCase();
return (
msg.includes('request body too large') ||
msg.includes('payload too large') ||
msg.includes('entity too large')
);
}

/**
* Get user-friendly error message based on error content
*/
export function getAIErrorMessage(errorMessage: string | undefined): string {
if (!errorMessage) return 'Something went wrong. Please try again.';

// Authentication errors
if (errorMessage.includes('Unauthorized') || errorMessage.includes('401')) {
const msg = errorMessage.toLowerCase();

if (isAuthenticationError(errorMessage)) {
return 'Authentication failed. Please refresh the page and try again.';
}

// Rate limit errors
if (
errorMessage.toLowerCase().includes('rate') ||
errorMessage.toLowerCase().includes('limit') ||
errorMessage.includes('429') ||
errorMessage.includes('402') ||
errorMessage.includes('Failed after') ||
errorMessage.includes('Provider returned error')
) {
if (isPayloadSizeError(errorMessage)) {
return 'Your request is too large. Try sending a shorter message or fewer/lower-size attachments.';
}

if (isContextLengthError(errorMessage)) {
// Preserve server-provided guidance when present
if (
msg.includes('latest message is too large') ||
msg.includes('even after trimming') ||
msg.includes('too long for this model')
) {
return errorMessage;
}
return 'The conversation is too long for this model\'s context window. Please start a new conversation or use a model with a larger context window.';
}

if (isRateLimitError(errorMessage)) {
return 'Free tier rate limit hit. Please try again in a few seconds or subscribe for premium models and access.';
}

Expand All @@ -36,14 +58,36 @@ export function isAuthenticationError(errorMessage: string | undefined): boolean
return errorMessage.includes('Unauthorized') || errorMessage.includes('401');
}

/**
* Check if error is a context length / token limit error
*/
export function isContextLengthError(errorMessage: string | undefined): boolean {
if (!errorMessage) return false;
if (isPayloadSizeError(errorMessage)) return false;

const msg = errorMessage.toLowerCase();
return (
msg.includes('context_length') ||
msg.includes('context length') ||
msg.includes('context window') ||
msg.includes('maximum context') ||
msg.includes('token limit exceeded') ||
msg.includes('tokens exceeds') ||
msg.includes('too many tokens') ||
(msg.includes('maximum') && msg.includes('tokens'))
);
}

/**
* Check if error is a rate limit error
*/
export function isRateLimitError(errorMessage: string | undefined): boolean {
if (!errorMessage) return false;
if (isContextLengthError(errorMessage)) return false;
const msg = errorMessage.toLowerCase();
return (
errorMessage.toLowerCase().includes('rate') ||
errorMessage.toLowerCase().includes('limit') ||
msg.includes('rate') ||
msg.includes('limit') ||
errorMessage.includes('429') ||
errorMessage.includes('402') ||
errorMessage.includes('Failed after') ||
Expand Down
67 changes: 57 additions & 10 deletions packages/lib/src/monitoring/ai-context-calculator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,27 @@ export interface ContextCalculation {
truncationStrategy: 'none' | 'oldest_first' | 'smart';
}

const CHARS_PER_TOKEN_LATIN = 4;
const CHARS_PER_TOKEN_CJK = 2;
const CJK_HEAVY_THRESHOLD = 0.2;

/**
* Estimate tokens in a text string
* Uses 4 characters per token as a rough estimate
* This is conservative - actual token count may be slightly lower
* Estimate tokens in a text string.
* Uses ~4 chars/token for Latin text, ~2 chars/token when significant
* non-ASCII / CJK content is detected (CJK characters often tokenize to 1-2 tokens each).
*/
export function estimateTokens(text: string): number {
if (!text) return 0;

// GPT-style tokenization: ~4 characters per token
// This is conservative to avoid underestimating
return Math.ceil(text.length / 4);
let nonAsciiCount = 0;
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) > 127) nonAsciiCount++;
}

const charsPerToken = (nonAsciiCount / text.length) > CJK_HEAVY_THRESHOLD
? CHARS_PER_TOKEN_CJK
: CHARS_PER_TOKEN_LATIN;
return Math.ceil(text.length / charsPerToken);
}

/**
Expand Down Expand Up @@ -137,6 +147,42 @@ export function getContextWindowSize(model: string, provider?: string): number {
const providerLower = provider?.toLowerCase() || '';
const modelLower = model.toLowerCase();

// OpenRouter must be checked first — its models contain names like 'claude', 'gpt', 'gemini'
// that would otherwise match the provider-specific branches below.
if (providerLower === 'openrouter') {
// Claude models via OpenRouter
if (modelLower.includes('claude')) return 200_000;
// Gemini models via OpenRouter
if (modelLower.includes('gemini-2.5')) return 1_000_000;
if (modelLower.includes('gemini-2.0') || modelLower.includes('gemini-1.5')) return 1_000_000;
// GPT models via OpenRouter
if (modelLower.includes('gpt-5.2')) {
return modelLower.includes('mini') || modelLower.includes('nano') ? 256_000 : 400_000;
}
if (modelLower.includes('gpt-5.1')) return 400_000;
if (modelLower.includes('gpt-5')) {
return modelLower.includes('mini') || modelLower.includes('nano') ? 128_000 : 272_000;
}
if (modelLower.includes('gpt-4o') || modelLower.includes('gpt-4-turbo')) return 128_000;
// Grok models via OpenRouter
if (modelLower.includes('grok-4-fast')) return 2_000_000;
if (modelLower.includes('grok')) return 128_000;
// DeepSeek models - commonly 64k or 128k
if (modelLower.includes('deepseek-r1') || modelLower.includes('deepseek-v3')) return 128_000;
if (modelLower.includes('deepseek')) return 64_000;
// Qwen models
if (modelLower.includes('qwen-2.5') || modelLower.includes('qwq')) return 128_000;
if (modelLower.includes('qwen')) return 32_000;
// Llama models
if (modelLower.includes('llama-3') || modelLower.includes('llama3')) return 128_000;
if (modelLower.includes('llama')) return 32_000;
// Mistral models
if (modelLower.includes('mistral-large') || modelLower.includes('mistral-nemo')) return 128_000;
if (modelLower.includes('mistral')) return 32_000;
// OpenRouter platform hard cap is 400k for many endpoints - use 200k as safe default
return 200_000;
}

// OpenAI models
if (providerLower === 'openai' || modelLower.includes('gpt')) {
// GPT-5.2 models (400k/256k context)
Expand Down Expand Up @@ -217,8 +263,8 @@ export function getContextWindowSize(model: string, provider?: string): number {
return 128_000; // Default for older MiniMax models
}

// OpenRouter or unknown
return 200_000; // Conservative default
// Unknown provider/model - conservative default
return 200_000;
}

/**
Expand Down Expand Up @@ -294,10 +340,10 @@ export function determineMessagesToInclude(
const messageTokens = estimateMessageTokens(message);

if (currentTokens + messageTokens <= maxTokens) {
included.unshift(message); // Add to front to maintain order
included.push(message);
currentTokens += messageTokens;
} else {
// Would exceed budget, stop here
included.reverse();
return {
includedMessages: included,
totalTokens: currentTokens,
Expand All @@ -307,6 +353,7 @@ export function determineMessagesToInclude(
}

// All messages fit
included.reverse();
return {
includedMessages: included,
totalTokens: currentTokens,
Expand Down
Loading