refactor(server): drop stale prompt text, duplicated routes and dead options

- lib/system-prompts.ts: delete the extended prompt kept only to reach
  the 4096-token cache threshold of two old Claude models; describe the
  current canvas-first UI in the App Features section
- app/api/chat/route.ts: remove the server-side example cache (the
  browser answers examples itself), the ENABLE_HISTORY_XML_REPLACE
  option and its helper, the unread messageMetadata fields; the
  DEBUG_LLM_PAYLOAD blocks become one describeMessages summary
- app/api/log-feedback and log-save share scoreLatestTrace in
  lib/langfuse.ts
- app/api/admin/test-model and validate-model call a shared
  testModelConnection() instead of one route faking a request to the
  other and passing trust through the body
- lib/dynamo-quota-manager.ts reads the failing item from the
  conditional update instead of a second GetItem
- lib/ai-providers.ts: geminiThinkingOptions helper for the google and
  vertexai cases, drop the unused fourth resolveBaseURL parameter
- edge-functions: always stream, shrink messageSchema, drop model
  aliases nothing sends
- delete app/api/chat/xml_guide.md, parseOutputTokenLimit, unused type
  exports and export keywords on file-internal symbols
- env.example: drop variables nothing reads, document the quota,
  validation and analytics variables, mention the apiKeyEnv array form
  (also in docs/*/ai-providers.md)
This commit is contained in:
dayuan.jiang
2026-10-12 08:15:15 +09:00
parent 610ba6872d
commit 8ca2d03e7f
36 changed files with 694 additions and 1505 deletions
+4 -10
View File
@@ -56,7 +56,7 @@ const StoredAdminProviderSchema = AdminProviderSchema.extend({
vertexApiKey: z.string().optional(),
})
export type AdminProviderInput = z.infer<typeof AdminProviderSchema>
type AdminProviderInput = z.infer<typeof AdminProviderSchema>
// Stored form: secrets are plain strings
export interface StoredAdminProvider {
@@ -115,7 +115,7 @@ export function loadAdminProviders(): StoredAdminProvider[] {
}
}
export type MaskedAdminProvider = Omit<
type MaskedAdminProvider = Omit<
StoredAdminProvider,
(typeof SECRET_FIELDS)[number]
> & {
@@ -278,15 +278,9 @@ export function deriveEnvUpdates(
return updates
}
// Every settings key the panel may have written for a given list.
// AI_MODELS_CONFIG is included to clean up values written by older
// versions of the panel (it is no longer written).
// Every settings key the panel may have written for a given list
function derivedEnvKeys(list: StoredAdminProvider[]): string[] {
const keys = new Set<string>([
"AI_MODELS_CONFIG",
"AI_PROVIDER",
"AI_MODEL",
])
const keys = new Set<string>(["AI_PROVIDER", "AI_MODEL"])
const indexByProvider = new Map<ProviderName, number>()
for (const p of list) {
const index = indexByProvider.get(p.provider) ?? 0
+66 -115
View File
@@ -30,9 +30,7 @@ import {
type ProviderName,
} from "@/lib/types/model-config"
export type { ProviderName }
export const AIHUBMIX_APP_CODE = "MSBS9675"
const AIHUBMIX_APP_CODE = "MSBS9675"
interface ModelConfig {
model: any
@@ -56,7 +54,7 @@ export const SINGLE_SYSTEM_PROVIDERS = new Set<ProviderName>([
* Normalize MiniMax base URL for AI SDK compatibility.
* MiniMax supports Anthropic-compatible and OpenAI-compatible endpoints.
*/
export function normalizeMiniMaxBaseURL(rawUrl: string): {
function normalizeMiniMaxBaseURL(rawUrl: string): {
baseURL: string
isAnthropicCompatible: boolean
} {
@@ -129,21 +127,19 @@ const BEDROCK_ANTHROPIC_BETA = {
* @param userApiKey - User-provided API key (if any)
* @param userBaseUrl - User-provided base URL (if any)
* @param serverBaseUrl - Server's base URL from environment variable
* @param defaultBaseUrl - Provider's official/default base URL (optional)
* @returns The resolved base URL to use
*/
export function resolveBaseURL(
userApiKey: string | null | undefined,
userBaseUrl: string | null | undefined,
serverBaseUrl: string | undefined,
defaultBaseUrl?: string,
): string | undefined {
if (userApiKey) {
// User provides their own API key - only use user's baseUrl or default
return userBaseUrl || defaultBaseUrl || undefined
// User provides their own API key - only use user's baseUrl
return userBaseUrl || undefined
}
// No user API key - fall back to server config
return userBaseUrl || serverBaseUrl || defaultBaseUrl || undefined
return userBaseUrl || serverBaseUrl || undefined
}
/**
@@ -244,6 +240,39 @@ function googleSamplingSettings(): { topK?: number; topP?: number } {
return settings
}
/**
* Gemini 2.5/3 models think by default, but need includeThoughts: true to
* return the reasoning. The budget applies to Gemini 2.5, the level to
* Gemini 3. Shared by Google AI and Vertex AI, which read different variables.
*/
function geminiThinkingOptions(
modelId: string | undefined,
budgetEnvName: string,
levelEnvName: string,
): { thinkingConfig: Record<string, any> } | undefined {
if (!modelId || !/gemini-?[23]/.test(modelId)) return undefined
const thinkingConfig: Record<string, any> = { includeThoughts: true }
const budget = parseIntSafe(
process.env[budgetEnvName],
budgetEnvName,
1024,
100000,
)
const level = process.env[levelEnvName] as
| "minimal"
| "low"
| "medium"
| "high"
| undefined
if (budget && (modelId.includes("2.5") || modelId.includes("2-5"))) {
thinkingConfig.thinkingBudget = budget
} else if (level && /gemini-?3/.test(modelId)) {
thinkingConfig.thinkingLevel = level
}
return { thinkingConfig }
}
/**
* Build provider-specific options from environment variables
* Supports various AI SDK providers with their unique configuration options
@@ -271,41 +300,27 @@ function buildProviderOptions(
switch (provider) {
case "openai": {
const reasoningEffort = process.env.OPENAI_REASONING_EFFORT
const reasoningSummary = process.env.OPENAI_REASONING_SUMMARY
const reasoningEffort = process.env.OPENAI_REASONING_EFFORT as
| "minimal"
| "low"
| "medium"
| "high"
| undefined
// Reasoning models (the o-series, gpt-5 and later) need
// reasoningSummary to return thoughts
if (modelId && /^(o\d|gpt-([5-9]|[1-9]\d))/.test(modelId)) {
options.openai = {
// Auto-enable reasoning summary for reasoning models
// Use 'auto' as default since not all models support 'detailed'
reasoningSummary:
(reasoningSummary as "auto" | "detailed") || "auto",
}
// reasoningSummary to return thoughts; 'auto' as the default
// since not all models support 'detailed'
const isReasoning =
!!modelId && /^(o\d|gpt-([5-9]|[1-9]\d))/.test(modelId)
const reasoningSummary =
(process.env.OPENAI_REASONING_SUMMARY as
| "auto"
| "detailed"
| undefined) || (isReasoning ? "auto" : undefined)
// Optionally configure reasoning effort
if (reasoningEffort) {
options.openai.reasoningEffort = reasoningEffort as
| "minimal"
| "low"
| "medium"
| "high"
}
} else if (reasoningEffort || reasoningSummary) {
// Non-reasoning models: only apply if explicitly configured
options.openai = {}
if (reasoningEffort) {
options.openai.reasoningEffort = reasoningEffort as
| "minimal"
| "low"
| "medium"
| "high"
}
if (reasoningSummary) {
options.openai.reasoningSummary = reasoningSummary as
| "auto"
| "detailed"
if (reasoningSummary || reasoningEffort) {
options.openai = {
...(reasoningSummary && { reasoningSummary }),
...(reasoningEffort && { reasoningEffort }),
}
}
break
@@ -333,85 +348,21 @@ function buildProviderOptions(
}
case "google": {
const thinkingBudgetVal = parseIntSafe(
process.env.GOOGLE_THINKING_BUDGET,
const google = geminiThinkingOptions(
modelId,
"GOOGLE_THINKING_BUDGET",
1024,
100000,
"GOOGLE_THINKING_LEVEL",
)
const thinkingLevel = process.env.GOOGLE_THINKING_LEVEL
// Google Gemini 2.5/3 models think by default, but need includeThoughts: true
// to return the reasoning in the response
if (
modelId &&
(modelId.includes("gemini-2") ||
modelId.includes("gemini-3") ||
modelId.includes("gemini2") ||
modelId.includes("gemini3"))
) {
const thinkingConfig: Record<string, any> = {
includeThoughts: true,
}
// Optionally configure thinking budget or level
if (
thinkingBudgetVal &&
(modelId.includes("2.5") || modelId.includes("2-5"))
) {
thinkingConfig.thinkingBudget = thinkingBudgetVal
} else if (
thinkingLevel &&
(modelId.includes("gemini-3") ||
modelId.includes("gemini3"))
) {
thinkingConfig.thinkingLevel = thinkingLevel as
| "low"
| "high"
}
options.google = { thinkingConfig }
}
if (google) options.google = google
break
}
case "vertexai": {
const thinkingBudget = parseIntSafe(
process.env.GOOGLE_VERTEX_THINKING_BUDGET,
const google = geminiThinkingOptions(
modelId,
"GOOGLE_VERTEX_THINKING_BUDGET",
1024,
100000,
"GOOGLE_VERTEX_THINKING_LEVEL",
)
const thinkingLevel = process.env.GOOGLE_VERTEX_THINKING_LEVEL
if (
modelId &&
(modelId.includes("gemini-2") ||
modelId.includes("gemini-3") ||
modelId.includes("gemini2") ||
modelId.includes("gemini3"))
) {
const thinkingConfig: Record<string, any> = {
includeThoughts: true,
}
const isGemini3 =
modelId?.includes("gemini-3") ||
modelId?.includes("gemini3")
const isGemini25 =
modelId?.includes("2.5") || modelId?.includes("2-5")
if (isGemini3 && thinkingLevel) {
// Vertex AI provider in AI SDK supports more granular levels (minimal/low/medium/high)
thinkingConfig.thinkingLevel = thinkingLevel as
| "minimal"
| "low"
| "medium"
| "high"
} else if (isGemini25 && thinkingBudget) {
thinkingConfig.thinkingBudget = thinkingBudget
}
options.google = { thinkingConfig }
}
if (google) options.google = google
break
}
case "azure": {
@@ -498,7 +449,7 @@ function buildProviderOptions(
}
// Map of provider to required environment variable
export const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally
openai: "OPENAI_API_KEY",
anthropic: "ANTHROPIC_API_KEY",
+2 -3
View File
@@ -892,9 +892,9 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
},
]
// The chat panel answers a built-in example from here, without a request.
// Examples that come with a file only match when that exact example file is
// attached, so a user's own file with the same prompt still goes to the model.
// Callers that can't tell file names (the server) only get text-only examples.
export function findCachedResponse(
promptText: string,
hasImage: boolean,
@@ -904,7 +904,6 @@ export function findCachedResponse(
(c) =>
c.promptText === promptText &&
c.hasImage === hasImage &&
(!c.fileName || c.fileName === fileName) &&
c.xml !== "",
(!c.fileName || c.fileName === fileName),
)
}
-29
View File
@@ -58,35 +58,6 @@ function isValidToolInput(input: unknown): boolean {
return !!input && typeof input === "object" && Object.keys(input).length > 0
}
// Helper function to replace historical tool call XML with placeholders
// This reduces token usage and forces LLM to rely on the current diagram XML (source of truth)
// Tool calls with invalid inputs are left for dropInvalidToolCalls to remove
export function replaceHistoricalToolInputs(messages: any[]): any[] {
return messages.map((msg) => {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
return msg
}
const replacedContent = msg.content.map((part: any) => {
if (
part.type === "tool-call" &&
isValidToolInput(part.input) &&
(part.toolName === "display_diagram" ||
part.toolName === "edit_diagram")
) {
return {
...part,
input: {
placeholder:
"[XML content replaced - see current diagram XML in system context]",
},
}
}
return part
})
return { ...msg, content: replacedContent }
})
}
// Remove tool-calls with invalid inputs (from failed repair or interrupted streaming),
// together with their tool-results: providers reject a result whose call is missing.
// Messages left empty are removed too (Bedrock rejects empty content arrays).
+1 -1
View File
@@ -4,7 +4,7 @@
*/
// Re-export types from the schema file (single source of truth)
export type { ValidationIssue, ValidationResult } from "./validation-schema"
export type { ValidationResult } from "./validation-schema"
import type { ValidationResult } from "./validation-schema"
+45 -61
View File
@@ -1,7 +1,6 @@
import {
ConditionalCheckFailedException,
DynamoDBClient,
GetItemCommand,
UpdateItemCommand,
} from "@aws-sdk/client-dynamodb"
@@ -108,76 +107,61 @@ export async function checkAndIncrementRequest(
":tokenLimit": { N: String(limits.tokens || 999999) },
":tpmLimit": { N: String(limits.tpm || 999999) },
},
// On a failed condition the exception carries the item as it
// was, so no second read is needed to tell which limit was hit
ReturnValuesOnConditionCheckFailure: "ALL_OLD",
}),
)
return { allowed: true }
} catch (e: any) {
// Condition failed - need to determine which limit was exceeded
// Condition failed - the exception's Item holds the current counts
if (e instanceof ConditionalCheckFailedException) {
// Get current counts to determine which limit was hit
try {
const getResult = await client.send(
new GetItemCommand({
TableName: TABLE,
Key: {
PK: { S: pk },
SK: { S: sk },
},
}),
)
const item = e.Item
const storedMinute = item?.lastMinute?.S
const item = getResult.Item
const storedMinute = item?.lastMinute?.S
const reqCount = Number(item?.reqCount?.N || 0)
const tokenCount = Number(item?.tokenCount?.N || 0)
const tpmCount =
storedMinute !== currentMinute
? 0
: Number(item?.tpmCount?.N || 0)
const reqCount = Number(item?.reqCount?.N || 0)
const tokenCount = Number(item?.tokenCount?.N || 0)
const tpmCount =
storedMinute !== currentMinute
? 0
: Number(item?.tpmCount?.N || 0)
// Determine which limit was exceeded
if (limits.requests > 0 && reqCount >= limits.requests) {
return {
allowed: false,
type: "request",
error: "Daily request limit exceeded",
used: reqCount,
limit: limits.requests,
}
// Determine which limit was exceeded
if (limits.requests > 0 && reqCount >= limits.requests) {
return {
allowed: false,
type: "request",
error: "Daily request limit exceeded",
used: reqCount,
limit: limits.requests,
}
if (limits.tokens > 0 && tokenCount >= limits.tokens) {
return {
allowed: false,
type: "token",
error: "Daily token limit exceeded",
used: tokenCount,
limit: limits.tokens,
}
}
if (limits.tpm > 0 && tpmCount >= limits.tpm) {
return {
allowed: false,
type: "tpm",
error: "Rate limit exceeded (tokens per minute)",
used: tpmCount,
limit: limits.tpm,
}
}
// Condition failed but no limit clearly exceeded - race condition edge case
// Fail safe by allowing (could be a TPM reset race)
console.warn(
`[quota] Condition failed but no limit exceeded for IP prefix: ${ip.slice(0, 8)}...`,
)
return { allowed: true }
} catch (getError: any) {
console.error(
`[quota] Failed to get quota details after condition failure, IP prefix: ${ip.slice(0, 8)}..., error: ${getError.message}`,
)
return { allowed: true } // Fail open
}
if (limits.tokens > 0 && tokenCount >= limits.tokens) {
return {
allowed: false,
type: "token",
error: "Daily token limit exceeded",
used: tokenCount,
limit: limits.tokens,
}
}
if (limits.tpm > 0 && tpmCount >= limits.tpm) {
return {
allowed: false,
type: "tpm",
error: "Rate limit exceeded (tokens per minute)",
used: tpmCount,
limit: limits.tpm,
}
}
// Condition failed but no limit clearly exceeded - race condition edge case
// Fail safe by allowing (could be a TPM reset race)
console.warn(
`[quota] Condition failed but no limit exceeded for IP prefix: ${ip.slice(0, 8)}...`,
)
return { allowed: true }
}
// Other DynamoDB errors - fail open
+50 -1
View File
@@ -1,10 +1,59 @@
import { LangfuseClient } from "@langfuse/client"
import { observe, updateActiveTrace } from "@langfuse/tracing"
import * as api from "@opentelemetry/api"
import { randomUUID } from "crypto"
// Singleton LangfuseClient instance for direct API calls
let langfuseClient: LangfuseClient | null = null
type IngestionBatch = Parameters<
LangfuseClient["api"]["ingestion"]["batch"]
>[0]["batch"]
// Attach a score to the session's most recent chat trace. When the session
// has no trace yet, a fallbackTrace creates a standalone trace to carry the
// score; without one the score is skipped. Returns whether a score was written.
export async function scoreLatestTrace(
langfuse: LangfuseClient,
opts: {
sessionId: string
name: string
value: number
comment: string
fallbackTrace?: {
name: string
userId: string
input: unknown
metadata: Record<string, unknown>
}
},
): Promise<boolean> {
const { sessionId, name, value, comment, fallbackTrace } = opts
const traces = await langfuse.api.trace.list({ sessionId, limit: 1 })
let traceId = traces.data?.[0]?.id
const timestamp = new Date().toISOString()
const batch: IngestionBatch = []
if (!traceId) {
if (!fallbackTrace) return false
traceId = randomUUID()
batch.push({
type: "trace-create",
id: randomUUID(),
timestamp,
body: { id: traceId, sessionId, ...fallbackTrace, timestamp },
})
}
batch.push({
type: "score-create",
id: randomUUID(),
timestamp,
body: { id: randomUUID(), traceId, name, value, comment },
})
await langfuse.api.ingestion.batch({ batch })
return true
}
export function getLangfuseClient(): LangfuseClient | null {
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
return null
@@ -22,7 +71,7 @@ export function getLangfuseClient(): LangfuseClient | null {
}
// Check if Langfuse is configured (both keys required)
export function isLangfuseEnabled(): boolean {
function isLangfuseEnabled(): boolean {
return !!(
process.env.LANGFUSE_PUBLIC_KEY && process.env.LANGFUSE_SECRET_KEY
)
+2 -2
View File
@@ -11,7 +11,7 @@ import {
* What went wrong with a model call, for a hint the user can act on. The
* provider's own message always goes along, because a guess can be wrong.
*/
export type LLMErrorCode =
type LLMErrorCode =
| "invalid_api_key"
| "forbidden"
| "model_not_found"
@@ -28,7 +28,7 @@ export type LLMErrorCode =
| "server_key_forbidden"
| "unknown"
export interface LLMError {
interface LLMError {
type: "provider"
code: LLMErrorCode
message: string
+1 -1
View File
@@ -6,7 +6,7 @@ import type { ProviderName } from "@/lib/types/model-config"
* Only used for hints: requests are sent the same way either way, since
* the data can be wrong or out of date.
*/
export interface ModelInfo {
interface ModelInfo {
tools: boolean
images: boolean
reasoning: boolean
+2 -9
View File
@@ -14,7 +14,7 @@ type WrappedModel = ReturnType<typeof wrapLanguageModel>
export const DEFAULT_MAX_OUTPUT_TOKENS = 64000
/** Ceiling for the user-supplied override, to catch typos like an extra zero. */
export const MAX_OUTPUT_TOKENS_LIMIT = 200000
const MAX_OUTPUT_TOKENS_LIMIT = 200000
/**
* Below this a diagram cannot come out whole, so a retry would just produce
@@ -81,7 +81,7 @@ export function rejectionText(error: unknown): string | null {
* "lower than N") would reinterpret unrelated failures, and retrying on a bogus
* number turns a readable error into an empty diagram.
*/
function readCeiling(text: string): number | null {
export function readCeiling(text: string): number | null {
// Combined input+output ceiling: subtract the input the provider counted,
// plus a small margin because its estimate is approximate.
const context = text.match(/maximum context length (?:is|of) (\d+)/i)
@@ -104,13 +104,6 @@ function readCeiling(text: string): number | null {
return output ? Number(output[1]) : null
}
/** The usable output ceiling named in a rejection, or null. */
export function parseOutputTokenLimit(error: unknown): number | null {
const text = rejectionText(error)
const ceiling = text ? readCeiling(text) : null
return ceiling === null ? null : usableLimit(ceiling)
}
/**
* Thinking budget the provider adds on top of maxOutputTokens. Bedrock and
* Anthropic send maxOutputTokens + budgetTokens as max_tokens, so a ceiling in
+1 -1
View File
@@ -14,7 +14,7 @@ export interface ListedModel {
tools?: boolean
}
export const AIHUBMIX_MODELS_ENDPOINT = "https://aihubmix.com/api/v1/models"
const AIHUBMIX_MODELS_ENDPOINT = "https://aihubmix.com/api/v1/models"
export function canListModels(provider: ProviderName): boolean {
return (
+1 -2
View File
@@ -10,7 +10,7 @@ export const ProviderNameSchema: z.ZodType<ProviderName> = z
message: "Invalid provider name",
})
export const ServerProviderSchema = z.object({
const ServerProviderSchema = z.object({
name: z.string().min(1),
provider: ProviderNameSchema,
models: z.array(z.string().min(1)),
@@ -30,7 +30,6 @@ export const ServerModelsConfigSchema = z.object({
providers: z.array(ServerProviderSchema),
})
export type ServerProviderConfig = z.infer<typeof ServerProviderSchema>
export type ServerModelsConfig = z.infer<typeof ServerModelsConfigSchema>
export interface FlattenedServerModel {
+12 -158
View File
@@ -1,17 +1,10 @@
/**
* System prompts for different AI models
* Extended prompt is used for models with higher cache token minimums (Opus 4.5, Haiku 4.5)
* System prompt for the chat route
*/
import {
STYLE_CLASS_EXAMPLE,
SWIMLANE_EXAMPLE,
TWO_EDGES_EXAMPLE,
WAYPOINT_EXAMPLE,
} from "@/packages/mcp-server/src/xml-examples.ts"
import { STYLE_CLASS_EXAMPLE } from "@/packages/mcp-server/src/xml-examples.ts"
// Default system prompt (~1900 tokens) - works with all models
export const DEFAULT_SYSTEM_PROMPT = `
const DEFAULT_SYSTEM_PROMPT = `
You are an expert diagram creation assistant specializing in draw.io XML generation.
Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications.
You can see images that users upload, and you can read the text content extracted from PDF documents they upload.
@@ -21,18 +14,14 @@ When you are asked to create a diagram, briefly describe your plan about the lay
After generating or editing a diagram, you don't need to say anything. The user can see the diagram - no need to describe it.
## App Context
You are an AI agent (powered by {{MODEL_NAME}}) inside a web app. The interface has:
- **Left panel**: Draw.io diagram editor where diagrams are rendered
- **Right panel**: Chat interface where you communicate with the user
You are an AI agent (powered by {{MODEL_NAME}}) inside a web app. The draw.io canvas fills the window, and the chat panel where you talk with the user floats on the right; users can open and close it.
You can read and modify diagrams by generating draw.io XML code through tool calls.
## App Features
1. **Diagram History** (clock icon, bottom-left of chat input): The app automatically saves a snapshot before each AI edit. Users can view the history panel and restore any previous version. Feel free to make changes - nothing is permanently lost.
2. **Theme Toggle** (palette icon, bottom-left of chat input): Users can switch between minimal UI and sketch-style UI for the draw.io editor.
3. **Image/PDF Upload** (paperclip icon, bottom-left of chat input): Users can upload images or PDF documents for you to analyze and generate diagrams from.
4. **Export** (via draw.io toolbar): Users can save diagrams as .drawio, .svg, or .png files.
5. **Clear Chat** (trash icon, bottom-right of chat input): Clears the conversation and resets the diagram.
1. **Version history**: The app automatically saves a version before each AI edit. Users can go back to any earlier version with the "Restore version" button shown next to a message. Feel free to make changes: nothing is permanently lost.
2. **Image/PDF Upload** (the + menu of the chat input): Users can upload images or PDF documents for you to analyze and generate diagrams from.
3. **Export** (via draw.io toolbar): Users can save diagrams as .drawio, .svg, or .png files.
You utilize the following tools:
---Tool1---
@@ -224,126 +213,9 @@ Since we skip styling, STRICTLY follow the "Edge Routing Rules" section below:
`
// Extended additions (~2600 tokens) - appended for models with 4000 token cache minimum
// Total EXTENDED_SYSTEM_PROMPT = ~4400 tokens
const EXTENDED_ADDITIONS = `
## Extended Tool Reference
### display_diagram Details
**VALIDATION RULES** (XML will be rejected if violated):
1. Generate ONLY mxStyle definitions and mxCell elements - wrapper tags and root cells are added automatically
2. All mxCell elements must be siblings - never nested inside other mxCell elements
3. Every mxCell needs a unique id attribute (start from "2")
4. parent defaults to "1"; write it only for a shape inside a container (the container's id)
5. Edge source/target attributes must reference existing cell IDs
6. Escape special characters in values: &lt; for <, &gt; for >, &amp; for &, &quot; for "
**Example with swimlanes and edges** (generate ONLY this - no wrapper tags):
\`\`\`xml
${SWIMLANE_EXAMPLE}
\`\`\`
### append_diagram Details
**WHEN TO USE:** Only call this tool when display_diagram output was truncated (you'll see an error message about truncation).
**CRITICAL RULES:**
1. Do NOT include any wrapper tags - just continue the mxCell elements
2. Continue from EXACTLY where your previous output stopped
3. Complete the remaining mxCell elements
4. If still truncated, call append_diagram again with the next fragment
**Example:** If previous output ended with \`<mxCell id="x" style="rounded=1\`, continue with \`;" x="40" y="40" w="120" h="60"/>\` and complete the remaining elements.
### edit_diagram Details
edit_diagram uses ID-based operations to modify cells directly by their id attribute.
**Operations:**
- **update**: Replace an existing cell. Provide cell_id and new_xml.
- **add**: Add a new cell. Provide cell_id (new unique id) and new_xml.
- **delete**: Remove a cell. **Cascade is automatic**: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.
**Input Format:**
\`\`\`json
{
"operations": [
{"operation": "update", "cell_id": "3", "new_xml": "<mxCell ...complete element...>"},
{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell ...new element...>"},
{"operation": "delete", "cell_id": "5"}
]
}
\`\`\`
**Examples:**
Change label:
\`\`\`json
{"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" x=\\"100\\" y=\\"100\\" w=\\"120\\" h=\\"60\\"/>"}]}
\`\`\`
Add new shape:
\`\`\`json
{"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;fillColor=#dae8fc;\\" x=\\"400\\" y=\\"200\\" w=\\"120\\" h=\\"60\\"/>"}]}
\`\`\`
Delete container (children & edges auto-deleted):
\`\`\`json
{"operations": [{"operation": "delete", "cell_id": "2"}]}
\`\`\`
**Error Recovery:**
If cell_id not found, check "Current diagram XML" for correct IDs. Use display_diagram if major restructuring is needed
## Edge Examples
### Two edges between same nodes (CORRECT - no overlap):
\`\`\`xml
${TWO_EDGES_EXAMPLE}
\`\`\`
### Edge with single waypoint (simple detour):
\`\`\`xml
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="a" target="b">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="300" y="150"/>
</Array>
</mxGeometry>
</mxCell>
\`\`\`
### Edge with waypoints (routing AROUND obstacles) - CRITICAL PATTERN:
**Scenario:** Hotfix(right,bottom) → Main(center,top), but Develop(center,middle) is in between.
**WRONG:** Direct diagonal line crosses over Develop
**CORRECT:** Route around the OUTSIDE (go right first, then up)
\`\`\`xml
${WAYPOINT_EXAMPLE}
\`\`\`
This routes the edge to the RIGHT of all shapes (x=750), then enters Main from the right side.
**Key principle:** When connecting distant nodes diagonally, route along the PERIMETER of the diagram, not through the middle where other shapes exist.`
// Extended system prompt = DEFAULT + EXTENDED_ADDITIONS
export const EXTENDED_SYSTEM_PROMPT = DEFAULT_SYSTEM_PROMPT + EXTENDED_ADDITIONS
// Model patterns that require extended prompt (4000 token cache minimum)
// These patterns match Opus 4.5 and Haiku 4.5 model IDs
const EXTENDED_PROMPT_MODEL_PATTERNS = [
"claude-opus-4-5", // Matches any Opus 4.5 variant
"claude-haiku-4-5", // Matches any Haiku 4.5 variant
]
/**
* Get the appropriate system prompt based on the model ID and style preference
* Uses extended prompt for Opus 4.5 and Haiku 4.5 which have 4000 token cache minimum
* @param modelId - The AI model ID from environment
* Build the system prompt for a model and style preference
* @param modelId - The AI model ID, shown to the model as its name
* @param minimalStyle - If true, removes style instructions to save tokens
* @returns The system prompt string
*/
@@ -353,32 +225,14 @@ export function getSystemPrompt(
): string {
const modelName = modelId || "AI"
let prompt: string
if (
modelId &&
EXTENDED_PROMPT_MODEL_PATTERNS.some((pattern) =>
modelId.includes(pattern),
)
) {
console.log(
`[System Prompt] Using EXTENDED prompt for model: ${modelId}`,
)
prompt = EXTENDED_SYSTEM_PROMPT
} else {
console.log(
`[System Prompt] Using DEFAULT prompt for model: ${modelId || "unknown"}`,
)
prompt = DEFAULT_SYSTEM_PROMPT
}
// Add style instructions based on preference
// Minimal style: prepend instruction at START (more prominent)
// Normal style: append at end
let prompt: string
if (minimalStyle) {
console.log(`[System Prompt] Minimal style mode ENABLED`)
prompt = MINIMAL_STYLE_INSTRUCTION + prompt
prompt = MINIMAL_STYLE_INSTRUCTION + DEFAULT_SYSTEM_PROMPT
} else {
prompt += STYLE_INSTRUCTIONS
prompt = DEFAULT_SYSTEM_PROMPT + STYLE_INSTRUCTIONS
}
return prompt.replace("{{MODEL_NAME}}", modelName)
+239
View File
@@ -0,0 +1,239 @@
import { streamText, tool } from "ai"
import { NextResponse } from "next/server"
import { z } from "zod"
import {
edgeOneEndpoint,
getAIModel,
usesServerCredentials,
usesServerEndpoint,
} from "@/lib/ai-providers"
import {
checkAndIncrementRequest,
isQuotaEnabled,
} from "@/lib/dynamo-quota-manager"
import { classifyLLMError } from "@/lib/llm-errors"
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
import { normalizeBaseUrl, type ProviderName } from "@/lib/types/model-config"
import { getUserIdFromRequest } from "@/lib/user-id"
export interface TestModelRequest {
provider: ProviderName
apiKey?: string
baseUrl?: string
modelId: string
// AWS Bedrock specific
awsAccessKeyId?: string
awsSecretAccessKey?: string
awsRegion?: string
awsSessionToken?: string
// Vertex AI specific
vertexApiKey?: string // Express Mode API key
}
const TEST_TIMEOUT_MS = 15_000
// Drawing works through tool calls, so the test asks for one
const PING_TOOL = tool({
description: "Report that the connection works.",
inputSchema: z.object({}),
})
const NO_TOOL_CALL_WARNING =
"Connected, but the model answered without calling a tool. It may not support tool calls, which drawing needs."
/**
* Run one tool call on a model, as the chat would, and report the result.
* Shared by the Settings dialog's Test (/api/validate-model) and the admin
* panel's Test (/api/admin/test-model). req supplies the EdgeOne endpoint,
* cookies, access code and the user id for the quota. trustedBaseUrl marks
* body.baseUrl as the server's own <P>_BASE_URL (the admin panel's Test of
* an entry without a URL), not one a user typed: chat uses it as it is, so
* no private-address or redirect rules, and no quota.
*/
export async function testModelConnection(
req: Request,
body: TestModelRequest,
trustedBaseUrl: boolean,
): Promise<NextResponse> {
try {
const {
provider,
apiKey,
modelId,
awsAccessKeyId,
awsSecretAccessKey,
awsRegion,
awsSessionToken,
// Note: Express Mode only needs vertexApiKey
vertexApiKey,
} = body
if (!provider || !modelId) {
return NextResponse.json(
{ valid: false, error: "Provider and model ID are required" },
{ status: 400 },
)
}
// EdgeOne is this site's own function, as in the chat; the admin
// panel's Test sends no URL, and a relative one cannot be fetched
const baseUrl =
provider === "edgeone" ? edgeOneEndpoint(req) : body.baseUrl
// SECURITY: Block SSRF attacks via custom baseUrl
if (
baseUrl &&
!trustedBaseUrl &&
!allowPrivateUrls() &&
(await isPrivateUrl(baseUrl))
) {
return NextResponse.json(
{ valid: false, error: "Invalid base URL" },
{ status: 400 },
)
}
// Validate credentials based on provider
if (provider === "bedrock") {
if (
!(apiKey || (awsAccessKeyId && awsSecretAccessKey)) ||
!awsRegion
) {
return NextResponse.json(
{
valid: false,
error: "AWS credentials (a Bedrock API key, or an Access Key ID and Secret Access Key, and a Region) are required",
},
{ status: 400 },
)
}
} else if (provider === "vertexai") {
if (!vertexApiKey) {
return NextResponse.json(
{
valid: false,
error: "Vertex AI API key is required for Express Mode",
},
{ status: 400 },
)
}
} else if (provider !== "ollama" && provider !== "edgeone" && !apiKey) {
return NextResponse.json(
{ valid: false, error: "API key is required" },
{ status: 400 },
)
}
// The Test button checks the user's own provider. On the server's
// keys (Ollama Cloud without a key or URL) anyone could run any model.
if (
usesServerCredentials(provider, {
apiKey,
baseUrl,
awsAccessKeyId,
awsSecretAccessKey,
vertexApiKey,
})
) {
return NextResponse.json(
{ valid: false, error: "API key is required" },
{ status: 400 },
)
}
// On the deployment's own endpoints a Test runs a model as a chat
// does, so with the quota on it counts as a chat request (an
// admin's Test of the server's URL does not)
const userId = getUserIdFromRequest(req)
if (
isQuotaEnabled() &&
!trustedBaseUrl &&
userId !== "anonymous" &&
(await usesServerEndpoint(
provider,
normalizeBaseUrl(body.baseUrl ?? ""),
apiKey,
))
) {
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 NextResponse.json(
{ valid: false, error: quotaCheck.error },
{ status: 429 },
)
}
}
// The same model the chat would use. A client base URL makes it
// refuse redirects to internal hosts.
const { model } = getAIModel({
provider,
modelId,
apiKey,
baseUrl,
trustedBaseUrl,
awsAccessKeyId,
awsSecretAccessKey,
awsRegion,
// Temporary AWS credentials need it, as in the chat
awsSessionToken,
vertexApiKey,
// EdgeOne checks the Pages cookies and the access code
...(provider === "edgeone" && {
headers: {
cookie: req.headers.get("cookie") || "",
"x-access-code": req.headers.get("x-access-code") || "",
},
}),
})
// Streaming, like the chat (some models only stream). Stop at the
// first tool call; a reasoning model that runs out of tokens first
// proves the connection but not tool support.
const startTime = Date.now()
const result = streamText({
model,
prompt: "Call the ping tool.",
tools: { ping: PING_TOOL },
maxOutputTokens: 1024,
maxRetries: 0,
abortSignal: AbortSignal.timeout(TEST_TIMEOUT_MS),
})
let calledTool = false
let finishReason: string | undefined
for await (const part of result.fullStream) {
if (part.type === "error") throw part.error
// The timeout ends the stream with an abort part, not an error
if (part.type === "abort") {
const timeout = new Error(
`The model did not answer within ${TEST_TIMEOUT_MS / 1000} s.`,
)
timeout.name = "TimeoutError"
throw timeout
}
if (part.type === "tool-call") {
calledTool = true
break
}
if (part.type === "finish") finishReason = part.finishReason
}
const responseTime = Date.now() - startTime
return NextResponse.json({
valid: true,
responseTime,
...(!calledTool &&
finishReason !== "length" && { warning: NO_TOOL_CALL_WARNING }),
})
} catch (error) {
console.error("[validate-model] Error:", error)
const { code, message } = classifyLLMError(error)
return NextResponse.json(
{ valid: false, code, error: message },
{ status: 200 }, // Return 200 so client can read error message
)
}
}
-1
View File
@@ -35,4 +35,3 @@ export const ValidationResultSchema = z.object({
})
export type ValidationResult = z.infer<typeof ValidationResultSchema>
export type ValidationIssue = ValidationResult["issues"][number]