2025-12-06 12:46:40 +09:00
import {
2025-12-08 19:52:18 +08:00
APICallError ,
2025-12-06 12:46:40 +09:00
convertToModelMessages ,
2025-12-14 12:34:34 +09:00
InvalidToolInputError ,
2026-10-12 12:58:52 +09:00
isFileUIPart ,
isTextUIPart ,
pruneMessages ,
safeValidateUIMessages ,
2025-12-07 00:40:13 +09:00
stepCountIs ,
2025-12-06 12:46:40 +09:00
streamText ,
} from "ai"
2025-12-14 12:34:34 +09:00
import { jsonrepair } from "jsonrepair"
2026-10-05 22:39:32 +09:00
import { checkAccessCode , rejectCrossSite } from "@/lib/access-code"
2025-12-31 12:20:09 +09:00
import {
2026-10-05 22:39:32 +09:00
CACHE_POINT ,
edgeOneEndpoint ,
2025-12-31 12:20:09 +09:00
getAIModel ,
2026-10-05 22:39:32 +09:00
getServerProvider ,
2025-12-31 12:20:09 +09:00
supportsPromptCaching ,
2026-10-05 22:39:32 +09:00
usesServerCredentials ,
usesServerEndpoint ,
2025-12-31 12:20:09 +09:00
} from "@/lib/ai-providers"
2026-01-05 01:37:32 +09:00
import {
2026-10-05 22:39:32 +09:00
dropInvalidToolCalls ,
fixToolInputJson ,
2026-10-08 16:20:15 +09:00
formatSelectionContext ,
2026-01-05 01:37:32 +09:00
validateFileParts ,
} from "@/lib/chat-helpers"
2026-10-12 12:58:52 +09:00
import type { ChatUIMessage } from "@/lib/chat-message"
import { chatTools } from "@/lib/chat-tools"
2026-10-05 22:39:32 +09:00
import { withDeprecatedParamsFallback } from "@/lib/deprecated-params"
2025-12-23 18:36:27 +09:00
import {
checkAndIncrementRequest ,
isQuotaEnabled ,
recordTokenUsage ,
} from "@/lib/dynamo-quota-manager"
2025-12-06 12:46:40 +09:00
import {
2026-10-05 22:39:32 +09:00
endTrace ,
2025-12-06 12:46:40 +09:00
getTelemetryConfig ,
setTraceInput ,
setTraceOutput ,
wrapWithObserve ,
} from "@/lib/langfuse"
2026-10-05 22:39:32 +09:00
import { classifyLLMError , streamErrorText } from "@/lib/llm-errors"
2026-08-22 20:47:10 +09:00
import {
resolveMaxOutputTokens ,
withOutputTokenLimitFallback ,
} from "@/lib/output-token-limit"
2026-10-12 14:48:32 +09:00
import { PROVIDERS , type ProviderName } from "@/lib/providers"
2026-10-05 22:39:32 +09:00
import {
type FlattenedServerModel ,
findServerModelById ,
} from "@/lib/server-model-config"
import { allowPrivateUrls , isPrivateUrl } from "@/lib/ssrf-protection"
2025-12-06 12:46:40 +09:00
import { getSystemPrompt } from "@/lib/system-prompts"
2026-10-05 22:39:32 +09:00
import { normalizeBaseUrl } from "@/lib/types/model-config"
2025-12-25 12:20:46 +09:00
import { getUserIdFromRequest } from "@/lib/user-id"
2026-10-10 21:58:21 +09:00
import { foldCells } from "@/packages/mcp-server/src/compact-cells.ts"
2025-04-04 02:10:24 +00:00
2026-08-22 20:47:10 +09:00
// No explicit cap: a reasoning model can spend minutes planning before it emits
// the tool call, so take whatever the host allows. Vercel's own default is 300s,
// which is also where Node's response-body timeout on the upstream stream lands.
2025-11-10 00:00:02 +09:00
2026-10-05 22:39:32 +09:00
// Responses streamed from the model, whose trace streamText's callbacks end
const modelStreamResponses = new WeakSet < Response >()
2026-10-10 09:24:25 +09:00
// A Request's signal follows the client's disconnect only while the Request
// object itself is alive: once it is garbage collected, the abort is lost
// (nodejs/undici#3644) and a stopped chat would run on at the provider.
// Each request is kept as long as its answer streams.
const requestOfResponse = new WeakMap < Response , Request >()
2026-10-05 22:39:32 +09:00
2025-12-04 11:24:26 +09:00
// Inner handler function
2026-10-05 22:39:32 +09:00
const DEBUG_LLM_PAYLOAD = process . env . DEBUG_LLM_PAYLOAD === "true"
2026-10-12 12:35:55 +09:00
// Message structure for DEBUG_LLM_PAYLOAD logs: roles, part types, tool names
// and input keys, without the diagram XML or image data
const describeMessages = ( msgs : any []) =>
msgs . map (( m ) => ({
role : m.role ,
parts : ( m . parts ?? m . content ?? []). map (
( p : any ) =>
p . type +
( p . toolName ? `: ${ p . toolName } ` : "" ) +
( p . input && typeof p . input === "object"
? `( ${ Object . keys ( p . input ). join ( "," ) } )`
: "" ),
),
}))
2025-12-04 11:24:26 +09:00
async function handleChatRequest ( req : Request ) : Promise < Response > {
2026-10-05 22:39:32 +09:00
const crossSite = rejectCrossSite ( req )
if ( crossSite ) return crossSite
2025-12-06 12:46:40 +09:00
// Check for access code
2026-10-05 22:39:32 +09:00
const accessDenied = checkAccessCode ( req )
if ( accessDenied ) return accessDenied
2025-12-05 21:09:34 +08:00
2026-03-07 19:07:54 +09:00
const body = await req . json ()
2026-10-12 12:58:52 +09:00
// The messages in the SDK's UI shape. The route writes its own system
// messages, so none may come from the client.
const validated = await safeValidateUIMessages < ChatUIMessage >({
messages : body.messages ,
})
if ( ! validated . success ) {
return Response . json (
{ error : validated.error.message },
{ status : 400 },
)
}
const messages = validated . data . filter (( m ) => m . role !== "system" )
const { xml , previousXml , sessionId } = body
2026-03-07 19:07:54 +09:00
const customSystemMessage =
typeof body . customSystemMessage === "string"
? body . customSystemMessage . slice ( 0 , 5000 )
: ""
2025-12-05 21:15:02 +09:00
2025-12-25 12:20:46 +09:00
// Get user ID for Langfuse tracking and quota
const userId = getUserIdFromRequest ( req )
2025-12-05 21:15:02 +09:00
2025-12-06 12:46:40 +09:00
// Validate sessionId for Langfuse (must be string, max 200 chars)
const validSessionId =
sessionId && typeof sessionId === "string" && sessionId . length <= 200
? sessionId
: undefined
2025-12-05 21:15:02 +09:00
2025-12-06 12:46:40 +09:00
// Extract user input text for Langfuse trace
2025-12-23 13:43:28 +09:00
// Find the last USER message, not just the last message (which could be assistant in multi-step tool flows)
const lastUserMessage = [... messages ]
. reverse ()
2026-10-12 12:58:52 +09:00
. find (( m ) => m . role === "user" )
const userInputText = lastUserMessage ? . parts . find ( isTextUIPart ) ? . text || ""
2025-12-05 21:15:02 +09:00
2025-12-06 12:46:40 +09:00
// Update Langfuse trace with input, session, and user
setTraceInput ({
input : userInputText ,
sessionId : validSessionId ,
userId : userId ,
})
2025-12-04 11:24:26 +09:00
2025-12-06 12:46:40 +09:00
// === FILE VALIDATION START ===
const fileValidation = validateFileParts ( messages )
if ( ! fileValidation . valid ) {
return Response . json ({ error : fileValidation.error }, { status : 400 })
2025-12-01 14:07:50 +09:00
}
2025-12-06 12:46:40 +09:00
// === FILE VALIDATION END ===
2025-12-01 14:07:50 +09:00
2025-12-09 17:50:07 +09:00
// Read client AI provider overrides from headers
2025-12-30 21:13:22 +08:00
const provider = req . headers . get ( "x-ai-provider" )
let baseUrl = req . headers . get ( "x-ai-base-url" )
2026-01-15 21:28:22 +05:30
const selectedModelId = req . headers . get ( "x-selected-model-id" )
2025-12-30 21:13:22 +08:00
2026-01-15 21:28:22 +05:30
// Check if this is a server model with custom env var names
let serverModelConfig : {
2026-02-02 15:54:35 +09:00
apiKeyEnv? : string | string []
2026-01-15 21:28:22 +05:30
baseUrlEnv? : string
provider? : string
} = {}
2026-10-05 22:39:32 +09:00
let serverModel : FlattenedServerModel | null = null
2026-01-15 21:28:22 +05:30
if ( selectedModelId ? . startsWith ( "server:" )) {
2026-10-05 22:39:32 +09:00
serverModel = await findServerModelById ( selectedModelId )
2026-01-15 21:28:22 +05:30
console . log (
`[Server Model Lookup] ID: ${ selectedModelId } , Found: ${ !! serverModel } , Provider: ${ serverModel ? . provider } ` ,
)
if ( serverModel ) {
serverModelConfig = {
apiKeyEnv : serverModel.apiKeyEnv ,
baseUrlEnv : serverModel.baseUrlEnv ,
// Use actual provider from config (client header may have incorrect value due to ID format change)
provider : serverModel.provider ,
}
}
}
2026-10-05 22:39:32 +09:00
// A server model's provider comes from its config: for one set up in
// the admin panel the header holds the provider name's slug. Without
// either, the server's own AI_PROVIDER.
2026-10-12 14:48:32 +09:00
const requestedProvider =
serverModelConfig . provider || provider || getServerProvider ()
// A provider without credentials (EdgeOne) is this deployment's own
// function, whatever URL the request names: another host would get the
// user's EdgeOne cookies, and the quota counts it. Absolute, as the SDK
// needs.
const onSiteFunction =
!! requestedProvider &&
Object . hasOwn ( PROVIDERS , requestedProvider ) &&
PROVIDERS [ requestedProvider as ProviderName ]. credential === "none"
if ( onSiteFunction ) baseUrl = edgeOneEndpoint ( req )
2026-10-05 22:39:32 +09:00
// Same rule as validate-model: with ALLOW_PRIVATE_URLS=false a request may
// not point the server at a private or internal address
if ( baseUrl && ! allowPrivateUrls () && ( await isPrivateUrl ( baseUrl ))) {
return Response . json (
{ error : "Private or internal base URLs are not allowed." },
{ status : 400 },
)
}
// Get cookie header for EdgeOne authentication (eo_token, eo_time)
const cookieHeader = req . headers . get ( "cookie" )
2025-12-09 17:50:07 +09:00
const clientOverrides = {
2026-10-12 14:48:32 +09:00
// Server model provider takes precedence over client header; the
// site's function named only in AI_PROVIDER is named here, for its
// own base URL
2026-10-05 22:39:32 +09:00
provider :
serverModelConfig.provider ||
provider ||
2026-10-12 14:48:32 +09:00
( onSiteFunction ? requestedProvider : null ),
2025-12-30 21:13:22 +08:00
baseUrl ,
2025-12-09 17:50:07 +09:00
apiKey : req.headers.get ( "x-ai-api-key" ),
2026-10-05 22:39:32 +09:00
// A server model runs the model it was configured with, whatever the header says
modelId : serverModel?.modelId || req . headers . get ( "x-ai-model" ),
2025-12-22 22:36:36 +09:00
// AWS Bedrock credentials
awsAccessKeyId : req.headers.get ( "x-aws-access-key-id" ),
awsSecretAccessKey : req.headers.get ( "x-aws-secret-access-key" ),
awsRegion : req.headers.get ( "x-aws-region" ),
awsSessionToken : req.headers.get ( "x-aws-session-token" ),
2026-01-15 21:28:22 +05:30
// Server model custom env var names
... serverModelConfig ,
2026-01-14 03:06:13 -05:00
// Vertex AI credentials (Express Mode)
vertexApiKey : req.headers.get ( "x-vertex-api-key" ),
2026-10-05 22:39:32 +09:00
// Pass cookies for EdgeOne Pages authentication, and the access code,
// which the EdgeOne function checks too
2026-10-12 14:48:32 +09:00
...( onSiteFunction && {
2026-10-05 22:39:32 +09:00
headers : {
...( cookieHeader && { cookie : cookieHeader }),
"x-access-code" : req . headers . get ( "x-access-code" ) || "" ,
},
}),
2025-12-09 17:50:07 +09:00
}
2025-12-14 19:38:40 +09:00
// Read minimal style preference from header
const minimalStyle = req . headers . get ( "x-minimal-style" ) === "true"
2026-01-15 21:28:22 +05:30
console . log (
`[Client Overrides] provider: ${ clientOverrides . provider } , modelId: ${ clientOverrides . modelId } ` ,
)
2025-12-09 17:50:07 +09:00
// Get AI model with optional client overrides
2026-03-07 17:53:47 +08:00
const {
2026-08-22 20:47:10 +09:00
model : baseModel ,
2026-03-07 17:53:47 +08:00
providerOptions ,
modelId ,
provider : resolvedProvider ,
} = getAIModel ( clientOverrides )
2025-11-10 11:27:25 +09:00
2026-10-05 22:39:32 +09:00
// On the server's own keys, only run models the server offers: a server
// model picked by id (its model name is fixed above) or one in AI_MODEL
// on AI_PROVIDER. With their own key, users can run any model.
const onServerCredentials = usesServerCredentials (
resolvedProvider ,
clientOverrides ,
)
const envModels =
process . env . AI_MODEL ? . split ( "," ). map (( m ) => m . trim ()) || []
const offeredInEnv =
envModels . includes ( modelId ) && resolvedProvider === getServerProvider ()
if ( onServerCredentials && ! serverModel && ! offeredInEnv ) {
return Response . json (
{
error : `Model " ${ modelId } " is not available on this server. Add your own API key in Settings to use it.` ,
},
{ status : 400 },
)
}
2026-08-22 20:47:10 +09:00
2026-10-05 22:39:32 +09:00
// === SERVER-SIDE QUOTA CHECK START ===
// Quota is opt-in (DYNAMODB_QUOTA_TABLE) and counts what runs on the
// server's keys, or on the server's own endpoints: EdgeOne, its keyless
// Ollama, and anything at a private address (the server's network,
// which ignores a dummy key header). Bedrock and EdgeOne never use the
// base URL header. In the desktop app every endpoint is the user's.
const clientBaseUrl = normalizeBaseUrl (
req . headers . get ( "x-ai-base-url" ) ?? "" ,
)
const onServerEndpoint = await usesServerEndpoint (
resolvedProvider ,
clientBaseUrl ,
clientOverrides . apiKey ,
)
const countsQuota =
isQuotaEnabled () &&
( onServerCredentials || onServerEndpoint ) &&
userId !== "anonymous"
if ( countsQuota ) {
const quotaCheck = await checkAndIncrementRequest ( userId , {
requests : Number ( process . env . DAILY_REQUEST_LIMIT ) || 10 ,
tokens : Number ( process . env . DAILY_TOKEN_LIMIT ) || 200000 ,
tpm : Number ( process . env . TPM_LIMIT ) || 20000 ,
})
if ( ! quotaCheck . allowed ) {
return Response . json (
{
error : quotaCheck.error ,
type : quotaCheck . type ,
used : quotaCheck.used ,
limit : quotaCheck.limit ,
},
{ status : 429 },
)
}
}
// === SERVER-SIDE QUOTA CHECK END ===
// Retry once if the provider rejects the requested budget, or (newer
// Claude models) the sampling or thinking settings
const model = withOutputTokenLimitFallback (
withDeprecatedParamsFallback ( baseModel ),
)
// The user setting can raise the budget only on their own key (in the
// desktop app every key is the user's); on the server's keys or own
// endpoints it can only lower it
2026-08-22 20:47:10 +09:00
const maxOutputTokens = resolveMaxOutputTokens (
req . headers . get ( "x-max-output-tokens" ),
2026-10-05 22:39:32 +09:00
onServerCredentials || onServerEndpoint ,
2026-08-22 20:47:10 +09:00
)
console . log ( `[maxOutputTokens] ${ maxOutputTokens } ` )
2025-12-09 15:53:59 +09:00
// Check if model supports prompt caching
const shouldCache = supportsPromptCaching ( modelId )
console . log (
`[Prompt Caching] ${ shouldCache ? "ENABLED" : "DISABLED" } for model: ${ modelId } ` ,
)
2026-10-12 12:35:55 +09:00
// System prompt, with the model name filled in and the style section chosen
2025-12-14 19:38:40 +09:00
const systemMessage = getSystemPrompt ( modelId , minimalStyle )
2026-03-07 19:07:54 +09:00
const finalSystemMessage = customSystemMessage
? ` ${ systemMessage } \ n \ n## Custom Instructions \ n ${ customSystemMessage } `
: systemMessage
2025-12-06 12:46:40 +09:00
2025-12-23 13:43:28 +09:00
// Extract file parts (images) from the last user message
2026-10-12 12:58:52 +09:00
const fileParts = lastUserMessage ? . parts . filter ( isFileUIPart ) ?? []
2025-12-06 12:46:40 +09:00
2026-06-28 00:26:23 +09:00
// Note: we used to pre-emptively reject images for models we guessed were
// text-only (by name matching). That heuristic misfired on newer models
// (see issue #874), so we now let the request through and surface the real
// provider error if the model genuinely can't accept images.
2025-12-31 12:20:09 +09:00
2025-12-06 12:46:40 +09:00
// User input only - XML is now in a separate cached system message
const formattedUserInput = `User input:
2025-03-24 02:38:27 +00:00
"""md
2025-12-13 23:28:41 +09:00
${ userInputText }
2025-12-06 12:46:40 +09:00
"""`
2025-08-31 12:54:14 +09:00
2026-10-12 12:58:52 +09:00
// Convert UIMessages to ModelMessages. A tool call that never got its
// result (the user stopped while it ran) is left out: the SDK would
// refuse this and every later request of the chat
// (MissingToolResultsError). Messages left without content (a notice
// the chat shows is a data part, which the model never sees) are
// dropped: Bedrock rejects empty content.
const modelMessages = pruneMessages ({
messages : await convertToModelMessages ( messages , {
ignoreIncompleteToolCalls : true ,
}),
2025-12-15 21:28:31 +09:00
})
2026-10-05 22:39:32 +09:00
// DEBUG_LLM_PAYLOAD=true logs the incoming message structure
if ( DEBUG_LLM_PAYLOAD ) {
2026-10-12 12:35:55 +09:00
console . log (
"[route.ts] incoming" ,
JSON . stringify ( describeMessages ( messages )),
)
2026-10-05 22:39:32 +09:00
}
2025-12-15 21:28:31 +09:00
// Filter out tool-calls with invalid inputs (from failed repair or interrupted streaming)
2026-10-05 22:39:32 +09:00
// and their results. Bedrock API rejects messages where toolUse.input is not a valid
// JSON object, and every provider rejects a tool result whose call is gone.
2026-10-12 12:58:52 +09:00
let enhancedMessages = dropInvalidToolCalls ( modelMessages )
2025-12-15 21:28:31 +09:00
2026-10-05 22:39:32 +09:00
// DEBUG_LLM_PAYLOAD=true logs what is sent to the model
if ( DEBUG_LLM_PAYLOAD ) {
2026-10-12 12:35:55 +09:00
console . log (
"[route.ts] to model" ,
JSON . stringify ( describeMessages ( enhancedMessages )),
)
2026-10-05 22:39:32 +09:00
}
2025-12-15 21:28:31 +09:00
2025-12-06 12:46:40 +09:00
// Update the last message with user input only (XML moved to separate cached system message)
if ( enhancedMessages . length >= 1 ) {
const lastModelMessage = enhancedMessages [ enhancedMessages . length - 1 ]
if ( lastModelMessage . role === "user" ) {
// Build content array with user input text and file parts
const contentParts : any [] = [
{ type : "text" , text : formattedUserInput },
]
2025-12-04 13:26:06 +09:00
2025-12-06 12:46:40 +09:00
// Add image parts back
for ( const filePart of fileParts ) {
contentParts . push ({
type : "image" ,
image : filePart.url ,
2026-10-05 22:39:32 +09:00
mediaType : filePart.mediaType ,
2025-12-06 12:46:40 +09:00
})
}
2025-12-04 13:26:06 +09:00
2025-12-06 12:46:40 +09:00
enhancedMessages = [
... enhancedMessages . slice ( 0 , - 1 ),
{ ... lastModelMessage , content : contentParts },
]
}
2025-12-01 01:15:43 +09:00
}
2025-12-06 12:46:40 +09:00
// Add cache point to the last assistant message in conversation history
// This caches the entire conversation prefix for subsequent requests
// Strategy: system (cached) + history with last assistant (cached) + new user message
2025-12-09 15:53:59 +09:00
if ( shouldCache && enhancedMessages . length >= 2 ) {
2025-12-06 12:46:40 +09:00
// Find the last assistant message (should be second-to-last, before current user message)
for ( let i = enhancedMessages . length - 2 ; i >= 0 ; i -- ) {
if ( enhancedMessages [ i ]. role === "assistant" ) {
enhancedMessages [ i ] = {
... enhancedMessages [ i ],
2026-10-05 22:39:32 +09:00
providerOptions : CACHE_POINT ,
2025-12-06 12:46:40 +09:00
}
break // Only cache the last assistant message
}
}
2025-08-31 12:54:14 +09:00
}
2025-12-06 12:46:40 +09:00
// System messages with multiple cache breakpoints for optimal caching:
2026-03-07 19:07:54 +09:00
// - Breakpoint 1: System instructions + custom instructions - changes when user updates custom system message
2025-12-06 12:46:40 +09:00
// - Breakpoint 2: Current XML context - changes per diagram, but constant within a conversation turn
2026-03-07 17:53:47 +08:00
// Some providers (e.g. MiniMax) don't support multiple system messages
// Merge them into a single system message for compatibility
2026-04-02 22:27:29 -05:00
// Also merge for OpenAI-compatible providers with custom base URLs (e.g. vLLM, LMStudio)
// because open-source model chat templates (Qwen, Llama, etc.) typically reject multiple system messages
const isCustomOpenAIEndpoint =
2026-10-12 14:48:32 +09:00
PROVIDERS [ resolvedProvider ]. sdk === "openai" &&
2026-04-02 22:27:29 -05:00
!! (
baseUrl ||
process . env . OPENAI_BASE_URL ||
( serverModelConfig . baseUrlEnv &&
process . env [ serverModelConfig . baseUrlEnv ])
)
const isSingleSystemProvider =
2026-10-12 19:42:09 +09:00
!! PROVIDERS [ resolvedProvider ]. singleSystem || isCustomOpenAIEndpoint
2026-03-07 17:53:47 +08:00
2026-10-08 16:20:15 +09:00
const selectionContext = formatSelectionContext ( body . selectedCells )
2026-10-10 21:58:21 +09:00
// The model reads the diagram in the compact notation it writes
// (compact-cells.ts); the canvas itself keeps the full XML
2026-03-07 17:53:47 +08:00
const xmlContext = ` ${
previousXml
? `Previous diagram XML (before user's last message):
"""xml
2026-10-10 21:58:21 +09:00
${ foldCells ( previousXml ) }
2026-03-07 17:53:47 +08:00
"""
`
: ""
2026-10-10 21:58:21 +09:00
} Current diagram XML (AUTHORITATIVE - the source of truth), shown in the same compact notation you write (shapes with x, y, w, h; edges with source and target):
2026-03-07 17:53:47 +08:00
"""xml
2026-10-10 21:58:21 +09:00
${ foldCells ( xml || "" ) }
2026-03-07 17:53:47 +08:00
"""
2026-10-08 16:20:15 +09:00
IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed. ${ selectionContext ? ` \ n \ n ${ selectionContext } ` : "" } `
2026-03-07 17:53:47 +08:00
const systemMessages = isSingleSystemProvider
? [
{
role : "system" as const ,
2026-03-07 19:07:54 +09:00
content : ` ${ finalSystemMessage } \ n \ n ${ xmlContext } ` ,
2026-03-07 17:53:47 +08:00
},
]
: [
2026-03-07 19:07:54 +09:00
// Cache breakpoint 1: Instructions (+ optional custom instructions)
2026-03-07 17:53:47 +08:00
{
role : "system" as const ,
2026-03-07 19:07:54 +09:00
content : finalSystemMessage ,
2026-10-05 22:39:32 +09:00
...( shouldCache && { providerOptions : CACHE_POINT }),
2026-03-07 17:53:47 +08:00
},
// Cache breakpoint 2: Previous and Current diagram XML context
{
role : "system" as const ,
content : xmlContext ,
2026-10-05 22:39:32 +09:00
...( shouldCache && { providerOptions : CACHE_POINT }),
2026-03-07 17:53:47 +08:00
},
]
2025-12-04 13:26:06 +09:00
2025-12-06 12:46:40 +09:00
const allMessages = [... systemMessages , ... enhancedMessages ]
2025-12-04 13:26:06 +09:00
2025-12-06 12:46:40 +09:00
const result = streamText ({
model ,
2026-10-12 12:58:52 +09:00
// The system messages carry cache points, so they go in messages
// (the client's own system messages were dropped above)
2026-10-05 22:39:32 +09:00
allowSystemInMessages : true ,
2026-01-29 12:40:40 +08:00
abortSignal : req.signal ,
2026-08-22 20:47:10 +09:00
// Must be sent: unset means the provider's own default, and Bedrock's is
// 4096, enough for a small diagram, so larger ones were cut off mid-attribute.
maxOutputTokens ,
2025-12-07 00:40:13 +09:00
stopWhen : stepCountIs ( 5 ),
2025-12-14 12:34:34 +09:00
// Repair truncated tool calls when maxOutputTokens is reached mid-JSON
experimental_repairToolCall : async ({ toolCall , error }) => {
2025-12-15 21:28:31 +09:00
// DEBUG: Log what we're trying to repair
console . log ( `[repairToolCall] Tool: ${ toolCall . toolName } ` )
console . log (
`[repairToolCall] Error: ${ error . name } - ${ error . message } ` ,
)
console . log ( `[repairToolCall] Input type: ${ typeof toolCall . input } ` )
console . log ( `[repairToolCall] Input value:` , toolCall . input )
2025-12-14 12:34:34 +09:00
// Only attempt repair for invalid tool input (broken JSON from truncation)
if (
error instanceof InvalidToolInputError ||
error . name === "AI_InvalidToolInputError"
) {
try {
2026-10-05 22:39:32 +09:00
// Pre-process to fix common LLM JSON errors that jsonrepair can't handle,
// then use jsonrepair to fix truncated JSON
const repairedInput = jsonrepair (
fixToolInputJson ( toolCall . input ),
)
2025-12-14 12:34:34 +09:00
console . log (
`[repairToolCall] Repaired truncated JSON for tool: ${ toolCall . toolName } ` ,
)
return { ... toolCall , input : repairedInput }
} catch ( repairError ) {
console . warn (
`[repairToolCall] Failed to repair JSON for tool: ${ toolCall . toolName } ` ,
repairError ,
)
2026-10-05 22:39:32 +09:00
// Keep the original error, so the model and the client see why
// the input was rejected and the model can retry the call
2025-12-14 12:34:34 +09:00
return null
}
}
// Don't attempt to repair other errors (like NoSuchToolError)
return null
},
2025-12-06 12:46:40 +09:00
messages : allMessages ,
2025-12-10 20:54:43 +05:30
...( providerOptions && { providerOptions }), // This now includes all reasoning configs
2026-10-12 12:58:52 +09:00
// Langfuse telemetry (undefined when not configured)
experimental_telemetry : getTelemetryConfig ({
sessionId : validSessionId ,
userId ,
2025-12-06 12:46:40 +09:00
}),
2026-10-12 12:58:52 +09:00
// The quota counts each step as it ends: a later step that fails or
// is stopped has still used these tokens (onFinish runs only for a
// completed answer). Awaited, so the write is done before the host
// may freeze the function after the answer ends.
onStepFinish : async ({ usage }) => {
if ( countsQuota ) {
await recordTokenUsage ( userId , usage . totalTokens ?? 0 )
}
},
onFinish : ({ text }) => {
2025-12-23 16:26:45 +09:00
// AI SDK 6 telemetry auto-reports token usage on its spans
setTraceOutput ( text )
2025-12-06 12:46:40 +09:00
},
2026-10-05 22:39:32 +09:00
// onFinish is skipped when the stream fails or is aborted, so end the trace here
onError : ({ error }) => {
console . error ( error ) // what AI SDK does without an onError
endTrace ()
},
2026-10-12 12:58:52 +09:00
onAbort : () => endTrace (),
tools : chatTools ,
2025-12-06 22:04:59 +05:30
...( process . env . TEMPERATURE !== undefined && {
temperature : parseFloat ( process . env . TEMPERATURE ),
}),
2025-12-06 12:46:40 +09:00
})
2025-04-04 02:10:24 +00:00
2026-10-05 22:39:32 +09:00
const response = result . toUIMessageStreamResponse ({
2025-12-10 20:54:43 +05:30
sendReasoning : true ,
2026-10-05 22:39:32 +09:00
// On the server's keys the provider's text can name its account.
// Keyless endpoints keep theirs: the desktop app's Ollama is the
// user's own, and EdgeOne's text is our function's explanation.
onError : ( error ) => streamErrorText ( error , onServerCredentials ),
2025-12-08 18:56:34 +09:00
})
2026-10-05 22:39:32 +09:00
modelStreamResponses . add ( response )
2026-10-10 09:24:25 +09:00
requestOfResponse . set ( response , req )
2026-10-05 22:39:32 +09:00
return response
2025-12-04 11:24:26 +09:00
}
2026-10-05 22:39:32 +09:00
// Errors before the stream starts, as JSON the chat panel reads
2025-12-08 19:52:18 +08:00
function handleError ( error : unknown ) : Response {
console . error ( "Error in chat route:" , error )
const isDev = process . env . NODE_ENV === "development"
2026-10-05 22:39:32 +09:00
const classified = classifyLLMError ( error )
const status =
( error as { statusCode? : number }) ? . statusCode ||
( error as { status? : number }) ? . status ||
( classified . code === "invalid_api_key" ? 401 : 500 )
2025-12-08 19:52:18 +08:00
return Response . json (
{
2026-10-05 22:39:32 +09:00
... classified ,
2025-12-08 19:52:18 +08:00
...( isDev && {
2026-10-05 22:39:32 +09:00
details : APICallError.isInstance ( error )
? error.responseBody
: undefined ,
2025-12-08 19:52:18 +08:00
stack : error instanceof Error ? error.stack : undefined ,
}),
},
{ status },
)
}
2025-12-05 21:15:02 +09:00
// Wrap handler with error handling
async function safeHandler ( req : Request ) : Promise < Response > {
2026-10-05 22:39:32 +09:00
let response : Response
2025-12-06 12:46:40 +09:00
try {
2026-10-05 22:39:32 +09:00
response = await handleChatRequest ( req )
2025-12-06 12:46:40 +09:00
} catch ( error ) {
2026-10-05 22:39:32 +09:00
response = handleError ( error )
2025-12-06 12:46:40 +09:00
}
2026-10-12 12:35:55 +09:00
// Early returns and errors never reach streamText's callbacks,
2026-10-05 22:39:32 +09:00
// so their Langfuse trace has to be ended here
if ( ! modelStreamResponses . has ( response )) endTrace ()
return response
2025-03-19 08:16:44 +00:00
}
2025-12-05 21:15:02 +09:00
// Wrap with Langfuse observe (if configured)
2025-12-06 12:46:40 +09:00
const observedHandler = wrapWithObserve ( safeHandler )
2025-12-05 21:15:02 +09:00
export async function POST ( req : Request ) {
2025-12-06 12:46:40 +09:00
return observedHandler ( req )
2025-12-05 21:15:02 +09:00
}