mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-13 04:59:51 +08:00
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:
@@ -1,4 +1,3 @@
|
|||||||
import { POST as validateModel } from "@/app/api/validate-model/route"
|
|
||||||
import { checkAdminAuth } from "@/lib/admin/auth"
|
import { checkAdminAuth } from "@/lib/admin/auth"
|
||||||
import {
|
import {
|
||||||
AdminProviderSchema,
|
AdminProviderSchema,
|
||||||
@@ -6,6 +5,7 @@ import {
|
|||||||
mergeSecrets,
|
mergeSecrets,
|
||||||
} from "@/lib/admin/providers"
|
} from "@/lib/admin/providers"
|
||||||
import { globalBaseUrl } from "@/lib/ai-providers"
|
import { globalBaseUrl } from "@/lib/ai-providers"
|
||||||
|
import { testModelConnection } from "@/lib/test-model-connection"
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
export const runtime = "nodejs"
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
@@ -48,37 +48,24 @@ export async function POST(req: Request) {
|
|||||||
sameEndpoint && stored ? [stored] : [],
|
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)
|
const serverUrl = globalBaseUrl(resolved.provider)
|
||||||
return validateModel(
|
return testModelConnection(
|
||||||
new Request(new URL("/api/validate-model", req.url), {
|
req,
|
||||||
method: "POST",
|
{
|
||||||
headers: {
|
provider: resolved.provider,
|
||||||
"Content-Type": "application/json",
|
apiKey: resolved.apiKey,
|
||||||
// Checked again there, in place of an access code
|
baseUrl: resolved.baseUrl || serverUrl,
|
||||||
"x-admin-password": req.headers.get("x-admin-password") || "",
|
modelId: body.modelId,
|
||||||
// The EdgeOne function checks the access code and Pages
|
awsAccessKeyId: resolved.awsAccessKeyId,
|
||||||
// cookies, and its URL is built from the page's origin
|
awsSecretAccessKey: resolved.awsSecretAccessKey,
|
||||||
"x-access-code": req.headers.get("x-access-code") || "",
|
awsRegion: resolved.awsRegion,
|
||||||
cookie: req.headers.get("cookie") || "",
|
vertexApiKey: resolved.vertexApiKey,
|
||||||
...(req.headers.get("origin") && {
|
},
|
||||||
origin: req.headers.get("origin") as string,
|
!resolved.baseUrl && !!serverUrl,
|
||||||
}),
|
|
||||||
},
|
|
||||||
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,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-132
@@ -1,8 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
APICallError,
|
APICallError,
|
||||||
convertToModelMessages,
|
convertToModelMessages,
|
||||||
createUIMessageStream,
|
|
||||||
createUIMessageStreamResponse,
|
|
||||||
InvalidToolInputError,
|
InvalidToolInputError,
|
||||||
stepCountIs,
|
stepCountIs,
|
||||||
streamText,
|
streamText,
|
||||||
@@ -21,12 +19,10 @@ import {
|
|||||||
usesServerCredentials,
|
usesServerCredentials,
|
||||||
usesServerEndpoint,
|
usesServerEndpoint,
|
||||||
} from "@/lib/ai-providers"
|
} from "@/lib/ai-providers"
|
||||||
import { findCachedResponse } from "@/lib/cached-responses"
|
|
||||||
import {
|
import {
|
||||||
dropInvalidToolCalls,
|
dropInvalidToolCalls,
|
||||||
fixToolInputJson,
|
fixToolInputJson,
|
||||||
formatSelectionContext,
|
formatSelectionContext,
|
||||||
replaceHistoricalToolInputs,
|
|
||||||
validateFileParts,
|
validateFileParts,
|
||||||
} from "@/lib/chat-helpers"
|
} from "@/lib/chat-helpers"
|
||||||
import { withDeprecatedParamsFallback } from "@/lib/deprecated-params"
|
import { withDeprecatedParamsFallback } from "@/lib/deprecated-params"
|
||||||
@@ -56,7 +52,6 @@ import { getSystemPrompt } from "@/lib/system-prompts"
|
|||||||
import { normalizeBaseUrl } from "@/lib/types/model-config"
|
import { normalizeBaseUrl } from "@/lib/types/model-config"
|
||||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||||
import { foldCells } from "@/packages/mcp-server/src/compact-cells.ts"
|
import { foldCells } from "@/packages/mcp-server/src/compact-cells.ts"
|
||||||
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
|
|
||||||
import {
|
import {
|
||||||
getShapeLibrary,
|
getShapeLibrary,
|
||||||
SHAPE_LIBRARY_LIST,
|
SHAPE_LIBRARY_LIST,
|
||||||
@@ -70,36 +65,6 @@ import {
|
|||||||
// the tool call, so take whatever the host allows. Vercel's own default is 300s,
|
// 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.
|
// 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
|
// Responses streamed from the model, whose trace streamText's callbacks end
|
||||||
const modelStreamResponses = new WeakSet<Response>()
|
const modelStreamResponses = new WeakSet<Response>()
|
||||||
// A Request's signal follows the client's disconnect only while the Request
|
// 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
|
// Inner handler function
|
||||||
const DEBUG_LLM_PAYLOAD = process.env.DEBUG_LLM_PAYLOAD === "true"
|
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> {
|
async function handleChatRequest(req: Request): Promise<Response> {
|
||||||
const crossSite = rejectCrossSite(req)
|
const crossSite = rejectCrossSite(req)
|
||||||
if (crossSite) return crossSite
|
if (crossSite) return crossSite
|
||||||
@@ -156,23 +136,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
}
|
}
|
||||||
// === FILE VALIDATION END ===
|
// === 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
|
// Read client AI provider overrides from headers
|
||||||
const provider = req.headers.get("x-ai-provider")
|
const provider = req.headers.get("x-ai-provider")
|
||||||
let baseUrl = req.headers.get("x-ai-base-url")
|
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}`,
|
`[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 systemMessage = getSystemPrompt(modelId, minimalStyle)
|
||||||
const finalSystemMessage = customSystemMessage
|
const finalSystemMessage = customSystemMessage
|
||||||
? `${systemMessage}\n\n## Custom Instructions\n${customSystemMessage}`
|
? `${systemMessage}\n\n## Custom Instructions\n${customSystemMessage}`
|
||||||
@@ -380,47 +343,15 @@ ${userInputText}
|
|||||||
|
|
||||||
// DEBUG_LLM_PAYLOAD=true logs the incoming message structure
|
// DEBUG_LLM_PAYLOAD=true logs the incoming message structure
|
||||||
if (DEBUG_LLM_PAYLOAD) {
|
if (DEBUG_LLM_PAYLOAD) {
|
||||||
console.log("[route.ts] Incoming messages count:", messages.length)
|
console.log(
|
||||||
messages.forEach((msg: any, idx: number) => {
|
"[route.ts] incoming",
|
||||||
console.log(
|
JSON.stringify(describeMessages(messages)),
|
||||||
`[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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
// Filter out messages with empty content arrays (Bedrock API rejects these)
|
||||||
// This is a safety measure - ideally convertToModelMessages should handle all cases
|
// This is a safety measure - ideally convertToModelMessages should handle all cases
|
||||||
let enhancedMessages = placeholderMessages.filter(
|
let enhancedMessages = modelMessages.filter(
|
||||||
(msg: any) =>
|
(msg: any) =>
|
||||||
msg.content && Array.isArray(msg.content) && msg.content.length > 0,
|
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
|
// DEBUG_LLM_PAYLOAD=true logs what is sent to the model
|
||||||
if (DEBUG_LLM_PAYLOAD) {
|
if (DEBUG_LLM_PAYLOAD) {
|
||||||
console.log("[route.ts] Model messages count:", enhancedMessages.length)
|
console.log(
|
||||||
enhancedMessages.forEach((msg: any, idx: number) => {
|
"[route.ts] to model",
|
||||||
console.log(
|
JSON.stringify(describeMessages(enhancedMessages)),
|
||||||
`[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",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the last message with user input only (XML moved to separate cached system message)
|
// 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
|
// Keyless endpoints keep theirs: the desktop app's Ollama is the
|
||||||
// user's own, and EdgeOne's text is our function's explanation.
|
// user's own, and EdgeOne's text is our function's explanation.
|
||||||
onError: (error) => streamErrorText(error, onServerCredentials),
|
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)
|
modelStreamResponses.add(response)
|
||||||
requestOfResponse.set(response, req)
|
requestOfResponse.set(response, req)
|
||||||
@@ -840,7 +734,7 @@ async function safeHandler(req: Request): Promise<Response> {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
response = handleError(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
|
// so their Langfuse trace has to be ended here
|
||||||
if (!modelStreamResponses.has(response)) endTrace()
|
if (!modelStreamResponses.has(response)) endTrace()
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -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>
|
|
||||||
```
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { randomUUID } from "crypto"
|
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { getLangfuseClient } from "@/lib/langfuse"
|
import { getLangfuseClient, scoreLatestTrace } from "@/lib/langfuse"
|
||||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||||
|
|
||||||
const feedbackSchema = z.object({
|
const feedbackSchema = z.object({
|
||||||
@@ -37,75 +36,24 @@ export async function POST(req: Request) {
|
|||||||
const userId = getUserIdFromRequest(req)
|
const userId = getUserIdFromRequest(req)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Find the most recent chat trace for this session to attach the score to
|
// Score the session's latest chat trace; without one, a standalone
|
||||||
const tracesResponse = await langfuse.api.trace.list({
|
// feedback trace carries the score
|
||||||
|
await scoreLatestTrace(langfuse, {
|
||||||
sessionId,
|
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 })
|
return Response.json({ success: true, logged: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Langfuse feedback error:", error)
|
console.error("Langfuse feedback error:", error)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { randomUUID } from "crypto"
|
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { getLangfuseClient } from "@/lib/langfuse"
|
import { getLangfuseClient, scoreLatestTrace } from "@/lib/langfuse"
|
||||||
|
|
||||||
const saveSchema = z.object({
|
const saveSchema = z.object({
|
||||||
filename: z.string().min(1).max(255),
|
filename: z.string().min(1).max(255),
|
||||||
@@ -33,39 +32,16 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const timestamp = new Date().toISOString()
|
// Flag the session's latest chat trace as saved; a session without a
|
||||||
|
// trace (no chat yet) is skipped
|
||||||
// Find the most recent chat trace for this session to attach the save flag
|
const logged = await scoreLatestTrace(langfuse, {
|
||||||
const tracesResponse = await langfuse.api.trace.list({
|
|
||||||
sessionId,
|
sessionId,
|
||||||
limit: 1,
|
name: "diagram-saved",
|
||||||
|
value: 1,
|
||||||
|
comment: `User saved diagram as ${filename}.${format}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
const traces = tracesResponse.data || []
|
return Response.json({ success: true, logged })
|
||||||
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 })
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Langfuse save error:", error)
|
console.error("Langfuse save error:", error)
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
|||||||
@@ -7,8 +7,5 @@ export const dynamic = "force-dynamic"
|
|||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const models = await loadFlattenedServerModels()
|
const models = await loadFlattenedServerModels()
|
||||||
return NextResponse.json({
|
return NextResponse.json({ models })
|
||||||
models,
|
|
||||||
hasConfig: models.length > 0,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-233
@@ -1,249 +1,29 @@
|
|||||||
import { streamText, tool } from "ai"
|
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { z } from "zod"
|
|
||||||
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||||
import { checkAdminAuth } from "@/lib/admin/auth"
|
|
||||||
import {
|
import {
|
||||||
edgeOneEndpoint,
|
type TestModelRequest,
|
||||||
getAIModel,
|
testModelConnection,
|
||||||
globalBaseUrl,
|
} from "@/lib/test-model-connection"
|
||||||
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 const runtime = "nodejs"
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
interface ValidateRequest {
|
// The Settings dialog's Test button: a model on the user's own credentials
|
||||||
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."
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const crossSite = rejectCrossSite(req)
|
const crossSite = rejectCrossSite(req)
|
||||||
if (crossSite) return crossSite
|
if (crossSite) return crossSite
|
||||||
// Lets the server send requests to arbitrary URLs, so require the access
|
// Lets the server send requests to arbitrary URLs, so require the access code
|
||||||
// code, or the admin password (the admin panel's Test button)
|
|
||||||
const accessError = checkAccessCode(req)
|
const accessError = checkAccessCode(req)
|
||||||
if (accessError && checkAdminAuth(req)) return accessError
|
if (accessError) return accessError
|
||||||
|
|
||||||
|
let body: TestModelRequest
|
||||||
try {
|
try {
|
||||||
const body: ValidateRequest = await req.json()
|
body = await req.json()
|
||||||
const {
|
} catch {
|
||||||
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)
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, code, error: message },
|
{ valid: false, error: "Invalid JSON body" },
|
||||||
{ status: 200 }, // Return 200 so client can read error message
|
{ status: 400 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// A URL in the request is one the user typed, never the server's own
|
||||||
|
return testModelConnection(req, body, false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
|||||||
| `provider` | 是 | 提供商类型(`openai`, `anthropic`, `google`, `bedrock` 等) |
|
| `provider` | 是 | 提供商类型(`openai`, `anthropic`, `google`, `bedrock` 等) |
|
||||||
| `models` | 是 | 模型 ID 列表 |
|
| `models` | 是 | 模型 ID 列表 |
|
||||||
| `default` | 否 | 设为 `true` 表示默认选中该提供商的第一个模型 |
|
| `default` | 否 | 设为 `true` 表示默认选中该提供商的第一个模型 |
|
||||||
| `apiKeyEnv` | 否 | 自定义 API Key 环境变量名(默认使用提供商标准变量如 `OPENAI_API_KEY`) |
|
| `apiKeyEnv` | 否 | 自定义 API Key 环境变量名(默认使用提供商标准变量如 `OPENAI_API_KEY`)。也可以写成环境变量名数组,每次请求从有值的变量中随机选一个,把负载分摊到多个 Key 上 |
|
||||||
| `baseUrlEnv` | 否 | 自定义 Base URL 环境变量名 |
|
| `baseUrlEnv` | 否 | 自定义 Base URL 环境变量名 |
|
||||||
|
|
||||||
### 说明
|
### 说明
|
||||||
|
|||||||
@@ -407,7 +407,7 @@ This is shorthand for the equivalent `ai-models.json`. For multiple providers or
|
|||||||
| `provider` | Yes | Provider type (`openai`, `anthropic`, `google`, `bedrock`, etc.) |
|
| `provider` | Yes | Provider type (`openai`, `anthropic`, `google`, `bedrock`, etc.) |
|
||||||
| `models` | Yes | List of model IDs |
|
| `models` | Yes | List of model IDs |
|
||||||
| `default` | No | Set to `true` to auto-select this provider's first model as default |
|
| `default` | No | Set to `true` to auto-select this provider's first model as default |
|
||||||
| `apiKeyEnv` | No | Custom API key env var name (defaults to provider's standard var like `OPENAI_API_KEY`) |
|
| `apiKeyEnv` | No | Custom API key env var name (defaults to provider's standard var like `OPENAI_API_KEY`). May also be an array of env var names: each request picks one that has a value at random, spreading load over several keys |
|
||||||
| `baseUrlEnv` | No | Custom base URL env var name |
|
| `baseUrlEnv` | No | Custom base URL env var name |
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
|||||||
| `provider` | はい | プロバイダータイプ(`openai`, `anthropic`, `google`, `bedrock` など) |
|
| `provider` | はい | プロバイダータイプ(`openai`, `anthropic`, `google`, `bedrock` など) |
|
||||||
| `models` | はい | モデルIDのリスト |
|
| `models` | はい | モデルIDのリスト |
|
||||||
| `default` | いいえ | `true` に設定すると、そのプロバイダーの最初のモデルがデフォルトで選択されます |
|
| `default` | いいえ | `true` に設定すると、そのプロバイダーの最初のモデルがデフォルトで選択されます |
|
||||||
| `apiKeyEnv` | いいえ | カスタムAPIキー環境変数名(デフォルトは `OPENAI_API_KEY` などの標準変数) |
|
| `apiKeyEnv` | いいえ | カスタムAPIキー環境変数名(デフォルトは `OPENAI_API_KEY` などの標準変数)。環境変数名の配列も指定でき、リクエストごとに値のある変数から1つがランダムに選ばれます(複数キーへの負荷分散) |
|
||||||
| `baseUrlEnv` | いいえ | カスタムBase URL環境変数名 |
|
| `baseUrlEnv` | いいえ | カスタムBase URL環境変数名 |
|
||||||
|
|
||||||
### 備考
|
### 備考
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const messageItemSchema = z
|
|||||||
})
|
})
|
||||||
.passthrough()
|
.passthrough()
|
||||||
|
|
||||||
|
// The fields read here; every other field is passed on to AI.chatCompletions
|
||||||
const messageSchema = z
|
const messageSchema = z
|
||||||
.object({
|
.object({
|
||||||
messages: z.array(messageItemSchema),
|
messages: z.array(messageItemSchema),
|
||||||
@@ -36,21 +37,6 @@ const messageSchema = z
|
|||||||
stream: z.boolean().optional(),
|
stream: z.boolean().optional(),
|
||||||
tools: z.any().optional(),
|
tools: z.any().optional(),
|
||||||
tool_choice: z.any().optional(),
|
tool_choice: z.any().optional(),
|
||||||
functions: z.any().optional(),
|
|
||||||
function_call: z.any().optional(),
|
|
||||||
temperature: z.number().optional(),
|
|
||||||
top_p: z.number().optional(),
|
|
||||||
max_tokens: z.number().optional(),
|
|
||||||
presence_penalty: z.number().optional(),
|
|
||||||
frequency_penalty: z.number().optional(),
|
|
||||||
stop: z.union([z.string(), z.array(z.string())]).optional(),
|
|
||||||
response_format: z.any().optional(),
|
|
||||||
seed: z.number().optional(),
|
|
||||||
user: z.string().optional(),
|
|
||||||
n: z.number().int().optional(),
|
|
||||||
logit_bias: z.record(z.string(), z.number()).optional(),
|
|
||||||
parallel_tool_calls: z.boolean().optional(),
|
|
||||||
stream_options: z.any().optional(),
|
|
||||||
})
|
})
|
||||||
.passthrough()
|
.passthrough()
|
||||||
|
|
||||||
@@ -61,12 +47,6 @@ const ALLOWED_MODELS = [
|
|||||||
"@tx/deepseek-ai/deepseek-v3-0324",
|
"@tx/deepseek-ai/deepseek-v3-0324",
|
||||||
]
|
]
|
||||||
|
|
||||||
const MODEL_ALIASES: Record<string, string> = {
|
|
||||||
"deepseek-v3.2": "@tx/deepseek-ai/deepseek-v32",
|
|
||||||
"deepseek-r1-0528": "@tx/deepseek-ai/deepseek-r1-0528",
|
|
||||||
"deepseek-v3-0324": "@tx/deepseek-ai/deepseek-v3-0324",
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create standardized JSON response
|
* Create standardized JSON response
|
||||||
*/
|
*/
|
||||||
@@ -145,8 +125,16 @@ export async function onRequest({ request, env }: any) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { messages, model, stream, tools, tool_choice, ...extraParams } =
|
// stream is taken out so it is not passed on: AI.chatCompletions
|
||||||
parseResult.data
|
// only streams, and every caller reads a stream
|
||||||
|
const {
|
||||||
|
messages,
|
||||||
|
model,
|
||||||
|
stream: _,
|
||||||
|
tools,
|
||||||
|
tool_choice,
|
||||||
|
...extraParams
|
||||||
|
} = parseResult.data
|
||||||
|
|
||||||
// Validate messages
|
// Validate messages
|
||||||
const userMessages = messages.filter(
|
const userMessages = messages.filter(
|
||||||
@@ -165,14 +153,13 @@ export async function onRequest({ request, env }: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve model
|
// Resolve model
|
||||||
const requestedModel = model || ALLOWED_MODELS[0]
|
const selectedModel = model || ALLOWED_MODELS[0]
|
||||||
const selectedModel = MODEL_ALIASES[requestedModel] || requestedModel
|
|
||||||
|
|
||||||
if (!ALLOWED_MODELS.includes(selectedModel)) {
|
if (!ALLOWED_MODELS.includes(selectedModel)) {
|
||||||
return createResponse(
|
return createResponse(
|
||||||
{
|
{
|
||||||
error: {
|
error: {
|
||||||
message: `Invalid model: ${requestedModel}.`,
|
message: `Invalid model: ${selectedModel}.`,
|
||||||
type: "invalid_request_error",
|
type: "invalid_request_error",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -181,39 +168,10 @@ export async function onRequest({ request, env }: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[EdgeOne] Model: ${selectedModel}, Tools: ${tools?.length || 0}, Stream: ${stream ?? true}`,
|
`[EdgeOne] Model: ${selectedModel}, Tools: ${tools?.length || 0}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const isStream = !!stream
|
|
||||||
|
|
||||||
// Non-streaming: return mock response for validation
|
|
||||||
// AI.chatCompletions doesn't support non-streaming mode
|
|
||||||
if (!isStream) {
|
|
||||||
const mockResponse = {
|
|
||||||
id: `chatcmpl-${Date.now()}`,
|
|
||||||
object: "chat.completion",
|
|
||||||
created: Math.floor(Date.now() / 1000),
|
|
||||||
model: selectedModel,
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
index: 0,
|
|
||||||
message: {
|
|
||||||
role: "assistant",
|
|
||||||
content: "OK",
|
|
||||||
},
|
|
||||||
finish_reason: "stop",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
usage: {
|
|
||||||
prompt_tokens: 10,
|
|
||||||
completion_tokens: 1,
|
|
||||||
total_tokens: 11,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return createResponse(mockResponse)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build AI.chatCompletions options for streaming
|
// Build AI.chatCompletions options for streaming
|
||||||
const aiOptions: any = {
|
const aiOptions: any = {
|
||||||
...extraParams,
|
...extraParams,
|
||||||
|
|||||||
+18
-5
@@ -8,6 +8,7 @@ AI_PROVIDER=bedrock
|
|||||||
# Tip: For a single-provider quick multi-model setup, list comma-separated model IDs.
|
# Tip: For a single-provider quick multi-model setup, list comma-separated model IDs.
|
||||||
# The first one becomes the default and the rest appear in the model picker.
|
# The first one becomes the default and the rest appear in the model picker.
|
||||||
# For multiple providers or custom apiKeyEnv/baseUrlEnv, use AI_MODELS_CONFIG / ai-models.json instead.
|
# For multiple providers or custom apiKeyEnv/baseUrlEnv, use AI_MODELS_CONFIG / ai-models.json instead.
|
||||||
|
# There, apiKeyEnv may also be an array of env var names: each request picks one that has a value at random.
|
||||||
# Example: AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
# Example: AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
||||||
AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||||
|
|
||||||
@@ -28,8 +29,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# OpenAI Configuration
|
# OpenAI Configuration
|
||||||
# OPENAI_API_KEY=sk-...
|
# OPENAI_API_KEY=sk-...
|
||||||
# OPENAI_BASE_URL=https://api.openai.com/v1 # Optional: Custom OpenAI-compatible endpoint
|
# OPENAI_BASE_URL=https://api.openai.com/v1 # Optional: Custom OpenAI-compatible endpoint
|
||||||
# OPENAI_ORGANIZATION=org-... # Optional
|
|
||||||
# OPENAI_PROJECT=proj_... # Optional
|
|
||||||
# Note: o1/o3/gpt-5 models automatically enable reasoning summary (default: detailed)
|
# Note: o1/o3/gpt-5 models automatically enable reasoning summary (default: detailed)
|
||||||
# OPENAI_REASONING_EFFORT=low # Optional: Reasoning effort (minimal/low/medium/high) - for o1/o3/gpt-5
|
# OPENAI_REASONING_EFFORT=low # Optional: Reasoning effort (minimal/low/medium/high) - for o1/o3/gpt-5
|
||||||
# OPENAI_REASONING_SUMMARY=detailed # Optional: Override reasoning summary (none/brief/detailed)
|
# OPENAI_REASONING_SUMMARY=detailed # Optional: Override reasoning summary (none/brief/detailed)
|
||||||
@@ -142,6 +141,18 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# ADMIN_PASSWORD=your-admin-password
|
# ADMIN_PASSWORD=your-admin-password
|
||||||
# SETTINGS_FILE=./data/settings.json # Optional: custom settings file location
|
# SETTINGS_FILE=./data/settings.json # Optional: custom settings file location
|
||||||
|
|
||||||
|
# Quota & Validation (Optional, also editable in the admin panel)
|
||||||
|
# Quota enforcement is disabled unless DYNAMODB_QUOTA_TABLE is set.
|
||||||
|
# DYNAMODB_QUOTA_TABLE=next-ai-draw-io-quota # DynamoDB table that stores the per-IP counters
|
||||||
|
# DYNAMODB_REGION=ap-northeast-1 # Region of the table
|
||||||
|
# QUOTA_TIMEZONE=UTC # Timezone for the daily reset boundary
|
||||||
|
# DAILY_REQUEST_LIMIT=10 # Requests per IP per day
|
||||||
|
# DAILY_TOKEN_LIMIT=200000 # Tokens per IP per day
|
||||||
|
# TPM_LIMIT=20000 # Tokens per minute
|
||||||
|
# ENABLE_VLM_VALIDATION=true # Visually validate generated diagrams with a vision model
|
||||||
|
# VALIDATION_MODEL= # Falls back to the default AI model when empty
|
||||||
|
# VALIDATION_TIMEOUT=30000 # Validation timeout in ms
|
||||||
|
|
||||||
# Draw.io Configuration (Optional)
|
# Draw.io Configuration (Optional)
|
||||||
# By default the app serves its own copy of draw.io from /drawio (downloaded into
|
# By default the app serves its own copy of draw.io from /drawio (downloaded into
|
||||||
# public/drawio by `npm run dev` / `npm run build`). Same origin is required for
|
# public/drawio by `npm run dev` / `npm run build`). Same origin is required for
|
||||||
@@ -156,9 +167,7 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
# NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
||||||
|
|
||||||
# PDF Input Feature (Optional)
|
# PDF Input Feature (Optional)
|
||||||
# Enable PDF file upload to extract text and generate diagrams
|
# PDF file upload to extract text and generate diagrams
|
||||||
# Enabled by default. Set to "false" to disable.
|
|
||||||
# ENABLE_PDF_INPUT=true
|
|
||||||
# NEXT_PUBLIC_MAX_EXTRACTED_CHARS=150000 # Max characters for PDF/text extraction (default: 150000)
|
# NEXT_PUBLIC_MAX_EXTRACTED_CHARS=150000 # Max characters for PDF/text extraction (default: 150000)
|
||||||
|
|
||||||
# Security Settings (Optional)
|
# Security Settings (Optional)
|
||||||
@@ -184,6 +193,10 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# It also turns off the MCP server advertisement in Settings > About.
|
# It also turns off the MCP server advertisement in Settings > About.
|
||||||
# NEXT_PUBLIC_SELFHOSTED=true
|
# NEXT_PUBLIC_SELFHOSTED=true
|
||||||
|
|
||||||
|
# Analytics (Optional)
|
||||||
|
# Google Analytics measurement ID; nothing is loaded when unset
|
||||||
|
# NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
|
||||||
|
|
||||||
# Minimax Configuration (Optional)
|
# Minimax Configuration (Optional)
|
||||||
# Get your API key from: https://platform.minimaxi.com/docs/guides/models-intro
|
# Get your API key from: https://platform.minimaxi.com/docs/guides/models-intro
|
||||||
# MINIMAX_API_KEY=your_minimax_api_key
|
# MINIMAX_API_KEY=your_minimax_api_key
|
||||||
|
|||||||
+4
-10
@@ -56,7 +56,7 @@ const StoredAdminProviderSchema = AdminProviderSchema.extend({
|
|||||||
vertexApiKey: z.string().optional(),
|
vertexApiKey: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type AdminProviderInput = z.infer<typeof AdminProviderSchema>
|
type AdminProviderInput = z.infer<typeof AdminProviderSchema>
|
||||||
|
|
||||||
// Stored form: secrets are plain strings
|
// Stored form: secrets are plain strings
|
||||||
export interface StoredAdminProvider {
|
export interface StoredAdminProvider {
|
||||||
@@ -115,7 +115,7 @@ export function loadAdminProviders(): StoredAdminProvider[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MaskedAdminProvider = Omit<
|
type MaskedAdminProvider = Omit<
|
||||||
StoredAdminProvider,
|
StoredAdminProvider,
|
||||||
(typeof SECRET_FIELDS)[number]
|
(typeof SECRET_FIELDS)[number]
|
||||||
> & {
|
> & {
|
||||||
@@ -278,15 +278,9 @@ export function deriveEnvUpdates(
|
|||||||
return updates
|
return updates
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every settings key the panel may have written for a given list.
|
// 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).
|
|
||||||
function derivedEnvKeys(list: StoredAdminProvider[]): string[] {
|
function derivedEnvKeys(list: StoredAdminProvider[]): string[] {
|
||||||
const keys = new Set<string>([
|
const keys = new Set<string>(["AI_PROVIDER", "AI_MODEL"])
|
||||||
"AI_MODELS_CONFIG",
|
|
||||||
"AI_PROVIDER",
|
|
||||||
"AI_MODEL",
|
|
||||||
])
|
|
||||||
const indexByProvider = new Map<ProviderName, number>()
|
const indexByProvider = new Map<ProviderName, number>()
|
||||||
for (const p of list) {
|
for (const p of list) {
|
||||||
const index = indexByProvider.get(p.provider) ?? 0
|
const index = indexByProvider.get(p.provider) ?? 0
|
||||||
|
|||||||
+66
-115
@@ -30,9 +30,7 @@ import {
|
|||||||
type ProviderName,
|
type ProviderName,
|
||||||
} from "@/lib/types/model-config"
|
} from "@/lib/types/model-config"
|
||||||
|
|
||||||
export type { ProviderName }
|
const AIHUBMIX_APP_CODE = "MSBS9675"
|
||||||
|
|
||||||
export const AIHUBMIX_APP_CODE = "MSBS9675"
|
|
||||||
|
|
||||||
interface ModelConfig {
|
interface ModelConfig {
|
||||||
model: any
|
model: any
|
||||||
@@ -56,7 +54,7 @@ export const SINGLE_SYSTEM_PROVIDERS = new Set<ProviderName>([
|
|||||||
* Normalize MiniMax base URL for AI SDK compatibility.
|
* Normalize MiniMax base URL for AI SDK compatibility.
|
||||||
* MiniMax supports Anthropic-compatible and OpenAI-compatible endpoints.
|
* MiniMax supports Anthropic-compatible and OpenAI-compatible endpoints.
|
||||||
*/
|
*/
|
||||||
export function normalizeMiniMaxBaseURL(rawUrl: string): {
|
function normalizeMiniMaxBaseURL(rawUrl: string): {
|
||||||
baseURL: string
|
baseURL: string
|
||||||
isAnthropicCompatible: boolean
|
isAnthropicCompatible: boolean
|
||||||
} {
|
} {
|
||||||
@@ -129,21 +127,19 @@ const BEDROCK_ANTHROPIC_BETA = {
|
|||||||
* @param userApiKey - User-provided API key (if any)
|
* @param userApiKey - User-provided API key (if any)
|
||||||
* @param userBaseUrl - User-provided base URL (if any)
|
* @param userBaseUrl - User-provided base URL (if any)
|
||||||
* @param serverBaseUrl - Server's base URL from environment variable
|
* @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
|
* @returns The resolved base URL to use
|
||||||
*/
|
*/
|
||||||
export function resolveBaseURL(
|
export function resolveBaseURL(
|
||||||
userApiKey: string | null | undefined,
|
userApiKey: string | null | undefined,
|
||||||
userBaseUrl: string | null | undefined,
|
userBaseUrl: string | null | undefined,
|
||||||
serverBaseUrl: string | undefined,
|
serverBaseUrl: string | undefined,
|
||||||
defaultBaseUrl?: string,
|
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
if (userApiKey) {
|
if (userApiKey) {
|
||||||
// User provides their own API key - only use user's baseUrl or default
|
// User provides their own API key - only use user's baseUrl
|
||||||
return userBaseUrl || defaultBaseUrl || undefined
|
return userBaseUrl || undefined
|
||||||
}
|
}
|
||||||
// No user API key - fall back to server config
|
// 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
|
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
|
* Build provider-specific options from environment variables
|
||||||
* Supports various AI SDK providers with their unique configuration options
|
* Supports various AI SDK providers with their unique configuration options
|
||||||
@@ -271,41 +300,27 @@ function buildProviderOptions(
|
|||||||
|
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case "openai": {
|
case "openai": {
|
||||||
const reasoningEffort = process.env.OPENAI_REASONING_EFFORT
|
const reasoningEffort = process.env.OPENAI_REASONING_EFFORT as
|
||||||
const reasoningSummary = process.env.OPENAI_REASONING_SUMMARY
|
| "minimal"
|
||||||
|
| "low"
|
||||||
|
| "medium"
|
||||||
|
| "high"
|
||||||
|
| undefined
|
||||||
// Reasoning models (the o-series, gpt-5 and later) need
|
// Reasoning models (the o-series, gpt-5 and later) need
|
||||||
// reasoningSummary to return thoughts
|
// reasoningSummary to return thoughts; 'auto' as the default
|
||||||
if (modelId && /^(o\d|gpt-([5-9]|[1-9]\d))/.test(modelId)) {
|
// since not all models support 'detailed'
|
||||||
options.openai = {
|
const isReasoning =
|
||||||
// Auto-enable reasoning summary for reasoning models
|
!!modelId && /^(o\d|gpt-([5-9]|[1-9]\d))/.test(modelId)
|
||||||
// Use 'auto' as default since not all models support 'detailed'
|
const reasoningSummary =
|
||||||
reasoningSummary:
|
(process.env.OPENAI_REASONING_SUMMARY as
|
||||||
(reasoningSummary as "auto" | "detailed") || "auto",
|
| "auto"
|
||||||
}
|
| "detailed"
|
||||||
|
| undefined) || (isReasoning ? "auto" : undefined)
|
||||||
|
|
||||||
// Optionally configure reasoning effort
|
if (reasoningSummary || reasoningEffort) {
|
||||||
if (reasoningEffort) {
|
options.openai = {
|
||||||
options.openai.reasoningEffort = reasoningEffort as
|
...(reasoningSummary && { reasoningSummary }),
|
||||||
| "minimal"
|
...(reasoningEffort && { reasoningEffort }),
|
||||||
| "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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
@@ -333,85 +348,21 @@ function buildProviderOptions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "google": {
|
case "google": {
|
||||||
const thinkingBudgetVal = parseIntSafe(
|
const google = geminiThinkingOptions(
|
||||||
process.env.GOOGLE_THINKING_BUDGET,
|
modelId,
|
||||||
"GOOGLE_THINKING_BUDGET",
|
"GOOGLE_THINKING_BUDGET",
|
||||||
1024,
|
"GOOGLE_THINKING_LEVEL",
|
||||||
100000,
|
|
||||||
)
|
)
|
||||||
const thinkingLevel = process.env.GOOGLE_THINKING_LEVEL
|
if (google) options.google = google
|
||||||
|
|
||||||
// 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 }
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "vertexai": {
|
case "vertexai": {
|
||||||
const thinkingBudget = parseIntSafe(
|
const google = geminiThinkingOptions(
|
||||||
process.env.GOOGLE_VERTEX_THINKING_BUDGET,
|
modelId,
|
||||||
"GOOGLE_VERTEX_THINKING_BUDGET",
|
"GOOGLE_VERTEX_THINKING_BUDGET",
|
||||||
1024,
|
"GOOGLE_VERTEX_THINKING_LEVEL",
|
||||||
100000,
|
|
||||||
)
|
)
|
||||||
const thinkingLevel = process.env.GOOGLE_VERTEX_THINKING_LEVEL
|
if (google) options.google = google
|
||||||
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "azure": {
|
case "azure": {
|
||||||
@@ -498,7 +449,7 @@ function buildProviderOptions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map of provider to required environment variable
|
// 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
|
bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally
|
||||||
openai: "OPENAI_API_KEY",
|
openai: "OPENAI_API_KEY",
|
||||||
anthropic: "ANTHROPIC_API_KEY",
|
anthropic: "ANTHROPIC_API_KEY",
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// 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(
|
export function findCachedResponse(
|
||||||
promptText: string,
|
promptText: string,
|
||||||
hasImage: boolean,
|
hasImage: boolean,
|
||||||
@@ -904,7 +904,6 @@ export function findCachedResponse(
|
|||||||
(c) =>
|
(c) =>
|
||||||
c.promptText === promptText &&
|
c.promptText === promptText &&
|
||||||
c.hasImage === hasImage &&
|
c.hasImage === hasImage &&
|
||||||
(!c.fileName || c.fileName === fileName) &&
|
(!c.fileName || c.fileName === fileName),
|
||||||
c.xml !== "",
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,35 +58,6 @@ function isValidToolInput(input: unknown): boolean {
|
|||||||
return !!input && typeof input === "object" && Object.keys(input).length > 0
|
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),
|
// 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.
|
// together with their tool-results: providers reject a result whose call is missing.
|
||||||
// Messages left empty are removed too (Bedrock rejects empty content arrays).
|
// Messages left empty are removed too (Bedrock rejects empty content arrays).
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Re-export types from the schema file (single source of truth)
|
// 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"
|
import type { ValidationResult } from "./validation-schema"
|
||||||
|
|
||||||
|
|||||||
+45
-61
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ConditionalCheckFailedException,
|
ConditionalCheckFailedException,
|
||||||
DynamoDBClient,
|
DynamoDBClient,
|
||||||
GetItemCommand,
|
|
||||||
UpdateItemCommand,
|
UpdateItemCommand,
|
||||||
} from "@aws-sdk/client-dynamodb"
|
} from "@aws-sdk/client-dynamodb"
|
||||||
|
|
||||||
@@ -108,76 +107,61 @@ export async function checkAndIncrementRequest(
|
|||||||
":tokenLimit": { N: String(limits.tokens || 999999) },
|
":tokenLimit": { N: String(limits.tokens || 999999) },
|
||||||
":tpmLimit": { N: String(limits.tpm || 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 }
|
return { allowed: true }
|
||||||
} catch (e: any) {
|
} 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) {
|
if (e instanceof ConditionalCheckFailedException) {
|
||||||
// Get current counts to determine which limit was hit
|
const item = e.Item
|
||||||
try {
|
const storedMinute = item?.lastMinute?.S
|
||||||
const getResult = await client.send(
|
|
||||||
new GetItemCommand({
|
|
||||||
TableName: TABLE,
|
|
||||||
Key: {
|
|
||||||
PK: { S: pk },
|
|
||||||
SK: { S: sk },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const item = getResult.Item
|
const reqCount = Number(item?.reqCount?.N || 0)
|
||||||
const storedMinute = item?.lastMinute?.S
|
const tokenCount = Number(item?.tokenCount?.N || 0)
|
||||||
|
const tpmCount =
|
||||||
|
storedMinute !== currentMinute
|
||||||
|
? 0
|
||||||
|
: Number(item?.tpmCount?.N || 0)
|
||||||
|
|
||||||
const reqCount = Number(item?.reqCount?.N || 0)
|
// Determine which limit was exceeded
|
||||||
const tokenCount = Number(item?.tokenCount?.N || 0)
|
if (limits.requests > 0 && reqCount >= limits.requests) {
|
||||||
const tpmCount =
|
return {
|
||||||
storedMinute !== currentMinute
|
allowed: false,
|
||||||
? 0
|
type: "request",
|
||||||
: Number(item?.tpmCount?.N || 0)
|
error: "Daily request limit exceeded",
|
||||||
|
used: reqCount,
|
||||||
// Determine which limit was exceeded
|
limit: limits.requests,
|
||||||
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
|
// Other DynamoDB errors - fail open
|
||||||
|
|||||||
+50
-1
@@ -1,10 +1,59 @@
|
|||||||
import { LangfuseClient } from "@langfuse/client"
|
import { LangfuseClient } from "@langfuse/client"
|
||||||
import { observe, updateActiveTrace } from "@langfuse/tracing"
|
import { observe, updateActiveTrace } from "@langfuse/tracing"
|
||||||
import * as api from "@opentelemetry/api"
|
import * as api from "@opentelemetry/api"
|
||||||
|
import { randomUUID } from "crypto"
|
||||||
|
|
||||||
// Singleton LangfuseClient instance for direct API calls
|
// Singleton LangfuseClient instance for direct API calls
|
||||||
let langfuseClient: LangfuseClient | null = null
|
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 {
|
export function getLangfuseClient(): LangfuseClient | null {
|
||||||
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
|
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
|
||||||
return null
|
return null
|
||||||
@@ -22,7 +71,7 @@ export function getLangfuseClient(): LangfuseClient | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if Langfuse is configured (both keys required)
|
// Check if Langfuse is configured (both keys required)
|
||||||
export function isLangfuseEnabled(): boolean {
|
function isLangfuseEnabled(): boolean {
|
||||||
return !!(
|
return !!(
|
||||||
process.env.LANGFUSE_PUBLIC_KEY && process.env.LANGFUSE_SECRET_KEY
|
process.env.LANGFUSE_PUBLIC_KEY && process.env.LANGFUSE_SECRET_KEY
|
||||||
)
|
)
|
||||||
|
|||||||
+2
-2
@@ -11,7 +11,7 @@ import {
|
|||||||
* What went wrong with a model call, for a hint the user can act on. The
|
* 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.
|
* provider's own message always goes along, because a guess can be wrong.
|
||||||
*/
|
*/
|
||||||
export type LLMErrorCode =
|
type LLMErrorCode =
|
||||||
| "invalid_api_key"
|
| "invalid_api_key"
|
||||||
| "forbidden"
|
| "forbidden"
|
||||||
| "model_not_found"
|
| "model_not_found"
|
||||||
@@ -28,7 +28,7 @@ export type LLMErrorCode =
|
|||||||
| "server_key_forbidden"
|
| "server_key_forbidden"
|
||||||
| "unknown"
|
| "unknown"
|
||||||
|
|
||||||
export interface LLMError {
|
interface LLMError {
|
||||||
type: "provider"
|
type: "provider"
|
||||||
code: LLMErrorCode
|
code: LLMErrorCode
|
||||||
message: string
|
message: string
|
||||||
|
|||||||
@@ -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
|
* Only used for hints: requests are sent the same way either way, since
|
||||||
* the data can be wrong or out of date.
|
* the data can be wrong or out of date.
|
||||||
*/
|
*/
|
||||||
export interface ModelInfo {
|
interface ModelInfo {
|
||||||
tools: boolean
|
tools: boolean
|
||||||
images: boolean
|
images: boolean
|
||||||
reasoning: boolean
|
reasoning: boolean
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type WrappedModel = ReturnType<typeof wrapLanguageModel>
|
|||||||
export const DEFAULT_MAX_OUTPUT_TOKENS = 64000
|
export const DEFAULT_MAX_OUTPUT_TOKENS = 64000
|
||||||
|
|
||||||
/** Ceiling for the user-supplied override, to catch typos like an extra zero. */
|
/** 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
|
* 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
|
* "lower than N") would reinterpret unrelated failures, and retrying on a bogus
|
||||||
* number turns a readable error into an empty diagram.
|
* 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,
|
// Combined input+output ceiling: subtract the input the provider counted,
|
||||||
// plus a small margin because its estimate is approximate.
|
// plus a small margin because its estimate is approximate.
|
||||||
const context = text.match(/maximum context length (?:is|of) (\d+)/i)
|
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
|
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
|
* Thinking budget the provider adds on top of maxOutputTokens. Bedrock and
|
||||||
* Anthropic send maxOutputTokens + budgetTokens as max_tokens, so a ceiling in
|
* Anthropic send maxOutputTokens + budgetTokens as max_tokens, so a ceiling in
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export interface ListedModel {
|
|||||||
tools?: boolean
|
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 {
|
export function canListModels(provider: ProviderName): boolean {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const ProviderNameSchema: z.ZodType<ProviderName> = z
|
|||||||
message: "Invalid provider name",
|
message: "Invalid provider name",
|
||||||
})
|
})
|
||||||
|
|
||||||
export const ServerProviderSchema = z.object({
|
const ServerProviderSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
provider: ProviderNameSchema,
|
provider: ProviderNameSchema,
|
||||||
models: z.array(z.string().min(1)),
|
models: z.array(z.string().min(1)),
|
||||||
@@ -30,7 +30,6 @@ export const ServerModelsConfigSchema = z.object({
|
|||||||
providers: z.array(ServerProviderSchema),
|
providers: z.array(ServerProviderSchema),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type ServerProviderConfig = z.infer<typeof ServerProviderSchema>
|
|
||||||
export type ServerModelsConfig = z.infer<typeof ServerModelsConfigSchema>
|
export type ServerModelsConfig = z.infer<typeof ServerModelsConfigSchema>
|
||||||
|
|
||||||
export interface FlattenedServerModel {
|
export interface FlattenedServerModel {
|
||||||
|
|||||||
+12
-158
@@ -1,17 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* System prompts for different AI models
|
* System prompt for the chat route
|
||||||
* Extended prompt is used for models with higher cache token minimums (Opus 4.5, Haiku 4.5)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { STYLE_CLASS_EXAMPLE } from "@/packages/mcp-server/src/xml-examples.ts"
|
||||||
STYLE_CLASS_EXAMPLE,
|
|
||||||
SWIMLANE_EXAMPLE,
|
|
||||||
TWO_EDGES_EXAMPLE,
|
|
||||||
WAYPOINT_EXAMPLE,
|
|
||||||
} from "@/packages/mcp-server/src/xml-examples.ts"
|
|
||||||
|
|
||||||
// Default system prompt (~1900 tokens) - works with all models
|
const DEFAULT_SYSTEM_PROMPT = `
|
||||||
export const DEFAULT_SYSTEM_PROMPT = `
|
|
||||||
You are an expert diagram creation assistant specializing in draw.io XML generation.
|
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.
|
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.
|
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.
|
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
|
## App Context
|
||||||
You are an AI agent (powered by {{MODEL_NAME}}) inside a web app. The interface has:
|
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.
|
||||||
- **Left panel**: Draw.io diagram editor where diagrams are rendered
|
|
||||||
- **Right panel**: Chat interface where you communicate with the user
|
|
||||||
|
|
||||||
You can read and modify diagrams by generating draw.io XML code through tool calls.
|
You can read and modify diagrams by generating draw.io XML code through tool calls.
|
||||||
|
|
||||||
## App Features
|
## 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.
|
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. **Theme Toggle** (palette icon, bottom-left of chat input): Users can switch between minimal UI and sketch-style UI for the draw.io editor.
|
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. **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.
|
3. **Export** (via draw.io toolbar): Users can save diagrams as .drawio, .svg, or .png files.
|
||||||
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.
|
|
||||||
|
|
||||||
You utilize the following tools:
|
You utilize the following tools:
|
||||||
---Tool1---
|
---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: < for <, > for >, & for &, " 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
|
* Build the system prompt for a model 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, shown to the model as its name
|
||||||
* @param modelId - The AI model ID from environment
|
|
||||||
* @param minimalStyle - If true, removes style instructions to save tokens
|
* @param minimalStyle - If true, removes style instructions to save tokens
|
||||||
* @returns The system prompt string
|
* @returns The system prompt string
|
||||||
*/
|
*/
|
||||||
@@ -353,32 +225,14 @@ export function getSystemPrompt(
|
|||||||
): string {
|
): string {
|
||||||
const modelName = modelId || "AI"
|
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)
|
// Minimal style: prepend instruction at START (more prominent)
|
||||||
// Normal style: append at end
|
// Normal style: append at end
|
||||||
|
let prompt: string
|
||||||
if (minimalStyle) {
|
if (minimalStyle) {
|
||||||
console.log(`[System Prompt] Minimal style mode ENABLED`)
|
console.log(`[System Prompt] Minimal style mode ENABLED`)
|
||||||
prompt = MINIMAL_STYLE_INSTRUCTION + prompt
|
prompt = MINIMAL_STYLE_INSTRUCTION + DEFAULT_SYSTEM_PROMPT
|
||||||
} else {
|
} else {
|
||||||
prompt += STYLE_INSTRUCTIONS
|
prompt = DEFAULT_SYSTEM_PROMPT + STYLE_INSTRUCTIONS
|
||||||
}
|
}
|
||||||
|
|
||||||
return prompt.replace("{{MODEL_NAME}}", modelName)
|
return prompt.replace("{{MODEL_NAME}}", modelName)
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,4 +35,3 @@ export const ValidationResultSchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export type ValidationResult = z.infer<typeof ValidationResultSchema>
|
export type ValidationResult = z.infer<typeof ValidationResultSchema>
|
||||||
export type ValidationIssue = ValidationResult["issues"][number]
|
|
||||||
|
|||||||
@@ -48,8 +48,6 @@ describe("deriveEnvUpdates", () => {
|
|||||||
expect(updates.ADMIN_OPENAI_API_KEY).toBe("sk-test")
|
expect(updates.ADMIN_OPENAI_API_KEY).toBe("sk-test")
|
||||||
expect(updates.OPENAI_API_KEY).toBeUndefined()
|
expect(updates.OPENAI_API_KEY).toBeUndefined()
|
||||||
expect(JSON.parse(updates.ADMIN_PROVIDERS as string)).toHaveLength(1)
|
expect(JSON.parse(updates.ADMIN_PROVIDERS as string)).toHaveLength(1)
|
||||||
// AI_MODELS_CONFIG is no longer written (merged at read time)
|
|
||||||
expect(updates.AI_MODELS_CONFIG).toBeNull()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("suffixes env vars for a second instance of the same provider", () => {
|
it("suffixes env vars for a second instance of the same provider", () => {
|
||||||
@@ -137,7 +135,6 @@ describe("deriveEnvUpdates", () => {
|
|||||||
const prev = [provider()]
|
const prev = [provider()]
|
||||||
const updates = deriveEnvUpdates([], prev)
|
const updates = deriveEnvUpdates([], prev)
|
||||||
expect(updates.ADMIN_OPENAI_API_KEY).toBeNull()
|
expect(updates.ADMIN_OPENAI_API_KEY).toBeNull()
|
||||||
expect(updates.AI_MODELS_CONFIG).toBeNull()
|
|
||||||
expect(updates.ADMIN_PROVIDERS).toBeNull()
|
expect(updates.ADMIN_PROVIDERS).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
// @vitest-environment node
|
// @vitest-environment node
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
// The request the admin Test hands to validate-model
|
// What the admin Test hands to testModelConnection
|
||||||
const sent = vi.hoisted(() => ({ body: null as any, headers: null as any }))
|
const sent = vi.hoisted(() => ({ body: null as any, trusted: null as any }))
|
||||||
vi.mock("@/app/api/validate-model/route", () => ({
|
vi.mock("@/lib/test-model-connection", () => ({
|
||||||
POST: async (req: Request) => {
|
testModelConnection: async (_req: Request, body: any, trusted: boolean) => {
|
||||||
sent.body = await req.json()
|
sent.body = body
|
||||||
sent.headers = Object.fromEntries(req.headers)
|
sent.trusted = trusted
|
||||||
return Response.json({ valid: true })
|
return Response.json({ valid: true })
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
@@ -61,7 +61,7 @@ describe("admin Test of an entry without a URL", () => {
|
|||||||
await test({ provider: "openai", apiKey: "panel-key" })
|
await test({ provider: "openai", apiKey: "panel-key" })
|
||||||
expect(sent.body.baseUrl).toBe("https://operator-proxy.example.com/v1")
|
expect(sent.body.baseUrl).toBe("https://operator-proxy.example.com/v1")
|
||||||
// The server's own URL, tested without the rules for typed URLs
|
// The server's own URL, tested without the rules for typed URLs
|
||||||
expect(sent.body.serverBaseUrl).toBe(true)
|
expect(sent.trusted).toBe(true)
|
||||||
|
|
||||||
process.env.AI_GATEWAY_BASE_URL = "https://gateway.example.com/v3/ai"
|
process.env.AI_GATEWAY_BASE_URL = "https://gateway.example.com/v3/ai"
|
||||||
await test({ provider: "gateway", apiKey: "k" })
|
await test({ provider: "gateway", apiKey: "k" })
|
||||||
@@ -91,7 +91,7 @@ describe("admin Test of an entry without a URL", () => {
|
|||||||
"https://vertex-proxy.example.com"
|
"https://vertex-proxy.example.com"
|
||||||
await test({ provider: "vertexai", vertexApiKey: "new-key" })
|
await test({ provider: "vertexai", vertexApiKey: "new-key" })
|
||||||
expect(sent.body.baseUrl).toBe("https://vertex-proxy.example.com")
|
expect(sent.body.baseUrl).toBe("https://vertex-proxy.example.com")
|
||||||
expect(sent.body.serverBaseUrl).toBe(true)
|
expect(sent.trusted).toBe(true)
|
||||||
} finally {
|
} finally {
|
||||||
delete process.env.GOOGLE_VERTEX_BASE_URL
|
delete process.env.GOOGLE_VERTEX_BASE_URL
|
||||||
}
|
}
|
||||||
@@ -103,7 +103,7 @@ describe("admin Test of an entry without a URL", () => {
|
|||||||
expect(sent.body.baseUrl).toBe(
|
expect(sent.body.baseUrl).toBe(
|
||||||
"https://team-openai.openai.azure.com/openai",
|
"https://team-openai.openai.azure.com/openai",
|
||||||
)
|
)
|
||||||
expect(sent.body.serverBaseUrl).toBe(true)
|
expect(sent.trusted).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("tests Ollama where chat sends the entry's key", async () => {
|
it("tests Ollama where chat sends the entry's key", async () => {
|
||||||
@@ -111,7 +111,7 @@ describe("admin Test of an entry without a URL", () => {
|
|||||||
// the SDK's local default (the Test used to go to Ollama Cloud)
|
// the SDK's local default (the Test used to go to Ollama Cloud)
|
||||||
await test({ provider: "ollama", apiKey: "k" })
|
await test({ provider: "ollama", apiKey: "k" })
|
||||||
expect(sent.body.baseUrl).toBe("http://127.0.0.1:11434/api")
|
expect(sent.body.baseUrl).toBe("http://127.0.0.1:11434/api")
|
||||||
expect(sent.body.serverBaseUrl).toBe(true)
|
expect(sent.trusted).toBe(true)
|
||||||
envFallback.values.OLLAMA_BASE_URL = "http://gpu:11434/api"
|
envFallback.values.OLLAMA_BASE_URL = "http://gpu:11434/api"
|
||||||
await test({ provider: "ollama", apiKey: "k" })
|
await test({ provider: "ollama", apiKey: "k" })
|
||||||
expect(sent.body.baseUrl).toBe("http://gpu:11434/api")
|
expect(sent.body.baseUrl).toBe("http://gpu:11434/api")
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import { generateText } from "ai"
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||||
import { getAIModel } from "@/lib/ai-providers"
|
import { getAIModel } from "@/lib/ai-providers"
|
||||||
|
|
||||||
const ENV = ["GOOGLE_GENERATIVE_AI_API_KEY", "GOOGLE_TOP_K", "GOOGLE_TOP_P"]
|
const ENV = [
|
||||||
|
"GOOGLE_GENERATIVE_AI_API_KEY",
|
||||||
|
"GOOGLE_TOP_K",
|
||||||
|
"GOOGLE_TOP_P",
|
||||||
|
"GOOGLE_THINKING_BUDGET",
|
||||||
|
"GOOGLE_THINKING_LEVEL",
|
||||||
|
]
|
||||||
const saved = Object.fromEntries(ENV.map((k) => [k, process.env[k]]))
|
const saved = Object.fromEntries(ENV.map((k) => [k, process.env[k]]))
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -15,7 +21,7 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/** Send one request through the real Google provider and return its body */
|
/** Send one request through the real Google provider and return its body */
|
||||||
async function googleRequestBody() {
|
async function googleRequestBody(modelId = "gemini-2.5-flash") {
|
||||||
let body: any
|
let body: any
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
@@ -34,11 +40,17 @@ async function googleRequestBody() {
|
|||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const { model } = getAIModel({
|
// providerOptions carry the thinking settings, as the chat route passes them
|
||||||
|
const { model, providerOptions } = getAIModel({
|
||||||
provider: "google",
|
provider: "google",
|
||||||
modelId: "gemini-2.5-flash",
|
modelId,
|
||||||
|
})
|
||||||
|
await generateText({
|
||||||
|
model,
|
||||||
|
prompt: "hi",
|
||||||
|
maxRetries: 0,
|
||||||
|
...(providerOptions && { providerOptions }),
|
||||||
})
|
})
|
||||||
await generateText({ model, prompt: "hi", maxRetries: 0 })
|
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,3 +72,27 @@ describe("Google sampling settings", () => {
|
|||||||
expect(body.generationConfig?.topP).toBeUndefined()
|
expect(body.generationConfig?.topP).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("Gemini thinking settings", () => {
|
||||||
|
it("sends GOOGLE_THINKING_BUDGET as the Gemini 2.5 thinking budget", async () => {
|
||||||
|
process.env.GOOGLE_GENERATIVE_AI_API_KEY = "test-key"
|
||||||
|
process.env.GOOGLE_THINKING_BUDGET = "8192"
|
||||||
|
delete process.env.GOOGLE_THINKING_LEVEL
|
||||||
|
const body = await googleRequestBody("gemini-2.5-flash")
|
||||||
|
expect(body.generationConfig.thinkingConfig).toEqual({
|
||||||
|
includeThoughts: true,
|
||||||
|
thinkingBudget: 8192,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sends GOOGLE_THINKING_LEVEL as the Gemini 3 thinking level", async () => {
|
||||||
|
process.env.GOOGLE_GENERATIVE_AI_API_KEY = "test-key"
|
||||||
|
process.env.GOOGLE_THINKING_LEVEL = "high"
|
||||||
|
delete process.env.GOOGLE_THINKING_BUDGET
|
||||||
|
const body = await googleRequestBody("gemini-3-pro-preview")
|
||||||
|
expect(body.generationConfig.thinkingConfig).toEqual({
|
||||||
|
includeThoughts: true,
|
||||||
|
thinkingLevel: "high",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
describe("resolveBaseURL", () => {
|
describe("resolveBaseURL", () => {
|
||||||
const SERVER_BASE_URL = "https://server-proxy.example.com"
|
const SERVER_BASE_URL = "https://server-proxy.example.com"
|
||||||
const USER_BASE_URL = "https://user-proxy.example.com"
|
const USER_BASE_URL = "https://user-proxy.example.com"
|
||||||
const DEFAULT_BASE_URL = "https://api.provider.com/v1"
|
|
||||||
const USER_API_KEY = "user-api-key-123"
|
const USER_API_KEY = "user-api-key-123"
|
||||||
|
|
||||||
describe("when user provides their own API key", () => {
|
describe("when user provides their own API key", () => {
|
||||||
@@ -18,28 +17,12 @@ describe("resolveBaseURL", () => {
|
|||||||
USER_API_KEY,
|
USER_API_KEY,
|
||||||
USER_BASE_URL,
|
USER_BASE_URL,
|
||||||
SERVER_BASE_URL,
|
SERVER_BASE_URL,
|
||||||
DEFAULT_BASE_URL,
|
|
||||||
)
|
)
|
||||||
expect(result).toBe(USER_BASE_URL)
|
expect(result).toBe(USER_BASE_URL)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("uses default baseUrl when user provides no baseUrl", () => {
|
it("returns undefined when user provides no baseUrl", () => {
|
||||||
const result = resolveBaseURL(
|
const result = resolveBaseURL(USER_API_KEY, null, SERVER_BASE_URL)
|
||||||
USER_API_KEY,
|
|
||||||
null,
|
|
||||||
SERVER_BASE_URL,
|
|
||||||
DEFAULT_BASE_URL,
|
|
||||||
)
|
|
||||||
expect(result).toBe(DEFAULT_BASE_URL)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns undefined when user provides no baseUrl and no default exists", () => {
|
|
||||||
const result = resolveBaseURL(
|
|
||||||
USER_API_KEY,
|
|
||||||
null,
|
|
||||||
SERVER_BASE_URL,
|
|
||||||
undefined,
|
|
||||||
)
|
|
||||||
expect(result).toBeUndefined()
|
expect(result).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -48,92 +31,47 @@ describe("resolveBaseURL", () => {
|
|||||||
USER_API_KEY,
|
USER_API_KEY,
|
||||||
undefined,
|
undefined,
|
||||||
SERVER_BASE_URL,
|
SERVER_BASE_URL,
|
||||||
undefined,
|
|
||||||
)
|
)
|
||||||
// Should NOT return SERVER_BASE_URL
|
// Should NOT return SERVER_BASE_URL
|
||||||
expect(result).not.toBe(SERVER_BASE_URL)
|
expect(result).not.toBe(SERVER_BASE_URL)
|
||||||
expect(result).toBeUndefined()
|
expect(result).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("prefers user's baseUrl over default", () => {
|
|
||||||
const result = resolveBaseURL(
|
|
||||||
USER_API_KEY,
|
|
||||||
USER_BASE_URL,
|
|
||||||
SERVER_BASE_URL,
|
|
||||||
DEFAULT_BASE_URL,
|
|
||||||
)
|
|
||||||
expect(result).toBe(USER_BASE_URL)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("when using server credentials (no user API key)", () => {
|
describe("when using server credentials (no user API key)", () => {
|
||||||
it("uses user's baseUrl when provided (overrides server)", () => {
|
it("uses user's baseUrl when provided (overrides server)", () => {
|
||||||
const result = resolveBaseURL(
|
const result = resolveBaseURL(null, USER_BASE_URL, SERVER_BASE_URL)
|
||||||
null,
|
|
||||||
USER_BASE_URL,
|
|
||||||
SERVER_BASE_URL,
|
|
||||||
DEFAULT_BASE_URL,
|
|
||||||
)
|
|
||||||
expect(result).toBe(USER_BASE_URL)
|
expect(result).toBe(USER_BASE_URL)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("falls back to server's baseUrl when no user baseUrl", () => {
|
it("falls back to server's baseUrl when no user baseUrl", () => {
|
||||||
const result = resolveBaseURL(
|
const result = resolveBaseURL(null, null, SERVER_BASE_URL)
|
||||||
null,
|
|
||||||
null,
|
|
||||||
SERVER_BASE_URL,
|
|
||||||
DEFAULT_BASE_URL,
|
|
||||||
)
|
|
||||||
expect(result).toBe(SERVER_BASE_URL)
|
expect(result).toBe(SERVER_BASE_URL)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("falls back to default when no user or server baseUrl", () => {
|
|
||||||
const result = resolveBaseURL(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
undefined,
|
|
||||||
DEFAULT_BASE_URL,
|
|
||||||
)
|
|
||||||
expect(result).toBe(DEFAULT_BASE_URL)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns undefined when no baseUrl available anywhere", () => {
|
it("returns undefined when no baseUrl available anywhere", () => {
|
||||||
const result = resolveBaseURL(null, null, undefined, undefined)
|
const result = resolveBaseURL(null, null, undefined)
|
||||||
expect(result).toBeUndefined()
|
expect(result).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("handles undefined apiKey same as null", () => {
|
it("handles undefined apiKey same as null", () => {
|
||||||
const result = resolveBaseURL(
|
const result = resolveBaseURL(undefined, null, SERVER_BASE_URL)
|
||||||
undefined,
|
|
||||||
null,
|
|
||||||
SERVER_BASE_URL,
|
|
||||||
DEFAULT_BASE_URL,
|
|
||||||
)
|
|
||||||
expect(result).toBe(SERVER_BASE_URL)
|
expect(result).toBe(SERVER_BASE_URL)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("edge cases", () => {
|
describe("edge cases", () => {
|
||||||
it("handles empty string apiKey as falsy (uses server config)", () => {
|
it("handles empty string apiKey as falsy (uses server config)", () => {
|
||||||
const result = resolveBaseURL(
|
const result = resolveBaseURL("", null, SERVER_BASE_URL)
|
||||||
"",
|
|
||||||
null,
|
|
||||||
SERVER_BASE_URL,
|
|
||||||
DEFAULT_BASE_URL,
|
|
||||||
)
|
|
||||||
// Empty string is falsy, so should use server config
|
// Empty string is falsy, so should use server config
|
||||||
expect(result).toBe(SERVER_BASE_URL)
|
expect(result).toBe(SERVER_BASE_URL)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("handles empty string baseUrl as falsy", () => {
|
it("handles empty string baseUrl as falsy", () => {
|
||||||
const result = resolveBaseURL(
|
const result = resolveBaseURL(USER_API_KEY, "", SERVER_BASE_URL)
|
||||||
USER_API_KEY,
|
// Empty string baseUrl is falsy; a user's key never goes to the
|
||||||
"",
|
// server's URL, so nothing is left
|
||||||
SERVER_BASE_URL,
|
expect(result).toBeUndefined()
|
||||||
DEFAULT_BASE_URL,
|
|
||||||
)
|
|
||||||
// Empty string baseUrl is falsy, should fall back to default
|
|
||||||
expect(result).toBe(DEFAULT_BASE_URL)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { describe, expect, it } from "vitest"
|
|||||||
import {
|
import {
|
||||||
dropInvalidToolCalls,
|
dropInvalidToolCalls,
|
||||||
fixToolInputJson,
|
fixToolInputJson,
|
||||||
replaceHistoricalToolInputs,
|
|
||||||
validateFileParts,
|
validateFileParts,
|
||||||
} from "@/lib/chat-helpers"
|
} from "@/lib/chat-helpers"
|
||||||
|
|
||||||
@@ -94,91 +93,6 @@ describe("validateFileParts", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("replaceHistoricalToolInputs", () => {
|
|
||||||
it("replaces display_diagram tool inputs with placeholder", () => {
|
|
||||||
const messages = [
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "tool-call",
|
|
||||||
toolName: "display_diagram",
|
|
||||||
input: { xml: "<mxCell...>" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const result = replaceHistoricalToolInputs(messages)
|
|
||||||
expect(result[0].content[0].input.placeholder).toContain(
|
|
||||||
"XML content replaced",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("replaces edit_diagram tool inputs with placeholder", () => {
|
|
||||||
const messages = [
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "tool-call",
|
|
||||||
toolName: "edit_diagram",
|
|
||||||
input: { operations: [] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const result = replaceHistoricalToolInputs(messages)
|
|
||||||
expect(result[0].content[0].input.placeholder).toContain(
|
|
||||||
"XML content replaced",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("leaves tool calls with invalid inputs for dropInvalidToolCalls", () => {
|
|
||||||
const messages = [
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "tool-call",
|
|
||||||
toolName: "display_diagram",
|
|
||||||
input: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "tool-call",
|
|
||||||
toolName: "display_diagram",
|
|
||||||
input: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const result = replaceHistoricalToolInputs(messages)
|
|
||||||
expect(result[0].content).toEqual(messages[0].content)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("preserves non-assistant messages", () => {
|
|
||||||
const messages = [{ role: "user", content: "hello" }]
|
|
||||||
const result = replaceHistoricalToolInputs(messages)
|
|
||||||
expect(result).toEqual(messages)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("preserves other tool calls", () => {
|
|
||||||
const messages = [
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "tool-call",
|
|
||||||
toolName: "other_tool",
|
|
||||||
input: { foo: "bar" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const result = replaceHistoricalToolInputs(messages)
|
|
||||||
expect(result[0].content[0].input).toEqual({ foo: "bar" })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("dropInvalidToolCalls", () => {
|
describe("dropInvalidToolCalls", () => {
|
||||||
it("drops an invalid tool-call together with its tool-result", () => {
|
it("drops an invalid tool-call together with its tool-result", () => {
|
||||||
const messages = [
|
const messages = [
|
||||||
|
|||||||
@@ -1,22 +1,37 @@
|
|||||||
// @vitest-environment node
|
// @vitest-environment node
|
||||||
import { describe, expect, it } from "vitest"
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
import { onRequest } from "@/edge-functions/api/edgeai/chat/completions"
|
import { onRequest } from "@/edge-functions/api/edgeai/chat/completions"
|
||||||
|
|
||||||
|
// EdgeOne's global AI object, answering with an empty stream
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("AI", {
|
||||||
|
chatCompletions: async () => new ReadableStream(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
function request(headers: Record<string, string>): Request {
|
function request(headers: Record<string, string>): Request {
|
||||||
return new Request("http://localhost/api/edgeai/chat/completions", {
|
return new Request("http://localhost/api/edgeai/chat/completions", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
// Non-streaming requests return a mock reply without calling AI
|
|
||||||
body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }),
|
body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = { "Content-Type": "application/json" }
|
const json = { "Content-Type": "application/json" }
|
||||||
|
|
||||||
|
// An accepted request streams the model's reply
|
||||||
|
function expectStream(res: Response) {
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.headers.get("content-type")).toContain("text/event-stream")
|
||||||
|
}
|
||||||
|
|
||||||
describe("EdgeOne chat completions function", () => {
|
describe("EdgeOne chat completions function", () => {
|
||||||
it("sends no CORS headers", async () => {
|
it("sends no CORS headers", async () => {
|
||||||
const res = await onRequest({ request: request(json), env: {} })
|
const res = await onRequest({ request: request(json), env: {} })
|
||||||
expect(res.status).toBe(200)
|
expectStream(res)
|
||||||
expect(res.headers.get("access-control-allow-origin")).toBeNull()
|
expect(res.headers.get("access-control-allow-origin")).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -40,7 +55,7 @@ describe("EdgeOne chat completions function", () => {
|
|||||||
}),
|
}),
|
||||||
env: {},
|
env: {},
|
||||||
})
|
})
|
||||||
expect(withCharset.status).toBe(200)
|
expectStream(withCharset)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("checks the access code when ACCESS_CODE_LIST is set", async () => {
|
it("checks the access code when ACCESS_CODE_LIST is set", async () => {
|
||||||
@@ -52,11 +67,11 @@ describe("EdgeOne chat completions function", () => {
|
|||||||
request: request({ ...json, "x-access-code": "secret" }),
|
request: request({ ...json, "x-access-code": "secret" }),
|
||||||
env,
|
env,
|
||||||
})
|
})
|
||||||
expect(ok.status).toBe(200)
|
expectStream(ok)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("lets requests through when env is unavailable", async () => {
|
it("lets requests through when env is unavailable", async () => {
|
||||||
const res = await onRequest({ request: request(json) })
|
const res = await onRequest({ request: request(json) })
|
||||||
expect(res.status).toBe(200)
|
expectStream(res)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
import { describe, expect, it, vi } from "vitest"
|
import { describe, expect, it, vi } from "vitest"
|
||||||
import { POST as logSave } from "@/app/api/log-save/route"
|
import { POST as logSave } from "@/app/api/log-save/route"
|
||||||
|
|
||||||
// A Langfuse client with a trace to attach the save score to
|
// A Langfuse client with a trace to attach the save score to; the real
|
||||||
|
// scoreLatestTrace runs against it
|
||||||
const batch = vi.fn(async () => ({}))
|
const batch = vi.fn(async () => ({}))
|
||||||
vi.mock("@/lib/langfuse", () => ({
|
vi.mock("@/lib/langfuse", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("@/lib/langfuse")>()),
|
||||||
getLangfuseClient: () => ({
|
getLangfuseClient: () => ({
|
||||||
api: {
|
api: {
|
||||||
trace: { list: async () => ({ data: [{ id: "trace-1" }] }) },
|
trace: { list: async () => ({ data: [{ id: "trace-1" }] }) },
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
import {
|
import {
|
||||||
DEFAULT_MAX_OUTPUT_TOKENS,
|
DEFAULT_MAX_OUTPUT_TOKENS,
|
||||||
parseOutputTokenLimit,
|
readCeiling,
|
||||||
|
rejectionText,
|
||||||
resolveMaxOutputTokens,
|
resolveMaxOutputTokens,
|
||||||
retryOutputTokens,
|
retryOutputTokens,
|
||||||
withOutputTokenLimitFallback,
|
withOutputTokenLimitFallback,
|
||||||
} from "@/lib/output-token-limit"
|
} from "@/lib/output-token-limit"
|
||||||
|
|
||||||
describe("parseOutputTokenLimit", () => {
|
// The ceiling a rejection names, before the "too small to retry" check
|
||||||
|
const parse = (error: unknown) => {
|
||||||
|
const text = rejectionText(error)
|
||||||
|
return text ? readCeiling(text) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("readCeiling", () => {
|
||||||
it("reads the ceiling from a Bedrock rejection", () => {
|
it("reads the ceiling from a Bedrock rejection", () => {
|
||||||
const error = {
|
const error = {
|
||||||
message:
|
message:
|
||||||
"The maximum tokens you requested exceeds the model limit of 4096. Try again with a maximum tokens value that is lower than 4096.",
|
"The maximum tokens you requested exceeds the model limit of 4096. Try again with a maximum tokens value that is lower than 4096.",
|
||||||
}
|
}
|
||||||
expect(parseOutputTokenLimit(error)).toBe(4096)
|
expect(parse(error)).toBe(4096)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("subtracts the input when the ceiling covers input plus output", () => {
|
it("subtracts the input when the ceiling covers input plus output", () => {
|
||||||
@@ -22,7 +29,7 @@ describe("parseOutputTokenLimit", () => {
|
|||||||
"This endpoint's maximum context length is 64000 tokens. However, you requested about 64025 tokens (25 of text input, 64000 in the output).",
|
"This endpoint's maximum context length is 64000 tokens. However, you requested about 64025 tokens (25 of text input, 64000 in the output).",
|
||||||
}
|
}
|
||||||
// 64000 - 25 - 1024 margin
|
// 64000 - 25 - 1024 margin
|
||||||
expect(parseOutputTokenLimit(error)).toBe(62951)
|
expect(parse(error)).toBe(62951)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("reads the ceiling from an Anthropic rejection", () => {
|
it("reads the ceiling from an Anthropic rejection", () => {
|
||||||
@@ -30,7 +37,7 @@ describe("parseOutputTokenLimit", () => {
|
|||||||
message:
|
message:
|
||||||
"max_tokens: 200000 > 64000, which is the maximum allowed number of output tokens for claude-sonnet-4-5",
|
"max_tokens: 200000 > 64000, which is the maximum allowed number of output tokens for claude-sonnet-4-5",
|
||||||
}
|
}
|
||||||
expect(parseOutputTokenLimit(error)).toBe(64000)
|
expect(parse(error)).toBe(64000)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("reads the ceiling from an OpenAI rejection", () => {
|
it("reads the ceiling from an OpenAI rejection", () => {
|
||||||
@@ -38,7 +45,7 @@ describe("parseOutputTokenLimit", () => {
|
|||||||
message:
|
message:
|
||||||
"max_tokens is too large: 64000. This model supports at most 16384 completion tokens",
|
"max_tokens is too large: 64000. This model supports at most 16384 completion tokens",
|
||||||
}
|
}
|
||||||
expect(parseOutputTokenLimit(error)).toBe(16384)
|
expect(parse(error)).toBe(16384)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("looks in the response body too", () => {
|
it("looks in the response body too", () => {
|
||||||
@@ -46,25 +53,25 @@ describe("parseOutputTokenLimit", () => {
|
|||||||
message: "Bad request",
|
message: "Bad request",
|
||||||
responseBody: '{"message":"exceeds the model limit of 10000."}',
|
responseBody: '{"message":"exceeds the model limit of 10000."}',
|
||||||
}
|
}
|
||||||
expect(parseOutputTokenLimit(error)).toBe(10000)
|
expect(parse(error)).toBe(10000)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("returns null for unrelated errors", () => {
|
it("returns null for unrelated errors", () => {
|
||||||
expect(parseOutputTokenLimit({ message: "Invalid API key" })).toBeNull()
|
expect(parse({ message: "Invalid API key" })).toBeNull()
|
||||||
expect(parseOutputTokenLimit(undefined)).toBeNull()
|
expect(parse(undefined)).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("ignores a number that is not about tokens", () => {
|
it("ignores a number that is not about tokens", () => {
|
||||||
// An earlier draft matched "lower than N" generically, which turned any
|
// An earlier draft matched "lower than N" generically, which turned any
|
||||||
// message shaped like this into a bogus budget
|
// message shaped like this into a bogus budget
|
||||||
expect(
|
expect(
|
||||||
parseOutputTokenLimit({
|
parse({
|
||||||
message: "temperature must be lower than 2",
|
message: "temperature must be lower than 2",
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
}),
|
}),
|
||||||
).toBeNull()
|
).toBeNull()
|
||||||
expect(
|
expect(
|
||||||
parseOutputTokenLimit({
|
parse({
|
||||||
message: "reduce requests to lower than 60 per minute",
|
message: "reduce requests to lower than 60 per minute",
|
||||||
statusCode: 429,
|
statusCode: 429,
|
||||||
}),
|
}),
|
||||||
@@ -76,28 +83,26 @@ describe("parseOutputTokenLimit", () => {
|
|||||||
message: "exceeds the model limit of 4096",
|
message: "exceeds the model limit of 4096",
|
||||||
statusCode: 429,
|
statusCode: 429,
|
||||||
}
|
}
|
||||||
expect(parseOutputTokenLimit(error)).toBeNull()
|
expect(parse(error)).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("rejects a ceiling too small to hold a diagram", () => {
|
it("reads a ceiling too small to hold a diagram as it is", () => {
|
||||||
expect(
|
// retryOutputTokens is what refuses to retry on these
|
||||||
parseOutputTokenLimit({ message: "model limit of 200" }),
|
expect(parse({ message: "model limit of 200" })).toBe(200)
|
||||||
).toBeNull()
|
|
||||||
// Context ceiling that leaves almost nothing after the input
|
// Context ceiling that leaves almost nothing after the input
|
||||||
expect(
|
expect(
|
||||||
parseOutputTokenLimit({
|
parse({
|
||||||
message:
|
message:
|
||||||
"This endpoint's maximum context length is 64000 tokens. However, you requested about 128000 tokens (63500 of text input, 64000 in the output).",
|
"This endpoint's maximum context length is 64000 tokens. However, you requested about 128000 tokens (63500 of text input, 64000 in the output).",
|
||||||
}),
|
}),
|
||||||
).toBeNull()
|
).toBe(64000 - 63500 - 1024)
|
||||||
})
|
// The input alone fills the context
|
||||||
|
expect(
|
||||||
it("returns null when the input alone fills the context", () => {
|
parse({
|
||||||
const error = {
|
message:
|
||||||
message:
|
"This endpoint's maximum context length is 1000 tokens. However, you requested about 65000 tokens (64000 of text input, 1000 in the output).",
|
||||||
"This endpoint's maximum context length is 1000 tokens. However, you requested about 65000 tokens (64000 of text input, 1000 in the output).",
|
}),
|
||||||
}
|
).toBe(1000 - 64000 - 1024)
|
||||||
expect(parseOutputTokenLimit(error)).toBeNull()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("reads the ceiling from a Volcengine Ark rejection", () => {
|
it("reads the ceiling from a Volcengine Ark rejection", () => {
|
||||||
@@ -106,10 +111,10 @@ describe("parseOutputTokenLimit", () => {
|
|||||||
"The parameter `max_tokens` specified in the request are not valid: integer above maximum value, expected a value <= 32768, but got 64000 instead.",
|
"The parameter `max_tokens` specified in the request are not valid: integer above maximum value, expected a value <= 32768, but got 64000 instead.",
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
}
|
}
|
||||||
expect(parseOutputTokenLimit(error)).toBe(32768)
|
expect(parse(error)).toBe(32768)
|
||||||
// Same message JSON-escaped in the response body
|
// Same message JSON-escaped in the response body
|
||||||
expect(
|
expect(
|
||||||
parseOutputTokenLimit({
|
parse({
|
||||||
message: "Bad request",
|
message: "Bad request",
|
||||||
responseBody:
|
responseBody:
|
||||||
'{"error":{"message":"The parameter `max_tokens` specified in the request are not valid: integer above maximum value, expected a value \\u003c= 16384, but got 64000 instead."}}',
|
'{"error":{"message":"The parameter `max_tokens` specified in the request are not valid: integer above maximum value, expected a value \\u003c= 16384, but got 64000 instead."}}',
|
||||||
@@ -122,27 +127,27 @@ describe("parseOutputTokenLimit", () => {
|
|||||||
message:
|
message:
|
||||||
"<400> InternalError.Algo.InvalidParameter: Range of max_tokens should be [1, 8192]",
|
"<400> InternalError.Algo.InvalidParameter: Range of max_tokens should be [1, 8192]",
|
||||||
}
|
}
|
||||||
expect(parseOutputTokenLimit(error)).toBe(8192)
|
expect(parse(error)).toBe(8192)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("subtracts the input in SGLang and vLLM context rejections", () => {
|
it("subtracts the input in SGLang and vLLM context rejections", () => {
|
||||||
// SGLang
|
// SGLang
|
||||||
expect(
|
expect(
|
||||||
parseOutputTokenLimit({
|
parse({
|
||||||
message:
|
message:
|
||||||
"Requested token count exceeds the model's maximum context length of 32768 tokens. You requested a total of 70000 tokens: 6000 tokens from the input messages and 64000 tokens for the completion.",
|
"Requested token count exceeds the model's maximum context length of 32768 tokens. You requested a total of 70000 tokens: 6000 tokens from the input messages and 64000 tokens for the completion.",
|
||||||
}),
|
}),
|
||||||
).toBe(32768 - 6000 - 1024)
|
).toBe(32768 - 6000 - 1024)
|
||||||
// vLLM, older wording
|
// vLLM, older wording
|
||||||
expect(
|
expect(
|
||||||
parseOutputTokenLimit({
|
parse({
|
||||||
message:
|
message:
|
||||||
"This model's maximum context length is 32768 tokens. However, you requested 70000 tokens (6000 in the messages, 64000 in the completion).",
|
"This model's maximum context length is 32768 tokens. However, you requested 70000 tokens (6000 in the messages, 64000 in the completion).",
|
||||||
}),
|
}),
|
||||||
).toBe(32768 - 6000 - 1024)
|
).toBe(32768 - 6000 - 1024)
|
||||||
// vLLM, newer wording
|
// vLLM, newer wording
|
||||||
expect(
|
expect(
|
||||||
parseOutputTokenLimit({
|
parse({
|
||||||
message:
|
message:
|
||||||
"This model's maximum context length is 32768 tokens and your request has 6000 input tokens (64000 > 32768 - 6000).",
|
"This model's maximum context length is 32768 tokens and your request has 6000 input tokens (64000 > 32768 - 6000).",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -257,17 +257,37 @@ describe("testing EdgeOne", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("the admin Test of the server's own base URL", () => {
|
describe("the admin Test of the server's own base URL", () => {
|
||||||
const test = (headers: Record<string, string>) =>
|
// An admin panel entry without a URL of its own
|
||||||
|
const adminTest = () =>
|
||||||
|
testModel(
|
||||||
|
new Request("http://localhost/api/admin/test-model", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-admin-password": "admin-pw",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
provider: {
|
||||||
|
id: "p1",
|
||||||
|
provider: "openai",
|
||||||
|
apiKey: "panel-key",
|
||||||
|
models: ["gpt-5.5"],
|
||||||
|
},
|
||||||
|
modelId: "gpt-5.5",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
// A user naming the same address in the Settings dialog
|
||||||
|
const userTest = () =>
|
||||||
validateModel(
|
validateModel(
|
||||||
new Request("http://localhost/api/validate-model", {
|
new Request("http://localhost/api/validate-model", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", ...headers },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
provider: "openai",
|
provider: "openai",
|
||||||
apiKey: "panel-key",
|
apiKey: "panel-key",
|
||||||
modelId: "gpt-5.5",
|
modelId: "gpt-5.5",
|
||||||
baseUrl: "http://10.0.0.5:8000/v1",
|
baseUrl: "http://10.0.0.5:8000/v1",
|
||||||
serverBaseUrl: true,
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -280,12 +300,10 @@ describe("the admin Test of the server's own base URL", () => {
|
|||||||
privateUrls.all = true
|
privateUrls.all = true
|
||||||
try {
|
try {
|
||||||
streamReply({ role: "assistant", content: "OK" })
|
streamReply({ role: "assistant", content: "OK" })
|
||||||
const admin = await (
|
const admin = await (await adminTest()).json()
|
||||||
await test({ "x-admin-password": "admin-pw" })
|
|
||||||
).json()
|
|
||||||
expect(admin.valid).toBe(true)
|
expect(admin.valid).toBe(true)
|
||||||
// Anyone else claiming it is still refused
|
// A user typing the same address is still refused
|
||||||
const other = await test({})
|
const other = await userTest()
|
||||||
expect(other.status).toBe(400)
|
expect(other.status).toBe(400)
|
||||||
} finally {
|
} finally {
|
||||||
privateUrls.all = false
|
privateUrls.all = false
|
||||||
|
|||||||
Reference in New Issue
Block a user