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
+19 -32
View File
@@ -1,4 +1,3 @@
import { POST as validateModel } from "@/app/api/validate-model/route"
import { checkAdminAuth } from "@/lib/admin/auth"
import {
AdminProviderSchema,
@@ -6,6 +5,7 @@ import {
mergeSecrets,
} from "@/lib/admin/providers"
import { globalBaseUrl } from "@/lib/ai-providers"
import { testModelConnection } from "@/lib/test-model-connection"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
@@ -48,37 +48,24 @@ export async function POST(req: Request) {
sameEndpoint && stored ? [stored] : [],
)
// Without a URL of its own, chat sends the entry's key to the server's
// <P>_BASE_URL: test that endpoint, not another one. It is the server's
// own, which chat uses without the checks for a URL a user typed.
// The admin's own request supplies the EdgeOne endpoint, cookies and
// access code, as a chat request would.
const serverUrl = globalBaseUrl(resolved.provider)
return validateModel(
new Request(new URL("/api/validate-model", req.url), {
method: "POST",
headers: {
"Content-Type": "application/json",
// Checked again there, in place of an access code
"x-admin-password": req.headers.get("x-admin-password") || "",
// The EdgeOne function checks the access code and Pages
// cookies, and its URL is built from the page's origin
"x-access-code": req.headers.get("x-access-code") || "",
cookie: req.headers.get("cookie") || "",
...(req.headers.get("origin") && {
origin: req.headers.get("origin") as string,
}),
},
body: JSON.stringify({
provider: resolved.provider,
apiKey: resolved.apiKey,
// Without a URL of its own, chat sends the entry's key to
// the server's <P>_BASE_URL: test that endpoint, not
// another one. It is the server's own, which chat uses
// without the checks for a URL a user typed.
baseUrl: resolved.baseUrl || serverUrl,
...(!resolved.baseUrl && serverUrl && { serverBaseUrl: true }),
modelId: body.modelId,
awsAccessKeyId: resolved.awsAccessKeyId,
awsSecretAccessKey: resolved.awsSecretAccessKey,
awsRegion: resolved.awsRegion,
vertexApiKey: resolved.vertexApiKey,
}),
}),
return testModelConnection(
req,
{
provider: resolved.provider,
apiKey: resolved.apiKey,
baseUrl: resolved.baseUrl || serverUrl,
modelId: body.modelId,
awsAccessKeyId: resolved.awsAccessKeyId,
awsSecretAccessKey: resolved.awsSecretAccessKey,
awsRegion: resolved.awsRegion,
vertexApiKey: resolved.vertexApiKey,
},
!resolved.baseUrl && !!serverUrl,
)
}
+26 -132
View File
@@ -1,8 +1,6 @@
import {
APICallError,
convertToModelMessages,
createUIMessageStream,
createUIMessageStreamResponse,
InvalidToolInputError,
stepCountIs,
streamText,
@@ -21,12 +19,10 @@ import {
usesServerCredentials,
usesServerEndpoint,
} from "@/lib/ai-providers"
import { findCachedResponse } from "@/lib/cached-responses"
import {
dropInvalidToolCalls,
fixToolInputJson,
formatSelectionContext,
replaceHistoricalToolInputs,
validateFileParts,
} from "@/lib/chat-helpers"
import { withDeprecatedParamsFallback } from "@/lib/deprecated-params"
@@ -56,7 +52,6 @@ import { getSystemPrompt } from "@/lib/system-prompts"
import { normalizeBaseUrl } from "@/lib/types/model-config"
import { getUserIdFromRequest } from "@/lib/user-id"
import { foldCells } from "@/packages/mcp-server/src/compact-cells.ts"
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
import {
getShapeLibrary,
SHAPE_LIBRARY_LIST,
@@ -70,36 +65,6 @@ import {
// 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.
// Helper function to create cached stream response
function createCachedStreamResponse(xml: string): Response {
const toolCallId = `cached-${Date.now()}`
const stream = createUIMessageStream({
execute: async ({ writer }) => {
writer.write({ type: "start" })
writer.write({
type: "tool-input-start",
toolCallId,
toolName: "display_diagram",
})
writer.write({
type: "tool-input-delta",
toolCallId,
inputTextDelta: xml,
})
writer.write({
type: "tool-input-available",
toolCallId,
toolName: "display_diagram",
input: { xml },
})
writer.write({ type: "finish" })
},
})
return createUIMessageStreamResponse({ stream })
}
// Responses streamed from the model, whose trace streamText's callbacks end
const modelStreamResponses = new WeakSet<Response>()
// A Request's signal follows the client's disconnect only while the Request
@@ -111,6 +76,21 @@ const requestOfResponse = new WeakMap<Response, Request>()
// Inner handler function
const DEBUG_LLM_PAYLOAD = process.env.DEBUG_LLM_PAYLOAD === "true"
// 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(",")})`
: ""),
),
}))
async function handleChatRequest(req: Request): Promise<Response> {
const crossSite = rejectCrossSite(req)
if (crossSite) return crossSite
@@ -156,23 +136,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
}
// === FILE VALIDATION END ===
// === CACHE CHECK START ===
const isFirstMessage = messages.length === 1
const isEmptyDiagram = !xml || !hasCells(xml)
if (isFirstMessage && isEmptyDiagram) {
const lastMessage = messages[0]
const textPart = lastMessage.parts?.find((p: any) => p.type === "text")
const filePart = lastMessage.parts?.find((p: any) => p.type === "file")
const cached = findCachedResponse(textPart?.text || "", !!filePart)
if (cached) {
return createCachedStreamResponse(cached.xml)
}
}
// === CACHE CHECK END ===
// Read client AI provider overrides from headers
const provider = req.headers.get("x-ai-provider")
let baseUrl = req.headers.get("x-ai-base-url")
@@ -348,7 +311,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
`[Prompt Caching] ${shouldCache ? "ENABLED" : "DISABLED"} for model: ${modelId}`,
)
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
// System prompt, with the model name filled in and the style section chosen
const systemMessage = getSystemPrompt(modelId, minimalStyle)
const finalSystemMessage = customSystemMessage
? `${systemMessage}\n\n## Custom Instructions\n${customSystemMessage}`
@@ -380,47 +343,15 @@ ${userInputText}
// DEBUG_LLM_PAYLOAD=true logs the incoming message structure
if (DEBUG_LLM_PAYLOAD) {
console.log("[route.ts] Incoming messages count:", messages.length)
messages.forEach((msg: any, idx: number) => {
console.log(
`[route.ts] Message ${idx} role:`,
msg.role,
"parts count:",
msg.parts?.length,
)
if (msg.parts) {
msg.parts.forEach((part: any, partIdx: number) => {
if (
part.type === "tool-invocation" ||
part.type === "tool-result"
) {
console.log(`[route.ts] Part ${partIdx}:`, {
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputKeys:
part.input && typeof part.input === "object"
? Object.keys(part.input)
: null,
})
}
})
}
})
console.log(
"[route.ts] incoming",
JSON.stringify(describeMessages(messages)),
)
}
// Replace historical tool call XML with placeholders to reduce tokens
// Disabled by default - some models (e.g. minimax) copy placeholders instead of generating XML
const enableHistoryReplace =
process.env.ENABLE_HISTORY_XML_REPLACE === "true"
const placeholderMessages = enableHistoryReplace
? replaceHistoricalToolInputs(modelMessages)
: modelMessages
// Filter out messages with empty content arrays (Bedrock API rejects these)
// This is a safety measure - ideally convertToModelMessages should handle all cases
let enhancedMessages = placeholderMessages.filter(
let enhancedMessages = modelMessages.filter(
(msg: any) =>
msg.content && Array.isArray(msg.content) && msg.content.length > 0,
)
@@ -432,36 +363,10 @@ ${userInputText}
// DEBUG_LLM_PAYLOAD=true logs what is sent to the model
if (DEBUG_LLM_PAYLOAD) {
console.log("[route.ts] Model messages count:", enhancedMessages.length)
enhancedMessages.forEach((msg: any, idx: number) => {
console.log(
`[route.ts] ModelMsg ${idx} role:`,
msg.role,
"content count:",
msg.content?.length,
)
if (msg.content) {
msg.content.forEach((part: any, partIdx: number) => {
if (
part.type === "tool-call" ||
part.type === "tool-result"
) {
console.log(`[route.ts] Content ${partIdx}:`, {
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputValue:
part.input === undefined
? "undefined"
: part.input === null
? "null"
: "object",
})
}
})
}
})
console.log(
"[route.ts] to model",
JSON.stringify(describeMessages(enhancedMessages)),
)
}
// Update the last message with user input only (XML moved to separate cached system message)
@@ -790,17 +695,6 @@ Call this tool to get shape names and usage syntax for a specific library.`,
// 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),
messageMetadata: ({ part }) => {
if (part.type === "finish") {
const usage = (part as any).totalUsage
// AI SDK 6 provides totalTokens directly
return {
totalTokens: usage?.totalTokens ?? 0,
finishReason: (part as any).finishReason,
}
}
return undefined
},
})
modelStreamResponses.add(response)
requestOfResponse.set(response, req)
@@ -840,7 +734,7 @@ async function safeHandler(req: Request): Promise<Response> {
} catch (error) {
response = handleError(error)
}
// Early returns, cache hits and errors never reach streamText's callbacks,
// Early returns and errors never reach streamText's callbacks,
// so their Langfuse trace has to be ended here
if (!modelStreamResponses.has(response)) endTrace()
return response
-321
View File
@@ -1,321 +0,0 @@
# Draw.io XML Schema Guide
This guide explains the structure of draw.io (diagrams.net) XML files to help you understand and create diagrams programmatically.
## Basic Structure
A draw.io XML file has the following hierarchy:
```xml
<mxfile>
<diagram>
<mxGraphModel>
<root>
<mxCell /> <!-- Cells that make up the diagram -->
</root>
</mxGraphModel>
</diagram>
</mxfile>
```
## Root Element: `<mxfile>`
The root element of a draw.io file.
**Attributes:**
- `host`: The application that created the file (e.g., "app.diagrams.net")
- `modified`: Last modification timestamp
- `agent`: Browser / user agent information
- `version`: Version of the application
- `type`: File type (usually "device" or "google")
**Example:**
```xml
<mxfile host="app.diagrams.net" modified="2023-07-14T10:20:30.123Z" agent="Mozilla/5.0" version="21.5.2" type="device">
```
## Diagram Element: `<diagram>`
Each page in your draw.io document is represented by a `<diagram>` element.
**Attributes:**
- `id`: Unique identifier for the diagram
- `name`: The name of the diagram / page
**Example:**
```xml
<diagram id="pWHN0msd4Ud1ZK5cD-Hr" name="Page-1">
```
## Graph Model: `<mxGraphModel>`
Contains the actual diagram data.
**Attributes:**
- `dx`: Grid size in x-direction (usually 1)
- `dy`: Grid size in y-direction (usually 1)
- `grid`: Whether grid is enabled (0 or 1)
- `gridSize`: Grid cell size (usually 10)
- `guides`: Whether guides are enabled (0 or 1)
- `tooltips`: Whether tooltips are enabled (0 or 1)
- `connect`: Whether connections are enabled (0 or 1)
- `arrows`: Whether arrows are enabled (0 or 1)
- `fold`: Whether folding is enabled (0 or 1)
- `page`: Whether page view is enabled (0 or 1)
- `pageScale`: Scale of the page (usually 1)
- `pageWidth`: Width of the page (e.g., 850)
- `pageHeight`: Height of the page (e.g., 1100)
- `math`: Whether math typesetting is enabled (0 or 1)
- `shadow`: Whether shadows are enabled (0 or 1)
**Example:**
```xml
<mxGraphModel dx="1" dy="1" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
```
## Root Cell Container: `<root>`
Contains all the cells in the diagram. **Note:** When generating diagrams, you only need to provide the mxCell elements - the root container and root cells (id="0", id="1") are added automatically.
**Internal structure (auto-generated):**
```xml
<root>
<mxCell id="0"/> <!-- Auto-added -->
<mxCell id="1" parent="0"/> <!-- Auto-added -->
<!-- Your mxCell elements go here (start from id="2") -->
</root>
```
## Cell Elements: `<mxCell>`
The basic building block of diagrams. Cells represent shapes, connectors, text, etc.
**Attributes for all cells:**
- `id`: Unique identifier for the cell
- `parent`: ID of the parent cell (typically "1" for most cells)
- `value`: Text content of the cell
- `style`: Styling information (see Style section below)
**Attributes for shapes (vertices):**
- `vertex`: Set to "1" for shapes
- `connectable`: Whether the shape can be connected (0 or 1)
**Attributes for connectors (edges):**
- `edge`: Set to "1" for connectors
- `source`: ID of the source cell
- `target`: ID of the target cell
**Example (Rectangle shape):**
```xml
<mxCell id="2" value="Hello World" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="350" y="190" width="120" height="60" as="geometry"/>
</mxCell>
```
**Example (Connector):**
```xml
<mxCell id="3" value="" style="endArrow=classic;html=1;rounded=0;" edge="1" parent="1" source="2" target="4">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="400" y="430" as="sourcePoint"/>
<mxPoint x="450" y="380" as="targetPoint"/>
</mxGeometry>
</mxCell>
```
## Geometry: `<mxGeometry>`
Defines the position and dimensions of cells.
**Attributes for shapes:**
- `x`: The x-coordinate of the **top-left** point of the shape.
- `y`: The y-coordinate of the **top-left** point of the shape.
- `width`: The width of the shape.
- `height`: The height of the shape.
- `as`: Specifies the role of this geometry within its parent cell. Typically set to `"geometry"` for the main shape definition.
**Attributes for connectors:**
- `relative`: Set to "1" for relative geometry
- `as`: Set to "geometry"
**Example for shapes:**
```xml
<mxGeometry x="350" y="190" width="120" height="60" as="geometry"/>
```
**Example for connectors:**
```xml
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="400" y="430" as="sourcePoint"/>
<mxPoint x="450" y="380" as="targetPoint"/>
</mxGeometry>
```
## Cell Style Reference
Styles are specified as semicolon-separated `key=value` pairs in the `style` attribute of `<mxCell>` elements.
### Shape-specific Styles
- Rectangle: `shape=rectangle`
- Ellipse: `shape=ellipse`
- Triangle: `shape=triangle`
- Rhombus: `shape=rhombus`
- Hexagon: `shape=hexagon`
- Cloud: `shape=cloud`
- Actor: `shape=actor`
- Cylinder: `shape=cylinder`
- Document: `shape=document`
- Note: `shape=note`
- Card: `shape=card`
- Parallelogram: `shape=parallelogram`
### Connector Styles
- `endArrow=classic`: Arrow type at the end (classic, open, oval, diamond, block)
- `startArrow=none`: Arrow type at the start (none, classic, open, oval, diamond)
- `curved=1`: Curved connector (0 or 1)
- `edgeStyle=orthogonalEdgeStyle`: Connector routing style
- `elbow=vertical`: Elbow direction (vertical, horizontal)
- `jumpStyle=arc`: Jump style for line crossing (arc, gap)
- `jumpSize=10`: Size of the jump
## Special Cells
Draw.io files contain two special cells that are always present:
1. **Root Cell** (id = "0"): The parent of all cells
2. **Default Parent Cell** (id = "1", parent = "0"): The default layer and parent for most cells
## Tips for Creating Draw.io XML
1. **Generate ONLY mxCell elements** - wrapper tags and root cells (id="0", id="1") are added automatically
2. Start IDs from "2" (id="0" and id="1" are reserved for root cells)
3. Assign unique and sequential IDs to all cells
4. Define parent relationships correctly (use parent="1" for top-level shapes)
5. Use `mxGeometry` elements to position shapes
6. For connectors, specify `source` and `target` attributes
7. **CRITICAL: All mxCell elements must be siblings. NEVER nest mxCell inside another mxCell.**
## Common Patterns
### Grouping Elements
To group elements, create a parent cell and set other cells' `parent` attribute to its ID:
```xml
<!-- Group container -->
<mxCell id="10" value="Group" style="group" vertex="1" connectable="0" parent="1">
<mxGeometry x="200" y="200" width="200" height="100" as="geometry" />
</mxCell>
<!-- Elements inside the group -->
<mxCell id="11" value="Element 1" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="10">
<mxGeometry width="90" height="40" as="geometry" />
</mxCell>
<mxCell id="12" value="Element 2" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="10">
<mxGeometry x="110" width="90" height="40" as="geometry" />
</mxCell>
```
### Swimlanes
Swimlanes use the `swimlane` shape style. **IMPORTANT: All mxCell elements (swimlanes, steps, and edges) must be siblings under `<root>`. Edges are NOT nested inside swimlanes or steps.**
```xml
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<!-- Swimlane 1 -->
<mxCell id="lane1" value="Frontend" style="swimlane;startSize=30;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="200" height="300" as="geometry"/>
</mxCell>
<!-- Swimlane 2 -->
<mxCell id="lane2" value="Backend" style="swimlane;startSize=30;" vertex="1" parent="1">
<mxGeometry x="280" y="40" width="200" height="300" as="geometry"/>
</mxCell>
<!-- Step inside lane1 (parent="lane1") -->
<mxCell id="step1" value="Send Request" style="rounded=1;" vertex="1" parent="lane1">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<!-- Step inside lane2 (parent="lane2") -->
<mxCell id="step2" value="Process" style="rounded=1;" vertex="1" parent="lane2">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<!-- Edge connecting step1 to step2 (sibling element, NOT nested inside steps) -->
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
```
### Tables
Tables use multiple cells with parent-child relationships:
```xml
<mxCell id="30" value="Table" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" vertex="1" parent="1">
<mxGeometry x="200" y="200" width="180" height="120" as="geometry" />
</mxCell>
<mxCell id="31" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[0,0.5,1,0.5];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" vertex="1" parent="30">
<mxGeometry y="30" width="180" height="30" as="geometry" />
</mxCell>
```
## Advanced Features
### Custom Attributes
Draw.io allows adding custom attributes to cells:
```xml
<mxCell id="40" value="Custom Element" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="200" y="200" width="120" height="60" as="geometry"/>
<Object label="Custom Label" customAttr="value" />
</mxCell>
```
These custom attributes can store additional metadata or be used by plugins and custom behaviors.
### User-defined Styles
You can define custom styles for cells by combining various style attributes:
```xml
<mxCell id="50" value="Custom Styled Cell"
style="shape=hexagon;perimeter=hexagonPerimeter2;whiteSpace=wrap;html=1;fixedSize=1;fillColor=#f8cecc;strokeColor=#b85450;strokeWidth=2;fontSize=14;fontStyle=1"
vertex="1" parent="1">
<mxGeometry x="300" y="200" width="120" height="80" as="geometry"/>
</mxCell>
```
### Layers
You can create multiple layers in a diagram to organize complex diagrams:
```xml
<!-- Default layer (always present) -->
<mxCell id="1" parent="0"/>
<!-- Additional custom layer -->
<mxCell id="60" value="Layer 2" style="locked=0;group=" parent="0"/>
<!-- Elements in Layer 2 -->
<mxCell id="61" value="Element in Layer 2" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="60">
<mxGeometry x="200" y="300" width="120" height="60" as="geometry"/>
</mxCell>
```
+16 -68
View File
@@ -1,6 +1,5 @@
import { randomUUID } from "crypto"
import { z } from "zod"
import { getLangfuseClient } from "@/lib/langfuse"
import { getLangfuseClient, scoreLatestTrace } from "@/lib/langfuse"
import { getUserIdFromRequest } from "@/lib/user-id"
const feedbackSchema = z.object({
@@ -37,75 +36,24 @@ export async function POST(req: Request) {
const userId = getUserIdFromRequest(req)
try {
// Find the most recent chat trace for this session to attach the score to
const tracesResponse = await langfuse.api.trace.list({
// Score the session's latest chat trace; without one, a standalone
// feedback trace carries the score
await scoreLatestTrace(langfuse, {
sessionId,
limit: 1,
name: "user-feedback",
value: feedback === "good" ? 1 : 0,
comment: `User gave ${feedback} feedback`,
fallbackTrace: {
name: "user-feedback",
userId,
input: { messageId, feedback },
metadata: {
source: "feedback-button",
note: "standalone - no chat trace found",
},
},
})
const traces = tracesResponse.data || []
const latestTrace = traces[0]
if (!latestTrace) {
// No trace found for this session - create a standalone feedback trace
const traceId = randomUUID()
const timestamp = new Date().toISOString()
await langfuse.api.ingestion.batch({
batch: [
{
type: "trace-create",
id: randomUUID(),
timestamp,
body: {
id: traceId,
name: "user-feedback",
sessionId,
userId,
input: { messageId, feedback },
metadata: {
source: "feedback-button",
note: "standalone - no chat trace found",
},
timestamp,
},
},
{
type: "score-create",
id: randomUUID(),
timestamp,
body: {
id: randomUUID(),
traceId,
name: "user-feedback",
value: feedback === "good" ? 1 : 0,
comment: `User gave ${feedback} feedback`,
},
},
],
})
} else {
// Attach score to the existing chat trace
const timestamp = new Date().toISOString()
await langfuse.api.ingestion.batch({
batch: [
{
type: "score-create",
id: randomUUID(),
timestamp,
body: {
id: randomUUID(),
traceId: latestTrace.id,
name: "user-feedback",
value: feedback === "good" ? 1 : 0,
comment: `User gave ${feedback} feedback`,
},
},
],
})
}
return Response.json({ success: true, logged: true })
} catch (error) {
console.error("Langfuse feedback error:", error)
+8 -32
View File
@@ -1,6 +1,5 @@
import { randomUUID } from "crypto"
import { z } from "zod"
import { getLangfuseClient } from "@/lib/langfuse"
import { getLangfuseClient, scoreLatestTrace } from "@/lib/langfuse"
const saveSchema = z.object({
filename: z.string().min(1).max(255),
@@ -33,39 +32,16 @@ export async function POST(req: Request) {
}
try {
const timestamp = new Date().toISOString()
// Find the most recent chat trace for this session to attach the save flag
const tracesResponse = await langfuse.api.trace.list({
// Flag the session's latest chat trace as saved; a session without a
// trace (no chat yet) is skipped
const logged = await scoreLatestTrace(langfuse, {
sessionId,
limit: 1,
name: "diagram-saved",
value: 1,
comment: `User saved diagram as ${filename}.${format}`,
})
const traces = tracesResponse.data || []
const latestTrace = traces[0]
if (latestTrace) {
// Add a score to the existing trace to flag that user saved
await langfuse.api.ingestion.batch({
batch: [
{
type: "score-create",
id: randomUUID(),
timestamp,
body: {
id: randomUUID(),
traceId: latestTrace.id,
name: "diagram-saved",
value: 1,
comment: `User saved diagram as ${filename}.${format}`,
},
},
],
})
}
// If no trace found, skip logging (user hasn't chatted yet)
return Response.json({ success: true, logged: !!latestTrace })
return Response.json({ success: true, logged })
} catch (error) {
console.error("Langfuse save error:", error)
return Response.json(
+1 -4
View File
@@ -7,8 +7,5 @@ export const dynamic = "force-dynamic"
export async function GET() {
const models = await loadFlattenedServerModels()
return NextResponse.json({
models,
hasConfig: models.length > 0,
})
return NextResponse.json({ models })
}
+13 -233
View File
@@ -1,249 +1,29 @@
import { streamText, tool } from "ai"
import { NextResponse } from "next/server"
import { z } from "zod"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import { checkAdminAuth } from "@/lib/admin/auth"
import {
edgeOneEndpoint,
getAIModel,
globalBaseUrl,
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"
type TestModelRequest,
testModelConnection,
} from "@/lib/test-model-connection"
export const runtime = "nodejs"
interface ValidateRequest {
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
// Set by the admin panel's Test: baseUrl is the server's <P>_BASE_URL
serverBaseUrl?: boolean
}
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."
// The Settings dialog's Test button: a model on the user's own credentials
export async function POST(req: Request) {
const crossSite = rejectCrossSite(req)
if (crossSite) return crossSite
// Lets the server send requests to arbitrary URLs, so require the access
// code, or the admin password (the admin panel's Test button)
// Lets the server send requests to arbitrary URLs, so require the access code
const accessError = checkAccessCode(req)
if (accessError && checkAdminAuth(req)) return accessError
if (accessError) return accessError
let body: TestModelRequest
try {
const body: ValidateRequest = await req.json()
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
// The admin panel's Test of an entry without a URL sends the
// server's own <P>_BASE_URL, which chat uses as it is: not a URL a
// user chose, so no private-address or redirect rules
const serverUrl =
body.serverBaseUrl === true &&
!!baseUrl &&
baseUrl === globalBaseUrl(provider) &&
!checkAdminAuth(req)
// SECURITY: Block SSRF attacks via custom baseUrl
if (
baseUrl &&
!serverUrl &&
!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() &&
!serverUrl &&
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: serverUrl,
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)
body = await req.json()
} catch {
return NextResponse.json(
{ valid: false, code, error: message },
{ status: 200 }, // Return 200 so client can read error message
{ valid: false, error: "Invalid JSON body" },
{ status: 400 },
)
}
// A URL in the request is one the user typed, never the server's own
return testModelConnection(req, body, false)
}