2025-12-30 21:13:22 +08:00
|
|
|
/**
|
|
|
|
|
* EdgeOne Pages Edge Function for OpenAI-compatible Chat Completions API
|
|
|
|
|
*
|
|
|
|
|
* This endpoint provides an OpenAI-compatible API that can be used with
|
|
|
|
|
* AI SDK's createOpenAI({ baseURL: '/api/edgeai' })
|
|
|
|
|
*
|
|
|
|
|
* Uses EdgeOne Edge AI's AI.chatCompletions() which now supports native tool calling.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { z } from "zod"
|
|
|
|
|
|
|
|
|
|
// EdgeOne Pages global AI object
|
|
|
|
|
declare const AI: {
|
|
|
|
|
chatCompletions(options: {
|
|
|
|
|
model: string
|
|
|
|
|
messages: Array<{ role: string; content: string | null }>
|
|
|
|
|
stream?: boolean
|
|
|
|
|
max_tokens?: number
|
|
|
|
|
temperature?: number
|
|
|
|
|
tools?: any
|
|
|
|
|
tool_choice?: any
|
|
|
|
|
}): Promise<ReadableStream<Uint8Array> | any>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const messageItemSchema = z
|
|
|
|
|
.object({
|
|
|
|
|
role: z.enum(["user", "assistant", "system", "tool", "function"]),
|
|
|
|
|
content: z.string().nullable().optional(),
|
|
|
|
|
})
|
|
|
|
|
.passthrough()
|
|
|
|
|
|
|
|
|
|
const messageSchema = z
|
|
|
|
|
.object({
|
|
|
|
|
messages: z.array(messageItemSchema),
|
|
|
|
|
model: z.string().optional(),
|
|
|
|
|
stream: z.boolean().optional(),
|
|
|
|
|
tools: 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()
|
|
|
|
|
|
|
|
|
|
// Model configuration
|
|
|
|
|
const ALLOWED_MODELS = [
|
|
|
|
|
"@tx/deepseek-ai/deepseek-v32",
|
|
|
|
|
"@tx/deepseek-ai/deepseek-r1-0528",
|
|
|
|
|
"@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",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const CORS_HEADERS = {
|
|
|
|
|
"Access-Control-Allow-Origin": "*",
|
|
|
|
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
|
|
|
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create standardized response with CORS headers
|
|
|
|
|
*/
|
|
|
|
|
function createResponse(body: any, status = 200, extraHeaders = {}): Response {
|
|
|
|
|
return new Response(JSON.stringify(body), {
|
|
|
|
|
status,
|
|
|
|
|
headers: {
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
...CORS_HEADERS,
|
|
|
|
|
...extraHeaders,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle OPTIONS request for CORS preflight
|
|
|
|
|
*/
|
|
|
|
|
function handleOptionsRequest(): Response {
|
|
|
|
|
return new Response(null, {
|
|
|
|
|
headers: {
|
|
|
|
|
...CORS_HEADERS,
|
|
|
|
|
"Access-Control-Max-Age": "86400",
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
test: add Vitest and Playwright testing infrastructure (#512)
* test: add Vitest and Playwright testing infrastructure
- Add Vitest for unit tests (39 tests)
- cached-responses.test.ts
- ai-providers.test.ts
- chat-helpers.test.ts
- utils.test.ts
- Add Playwright for E2E tests (3 smoke tests)
- Homepage load
- Japanese locale
- Settings dialog
- Add CI workflow (.github/workflows/test.yml)
- Add vitest.config.mts and playwright.config.ts
- Update .gitignore for test artifacts
* test: add more E2E tests for UI components
- Chat panel tests (interactive elements, iframe)
- Settings tests (dark mode, language, draw.io theme)
- Save dialog tests (buttons exist)
- History dialog tests
- Model config tests
- Keyboard interaction tests
- Upload area tests
Total: 15 E2E tests, all passing
* test: fix E2E test issues from review
Fixes based on Gemini and Codex review:
- Remove brittle nth(1) selector in keyboard tests
- Remove waitForTimeout(500) race condition
- Remove if(isVisible) silent skip patterns
- Add proper assertions instead of no-op checks
- Remove expect(count >= 0) that always passes
- Remove unused hasProviderUI variable
All 14 E2E tests and 39 unit tests pass.
* style: auto-format with Biome
* fix: resolve lint errors for CI
* test(e2e): add diagram generation tests with mocked AI responses
- Add tests for generate, edit, and append diagram operations
- Use SSE mocked responses matching AI SDK UI message stream format
- Generate mxCell XML directly in tests for deterministic assertions
- Tests verify tool card rendering and 'Complete' badge state
* test: add comprehensive E2E tests for all major features
- Error handling tests (API errors, rate limits, network timeout, truncated XML)
- Multi-turn conversation tests (sequential requests, history preservation)
- File upload tests (upload button, file preview, sending with message)
- Theme switching tests (dark mode toggle, persistence, system preference)
- Language switching tests (EN/JA/ZH, persistence, locale URLs)
- Iframe interaction tests (draw.io loading, toolbar, diagram rendering)
- Copy/paste tests (chat input, XML input, special characters)
- History restore tests (new chat, persistence, browser navigation)
* refactor: extract shared test helpers and improve error assertions
- Create tests/e2e/lib/helpers.ts with shared SSE mock functions
- Add proper error UI assertions to error-handling.spec.ts
- Remove waitForTimeout calls in favor of real assertions
- Update 6 test files to use shared helpers
* docs: add testing section to CONTRIBUTING.md
* fix: improve test infrastructure based on PR review
- Fix double build in CI: remove redundant build from playwright webServer
- Export chat helpers from shared module for proper unit testing
- Replace waitForTimeout with explicit waits in E2E tests
- Add data-testid attributes to settings and new chat buttons
- Add list reporter for CI to show failures in logs
- Add Playwright browser caching to speed up CI
- Add vitest coverage configuration
- Fix conditional test assertions to use test.skip() instead of silent pass
- Remove unused variables flagged by linter
* fix: improve E2E test assertions and remove silent skips
- Replace silent test.skip() with explicit conditional skips
- Add actual persistence assertion after page reload
- Use data-testid selector for new chat button test
* refactor: add shared fixtures and test.step() patterns
- Add tests/e2e/lib/fixtures.ts with shared test helpers
- Add tests/e2e/fixtures/diagrams.ts with XML test data
- Add expectBeforeAndAfterReload() helper for persistence tests
- Add test.step() for better test reporting in complex tests
- Consolidate mock helpers into fixtures module
- Reduce code duplication across 17 test files
* fix: make persistence tests more reliable
- Remove expectBeforeAndAfterReload from mocked API tests
- Add explicit test.step() for before/after reload checks
- Add retry config for flaky clipboard tests
- Add sleep after reload for language persistence test
* test: remove flaky XML paste test
* docs: run both unit and e2e tests before PR
* chore: add type check and unit test git hooks
---------
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-01-05 01:37:32 +09:00
|
|
|
export async function onRequest({ request, env: _env }: any) {
|
2025-12-30 21:13:22 +08:00
|
|
|
if (request.method === "OPTIONS") {
|
|
|
|
|
return handleOptionsRequest()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
request.headers.delete("accept-encoding")
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const json = await request.clone().json()
|
|
|
|
|
const parseResult = messageSchema.safeParse(json)
|
|
|
|
|
|
|
|
|
|
if (!parseResult.success) {
|
|
|
|
|
return createResponse(
|
|
|
|
|
{
|
|
|
|
|
error: {
|
|
|
|
|
message: parseResult.error.message,
|
|
|
|
|
type: "invalid_request_error",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
400,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { messages, model, stream, tools, tool_choice, ...extraParams } =
|
|
|
|
|
parseResult.data
|
|
|
|
|
|
|
|
|
|
// Validate messages
|
|
|
|
|
const userMessages = messages.filter(
|
|
|
|
|
(message) => message.role === "user",
|
|
|
|
|
)
|
|
|
|
|
if (!userMessages.length) {
|
|
|
|
|
return createResponse(
|
|
|
|
|
{
|
|
|
|
|
error: {
|
|
|
|
|
message: "No user message found",
|
|
|
|
|
type: "invalid_request_error",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
400,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Resolve model
|
|
|
|
|
const requestedModel = model || ALLOWED_MODELS[0]
|
|
|
|
|
const selectedModel = MODEL_ALIASES[requestedModel] || requestedModel
|
|
|
|
|
|
|
|
|
|
if (!ALLOWED_MODELS.includes(selectedModel)) {
|
|
|
|
|
return createResponse(
|
|
|
|
|
{
|
|
|
|
|
error: {
|
|
|
|
|
message: `Invalid model: ${requestedModel}.`,
|
|
|
|
|
type: "invalid_request_error",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
429,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(
|
|
|
|
|
`[EdgeOne] Model: ${selectedModel}, Tools: ${tools?.length || 0}, Stream: ${stream ?? true}`,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
const aiOptions: any = {
|
|
|
|
|
...extraParams,
|
|
|
|
|
model: selectedModel,
|
|
|
|
|
messages,
|
|
|
|
|
stream: true,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add tools if provided
|
|
|
|
|
if (tools && tools.length > 0) {
|
|
|
|
|
aiOptions.tools = tools
|
|
|
|
|
}
|
|
|
|
|
if (tool_choice !== undefined) {
|
|
|
|
|
aiOptions.tool_choice = tool_choice
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const aiResponse = await AI.chatCompletions(aiOptions)
|
|
|
|
|
|
|
|
|
|
// Streaming response
|
|
|
|
|
return new Response(aiResponse, {
|
|
|
|
|
headers: {
|
|
|
|
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
|
|
|
"Cache-Control": "no-cache, no-store, no-transform",
|
|
|
|
|
"X-Accel-Buffering": "no",
|
|
|
|
|
Connection: "keep-alive",
|
|
|
|
|
...CORS_HEADERS,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
// Handle EdgeOne specific errors
|
|
|
|
|
try {
|
|
|
|
|
const message = JSON.parse(error.message)
|
|
|
|
|
if (message.code === 14020) {
|
|
|
|
|
return createResponse(
|
|
|
|
|
{
|
|
|
|
|
error: {
|
|
|
|
|
message:
|
|
|
|
|
"The daily public quota has been exhausted. After deployment, you can enjoy a personal daily exclusive quota.",
|
|
|
|
|
type: "rate_limit_error",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
429,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
return createResponse(
|
|
|
|
|
{ error: { message: error.message, type: "api_error" } },
|
|
|
|
|
500,
|
|
|
|
|
)
|
|
|
|
|
} catch {
|
|
|
|
|
// Not a JSON error message
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.error("[EdgeOne] AI error:", error.message)
|
|
|
|
|
return createResponse(
|
|
|
|
|
{
|
|
|
|
|
error: {
|
|
|
|
|
message: error.message || "AI service error",
|
|
|
|
|
type: "api_error",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
500,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
console.error("[EdgeOne] Request error:", error.message)
|
|
|
|
|
return createResponse(
|
|
|
|
|
{
|
|
|
|
|
error: {
|
|
|
|
|
message: "Request processing failed",
|
|
|
|
|
type: "server_error",
|
|
|
|
|
details: error.message,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
500,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|