|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| declare namespace Deno { |
| interface Conn { |
| readonly rid: number; |
| localAddr: Addr; |
| remoteAddr: Addr; |
| read(p: Uint8Array): Promise<number | null>; |
| write(p: Uint8Array): Promise<number>; |
| close(): void; |
| } |
| |
| interface Addr { |
| hostname: string; |
| port: number; |
| transport: string; |
| } |
| |
| interface Listener extends AsyncIterable<Conn> { |
| readonly addr: Addr; |
| accept(): Promise<Conn>; |
| close(): void; |
| [Symbol.asyncIterator](): AsyncIterableIterator<Conn>; |
| } |
| |
| interface HttpConn { |
| nextRequest(): Promise<RequestEvent | null>; |
| [Symbol.asyncIterator](): AsyncIterableIterator<RequestEvent>; |
| } |
| |
| interface RequestEvent { |
| request: Request; |
| respondWith(r: Response | Promise<Response>): Promise<void>; |
| } |
| |
| function listen(options: { port: number }): Listener; |
| function serveHttp(conn: Conn): HttpConn; |
| function serve(handler: (request: Request) => Promise<Response>): void; |
| |
| namespace env { |
| function get(key: string): string | undefined; |
| } |
| } |
|
|
| |
| |
| |
| |
| interface RequestStats { |
| totalRequests: number; |
| successfulRequests: number; |
| failedRequests: number; |
| lastRequestTime: Date; |
| averageResponseTime: number; |
| } |
|
|
| |
| |
| |
| |
| interface LiveRequest { |
| id: string; |
| timestamp: Date; |
| method: string; |
| path: string; |
| status: number; |
| duration: number; |
| userAgent: string; |
| model?: string; |
| } |
|
|
| |
| |
| |
| |
| interface OpenAIRequest { |
| model: string; |
| messages: Message[]; |
| stream?: boolean; |
| temperature?: number; |
| max_tokens?: number; |
| } |
|
|
| |
| |
| |
| |
| interface Message { |
| role: string; |
| content: string | Array<{ |
| type: string; |
| text?: string; |
| image_url?: {url: string}; |
| video_url?: {url: string}; |
| document_url?: {url: string}; |
| audio_url?: {url: string}; |
| }>; |
| } |
|
|
| |
| |
| |
| |
| interface UpstreamRequest { |
| stream: boolean; |
| model: string; |
| messages: Message[]; |
| params: Record<string, unknown>; |
| features: Record<string, unknown>; |
| background_tasks?: Record<string, boolean>; |
| chat_id?: string; |
| id?: string; |
| mcp_servers?: string[]; |
| model_item?: { |
| id: string; |
| name: string; |
| owned_by: string; |
| openai?: any; |
| urlIdx?: number; |
| info?: any; |
| actions?: any[]; |
| tags?: any[]; |
| }; |
| tool_servers?: string[]; |
| variables?: Record<string, string>; |
| } |
|
|
| |
| |
| |
| interface OpenAIResponse { |
| id: string; |
| object: string; |
| created: number; |
| model: string; |
| choices: Choice[]; |
| usage?: Usage; |
| } |
|
|
| interface Choice { |
| index: number; |
| message?: Message; |
| delta?: Delta; |
| finish_reason?: string; |
| } |
|
|
| interface Delta { |
| role?: string; |
| content?: string; |
| } |
|
|
| interface Usage { |
| prompt_tokens: number; |
| completion_tokens: number; |
| total_tokens: number; |
| } |
|
|
| |
| |
| |
| interface UpstreamData { |
| type: string; |
| data: { |
| delta_content: string; |
| phase: string; |
| done: boolean; |
| usage?: Usage; |
| error?: UpstreamError; |
| inner?: { |
| error?: UpstreamError; |
| }; |
| }; |
| error?: UpstreamError; |
| } |
|
|
| interface UpstreamError { |
| detail: string; |
| code: number; |
| } |
|
|
| interface ModelsResponse { |
| object: string; |
| data: Model[]; |
| } |
|
|
| interface Model { |
| id: string; |
| object: string; |
| created: number; |
| owned_by: string; |
| } |
|
|
| |
| |
| |
|
|
| |
| const THINK_TAGS_MODE = "strip"; |
|
|
| |
| const X_FE_VERSION = "prod-fe-1.0.70"; |
| const BROWSER_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0"; |
| const SEC_CH_UA = "\"Not;A=Brand\";v=\"99\", \"Microsoft Edge\";v=\"139\", \"Chromium\";v=\"139\""; |
| const SEC_CH_UA_MOB = "?0"; |
| const SEC_CH_UA_PLAT = "\"Windows\""; |
| const ORIGIN_BASE = "https://chat.z.ai"; |
|
|
| const ANON_TOKEN_ENABLED = true; |
|
|
| |
| |
| |
| const UPSTREAM_URL = Deno.env.get("UPSTREAM_URL") || "https://chat.z.ai/api/chat/completions"; |
| const DEFAULT_KEY = Deno.env.get("DEFAULT_KEY") || "sk-your-key"; |
| const ZAI_TOKEN = Deno.env.get("ZAI_TOKEN") || ""; |
|
|
| |
| |
| |
| interface ModelConfig { |
| id: string; |
| name: string; |
| upstreamId: string; |
| capabilities: { |
| vision: boolean; |
| mcp: boolean; |
| thinking: boolean; |
| }; |
| defaultParams: { |
| top_p: number; |
| temperature: number; |
| max_tokens?: number; |
| }; |
| } |
|
|
| const SUPPORTED_MODELS: ModelConfig[] = [ |
| { |
| id: "0727-360B-API", |
| name: "GLM-4.5", |
| upstreamId: "0727-360B-API", |
| capabilities: { |
| vision: false, |
| mcp: true, |
| thinking: true |
| }, |
| defaultParams: { |
| top_p: 0.95, |
| temperature: 0.6, |
| max_tokens: 80000 |
| } |
| }, |
| { |
| id: "glm-4.5v", |
| name: "GLM-4.5V", |
| upstreamId: "glm-4.5v", |
| capabilities: { |
| vision: true, |
| mcp: false, |
| thinking: true |
| }, |
| defaultParams: { |
| top_p: 0.6, |
| temperature: 0.8 |
| } |
| } |
| ]; |
|
|
| |
| const DEFAULT_MODEL = SUPPORTED_MODELS[0]; |
|
|
| |
| function getModelConfig(modelId: string): ModelConfig { |
| |
| const normalizedModelId = normalizeModelId(modelId); |
| const found = SUPPORTED_MODELS.find(m => m.id === normalizedModelId); |
| |
| if (!found) { |
| debugLog("⚠️ 未找到模型配置: %s (标准化后: %s),使用默认模型: %s", |
| modelId, normalizedModelId, DEFAULT_MODEL.name); |
| } |
| |
| return found || DEFAULT_MODEL; |
| } |
|
|
| |
| |
| |
| |
| function normalizeModelId(modelId: string): string { |
| const normalized = modelId.toLowerCase().trim(); |
| |
| |
| const modelMappings: Record<string, string> = { |
| 'glm-4.5v': 'glm-4.5v', |
| 'glm4.5v': 'glm-4.5v', |
| 'glm_4.5v': 'glm-4.5v', |
| 'gpt-4-vision-preview': 'glm-4.5v', |
| '0727-360b-api': '0727-360B-API', |
| 'glm-4.5': '0727-360B-API', |
| 'glm4.5': '0727-360B-API', |
| 'glm_4.5': '0727-360B-API', |
| 'gpt-4': '0727-360B-API' |
| }; |
| |
| const mapped = modelMappings[normalized]; |
| if (mapped) { |
| debugLog("🔄 模型ID映射: %s → %s", modelId, mapped); |
| return mapped; |
| } |
| |
| return normalized; |
| } |
|
|
| |
| |
| |
| |
| function processMessages(messages: Message[], modelConfig: ModelConfig): Message[] { |
| const processedMessages: Message[] = []; |
| |
| for (const message of messages) { |
| const processedMessage: Message = { ...message }; |
| |
| |
| if (Array.isArray(message.content)) { |
| debugLog("检测到多模态消息,内容块数量: %d", message.content.length); |
| |
| |
| const mediaStats = { |
| text: 0, |
| images: 0, |
| videos: 0, |
| documents: 0, |
| audios: 0, |
| others: 0 |
| }; |
| |
| |
| if (!modelConfig.capabilities.vision) { |
| debugLog("警告: 模型 %s 不支持多模态,但收到了多模态消息", modelConfig.name); |
| |
| const textContent = message.content |
| .filter(block => block.type === 'text') |
| .map(block => block.text) |
| .join('\n'); |
| processedMessage.content = textContent; |
| } else { |
| |
| for (const block of message.content) { |
| switch (block.type) { |
| case 'text': |
| if (block.text) { |
| mediaStats.text++; |
| debugLog("📝 文本内容,长度: %d", block.text.length); |
| } |
| break; |
| |
| case 'image_url': |
| if (block.image_url?.url) { |
| mediaStats.images++; |
| const url = block.image_url.url; |
| if (url.startsWith('data:image/')) { |
| const mimeMatch = url.match(/data:image\/([^;]+)/); |
| const format = mimeMatch ? mimeMatch[1] : 'unknown'; |
| debugLog("🖼️ 图像数据: %s格式, 大小: %d字符", format, url.length); |
| } else if (url.startsWith('http')) { |
| debugLog("🔗 图像URL: %s", url); |
| } else { |
| debugLog("⚠️ 未知图像格式: %s", url.substring(0, 50)); |
| } |
| } |
| break; |
| |
| case 'video_url': |
| if (block.video_url?.url) { |
| mediaStats.videos++; |
| const url = block.video_url.url; |
| if (url.startsWith('data:video/')) { |
| const mimeMatch = url.match(/data:video\/([^;]+)/); |
| const format = mimeMatch ? mimeMatch[1] : 'unknown'; |
| debugLog("🎥 视频数据: %s格式, 大小: %d字符", format, url.length); |
| } else if (url.startsWith('http')) { |
| debugLog("🔗 视频URL: %s", url); |
| } else { |
| debugLog("⚠️ 未知视频格式: %s", url.substring(0, 50)); |
| } |
| } |
| break; |
| |
| case 'document_url': |
| if (block.document_url?.url) { |
| mediaStats.documents++; |
| const url = block.document_url.url; |
| if (url.startsWith('data:application/')) { |
| const mimeMatch = url.match(/data:application\/([^;]+)/); |
| const format = mimeMatch ? mimeMatch[1] : 'unknown'; |
| debugLog("📄 文档数据: %s格式, 大小: %d字符", format, url.length); |
| } else if (url.startsWith('http')) { |
| debugLog("🔗 文档URL: %s", url); |
| } else { |
| debugLog("⚠️ 未知文档格式: %s", url.substring(0, 50)); |
| } |
| } |
| break; |
| |
| case 'audio_url': |
| if (block.audio_url?.url) { |
| mediaStats.audios++; |
| const url = block.audio_url.url; |
| if (url.startsWith('data:audio/')) { |
| const mimeMatch = url.match(/data:audio\/([^;]+)/); |
| const format = mimeMatch ? mimeMatch[1] : 'unknown'; |
| debugLog("🎵 音频数据: %s格式, 大小: %d字符", format, url.length); |
| } else if (url.startsWith('http')) { |
| debugLog("🔗 音频URL: %s", url); |
| } else { |
| debugLog("⚠️ 未知音频格式: %s", url.substring(0, 50)); |
| } |
| } |
| break; |
| |
| default: |
| mediaStats.others++; |
| debugLog("❓ 未知内容类型: %s", block.type); |
| } |
| } |
| |
| |
| const totalMedia = mediaStats.images + mediaStats.videos + mediaStats.documents + mediaStats.audios; |
| if (totalMedia > 0) { |
| debugLog("🎯 多模态内容统计: 文本(%d) 图像(%d) 视频(%d) 文档(%d) 音频(%d)", |
| mediaStats.text, mediaStats.images, mediaStats.videos, mediaStats.documents, mediaStats.audios); |
| } |
| } |
| } else if (typeof message.content === 'string') { |
| debugLog("📝 纯文本消息,长度: %d", message.content.length); |
| } |
| |
| processedMessages.push(processedMessage); |
| } |
| |
| return processedMessages; |
| } |
|
|
| const DEBUG_MODE = Deno.env.get("DEBUG_MODE") !== "false"; |
| const DEFAULT_STREAM = Deno.env.get("DEFAULT_STREAM") !== "false"; |
| const DASHBOARD_ENABLED = Deno.env.get("DASHBOARD_ENABLED") !== "false"; |
|
|
| |
| |
| |
|
|
| let stats: RequestStats = { |
| totalRequests: 0, |
| successfulRequests: 0, |
| failedRequests: 0, |
| lastRequestTime: new Date(), |
| averageResponseTime: 0 |
| }; |
|
|
| let liveRequests: LiveRequest[] = []; |
|
|
| |
| |
| |
|
|
| function debugLog(format: string, ...args: unknown[]): void { |
| if (DEBUG_MODE) { |
| console.log(`[DEBUG] ${format}`, ...args); |
| } |
| } |
|
|
| function recordRequestStats(startTime: number, path: string, status: number): void { |
| const duration = Date.now() - startTime; |
| |
| stats.totalRequests++; |
| stats.lastRequestTime = new Date(); |
| |
| if (status >= 200 && status < 300) { |
| stats.successfulRequests++; |
| } else { |
| stats.failedRequests++; |
| } |
| |
| |
| if (stats.totalRequests > 0) { |
| const totalDuration = stats.averageResponseTime * (stats.totalRequests - 1) + duration; |
| stats.averageResponseTime = totalDuration / stats.totalRequests; |
| } else { |
| stats.averageResponseTime = duration; |
| } |
| } |
|
|
| function addLiveRequest(method: string, path: string, status: number, duration: number, userAgent: string, model?: string): void { |
| const request: LiveRequest = { |
| id: Date.now().toString(), |
| timestamp: new Date(), |
| method, |
| path, |
| status, |
| duration, |
| userAgent, |
| model |
| }; |
| |
| liveRequests.push(request); |
| |
| |
| if (liveRequests.length > 100) { |
| liveRequests = liveRequests.slice(1); |
| } |
| } |
|
|
| function getLiveRequestsData(): string { |
| try { |
| |
| if (!Array.isArray(liveRequests)) { |
| debugLog("liveRequests不是数组,重置为空数组"); |
| liveRequests = []; |
| } |
| |
| |
| const requestData = liveRequests.map(req => ({ |
| id: req.id || "", |
| timestamp: req.timestamp || new Date(), |
| method: req.method || "", |
| path: req.path || "", |
| status: req.status || 0, |
| duration: req.duration || 0, |
| user_agent: req.userAgent || "" |
| })); |
| |
| return JSON.stringify(requestData); |
| } catch (error) { |
| debugLog("获取实时请求数据失败: %v", error); |
| return JSON.stringify([]); |
| } |
| } |
|
|
| function getStatsData(): string { |
| try { |
| |
| if (!stats) { |
| debugLog("stats对象不存在,使用默认值"); |
| stats = { |
| totalRequests: 0, |
| successfulRequests: 0, |
| failedRequests: 0, |
| lastRequestTime: new Date(), |
| averageResponseTime: 0 |
| }; |
| } |
| |
| |
| const statsData = { |
| totalRequests: stats.totalRequests || 0, |
| successfulRequests: stats.successfulRequests || 0, |
| failedRequests: stats.failedRequests || 0, |
| averageResponseTime: stats.averageResponseTime || 0 |
| }; |
| |
| return JSON.stringify(statsData); |
| } catch (error) { |
| debugLog("获取统计数据失败: %v", error); |
| return JSON.stringify({ |
| totalRequests: 0, |
| successfulRequests: 0, |
| failedRequests: 0, |
| averageResponseTime: 0 |
| }); |
| } |
| } |
|
|
| function getClientIP(request: Request): string { |
| |
| const xff = request.headers.get("X-Forwarded-For"); |
| if (xff) { |
| const ips = xff.split(","); |
| if (ips.length > 0) { |
| return ips[0].trim(); |
| } |
| } |
| |
| |
| const xri = request.headers.get("X-Real-IP"); |
| if (xri) { |
| return xri; |
| } |
| |
| |
| return "unknown"; |
| } |
|
|
| function setCORSHeaders(headers: Headers): void { |
| headers.set("Access-Control-Allow-Origin", "*"); |
| headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); |
| headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization"); |
| headers.set("Access-Control-Allow-Credentials", "true"); |
| } |
|
|
| function validateApiKey(authHeader: string | null): boolean { |
| if (!authHeader || !authHeader.startsWith("Bearer ")) { |
| return false; |
| } |
| |
| const apiKey = authHeader.substring(7); |
| return apiKey === DEFAULT_KEY; |
| } |
|
|
| async function getAnonymousToken(): Promise<string> { |
| try { |
| const response = await fetch(`${ORIGIN_BASE}/api/v1/auths/`, { |
| method: "GET", |
| headers: { |
| "User-Agent": BROWSER_UA, |
| "Accept": "*/*", |
| "Accept-Language": "zh-CN,zh;q=0.9", |
| "X-FE-Version": X_FE_VERSION, |
| "sec-ch-ua": SEC_CH_UA, |
| "sec-ch-ua-mobile": SEC_CH_UA_MOB, |
| "sec-ch-ua-platform": SEC_CH_UA_PLAT, |
| "Origin": ORIGIN_BASE, |
| "Referer": `${ORIGIN_BASE}/` |
| } |
| }); |
| |
| if (!response.ok) { |
| throw new Error(`Anonymous token request failed with status ${response.status}`); |
| } |
| |
| const data = await response.json() as { token: string }; |
| if (!data.token) { |
| throw new Error("Anonymous token is empty"); |
| } |
| |
| return data.token; |
| } catch (error) { |
| debugLog("获取匿名token失败: %v", error); |
| throw error; |
| } |
| } |
|
|
| |
| async function callUpstreamWithHeaders( |
| upstreamReq: UpstreamRequest, |
| refererChatID: string, |
| authToken: string |
| ): Promise<Response> { |
| try { |
| debugLog("调用上游API: %s", UPSTREAM_URL); |
| |
| |
| const hasMultimedia = upstreamReq.messages.some(msg => |
| Array.isArray(msg.content) && |
| msg.content.some(block => |
| ['image_url', 'video_url', 'document_url', 'audio_url'].includes(block.type) |
| ) |
| ); |
| |
| if (hasMultimedia) { |
| debugLog("🎯 请求包含多模态数据,正在发送到上游..."); |
| |
| for (let i = 0; i < upstreamReq.messages.length; i++) { |
| const msg = upstreamReq.messages[i]; |
| if (Array.isArray(msg.content)) { |
| for (let j = 0; j < msg.content.length; j++) { |
| const block = msg.content[j]; |
| |
| |
| if (block.type === 'image_url' && block.image_url?.url) { |
| const url = block.image_url.url; |
| if (url.startsWith('data:image/')) { |
| const mimeMatch = url.match(/data:image\/([^;]+)/); |
| const format = mimeMatch ? mimeMatch[1] : 'unknown'; |
| const sizeKB = Math.round(url.length * 0.75 / 1024); |
| debugLog("🖼️ 消息[%d] 图像[%d]: %s格式, 数据长度: %d字符 (~%dKB)", |
| i, j, format, url.length, sizeKB); |
| |
| |
| if (sizeKB > 1000) { |
| debugLog("⚠️ 图片较大 (%dKB),可能导致上游处理失败", sizeKB); |
| debugLog("💡 建议: 将图片压缩到 500KB 以下"); |
| } else if (sizeKB > 500) { |
| debugLog("⚠️ 图片偏大 (%dKB),建议压缩", sizeKB); |
| } |
| } else { |
| debugLog("🔗 消息[%d] 图像[%d]: 外部URL - %s", i, j, url); |
| } |
| } |
| |
| |
| if (block.type === 'video_url' && block.video_url?.url) { |
| const url = block.video_url.url; |
| if (url.startsWith('data:video/')) { |
| const mimeMatch = url.match(/data:video\/([^;]+)/); |
| const format = mimeMatch ? mimeMatch[1] : 'unknown'; |
| debugLog("🎥 消息[%d] 视频[%d]: %s格式, 数据长度: %d字符", |
| i, j, format, url.length); |
| } else { |
| debugLog("🔗 消息[%d] 视频[%d]: 外部URL - %s", i, j, url); |
| } |
| } |
| |
| |
| if (block.type === 'document_url' && block.document_url?.url) { |
| const url = block.document_url.url; |
| if (url.startsWith('data:application/')) { |
| const mimeMatch = url.match(/data:application\/([^;]+)/); |
| const format = mimeMatch ? mimeMatch[1] : 'unknown'; |
| debugLog("📄 消息[%d] 文档[%d]: %s格式, 数据长度: %d字符", |
| i, j, format, url.length); |
| } else { |
| debugLog("🔗 消息[%d] 文档[%d]: 外部URL - %s", i, j, url); |
| } |
| } |
| |
| |
| if (block.type === 'audio_url' && block.audio_url?.url) { |
| const url = block.audio_url.url; |
| if (url.startsWith('data:audio/')) { |
| const mimeMatch = url.match(/data:audio\/([^;]+)/); |
| const format = mimeMatch ? mimeMatch[1] : 'unknown'; |
| debugLog("🎵 消息[%d] 音频[%d]: %s格式, 数据长度: %d字符", |
| i, j, format, url.length); |
| } else { |
| debugLog("🔗 消息[%d] 音频[%d]: 外部URL - %s", i, j, url); |
| } |
| } |
| } |
| } |
| } |
| } |
| |
| debugLog("上游请求体: %s", JSON.stringify(upstreamReq)); |
| |
| const response = await fetch(UPSTREAM_URL, { |
| method: "POST", |
| headers: { |
| "Content-Type": "application/json", |
| "Accept": "application/json, text/event-stream", |
| "User-Agent": BROWSER_UA, |
| "Authorization": `Bearer ${authToken}`, |
| "Accept-Language": "zh-CN", |
| "sec-ch-ua": SEC_CH_UA, |
| "sec-ch-ua-mobile": SEC_CH_UA_MOB, |
| "sec-ch-ua-platform": SEC_CH_UA_PLAT, |
| "X-FE-Version": X_FE_VERSION, |
| "Origin": ORIGIN_BASE, |
| "Referer": `${ORIGIN_BASE}/c/${refererChatID}` |
| }, |
| body: JSON.stringify(upstreamReq) |
| }); |
| |
| debugLog("上游响应状态: %d %s", response.status, response.statusText); |
| return response; |
| } catch (error) { |
| debugLog("调用上游失败: %v", error); |
| throw error; |
| } |
| } |
|
|
| function transformThinking(content: string): string { |
| |
| let result = content.replace(/<summary>.*?<\/summary>/gs, ""); |
| |
| result = result.replace(/<\/thinking>/g, ""); |
| result = result.replace(/<Full>/g, ""); |
| result = result.replace(/<\/Full>/g, ""); |
| result = result.trim(); |
| |
| switch (THINK_TAGS_MODE as "strip" | "think" | "raw") { |
| case "think": |
| result = result.replace(/<details[^>]*>/g, "<thinking>"); |
| result = result.replace(/<\/details>/g, "</thinking>"); |
| break; |
| case "strip": |
| result = result.replace(/<details[^>]*>/g, ""); |
| result = result.replace(/<\/details>/g, ""); |
| break; |
| } |
| |
| |
| result = result.replace(/^> /, ""); |
| result = result.replace(/\n> /g, "\n"); |
| return result.trim(); |
| } |
|
|
| async function processUpstreamStream( |
| body: ReadableStream<Uint8Array>, |
| writer: WritableStreamDefaultWriter<Uint8Array>, |
| encoder: TextEncoder, |
| modelName: string |
| ): Promise<void> { |
| const reader = body.getReader(); |
| const decoder = new TextDecoder(); |
| let buffer = ""; |
| |
| try { |
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| |
| buffer += decoder.decode(value, { stream: true }); |
| const lines = buffer.split('\n'); |
| buffer = lines.pop() || ""; |
| |
| for (const line of lines) { |
| if (line.startsWith("data: ")) { |
| const dataStr = line.substring(6); |
| if (dataStr === "") continue; |
| |
| debugLog("收到SSE数据: %s", dataStr); |
| |
| try { |
| const upstreamData = JSON.parse(dataStr) as UpstreamData; |
| |
| |
| if (upstreamData.error || upstreamData.data.error || |
| (upstreamData.data.inner && upstreamData.data.inner.error)) { |
| const errObj = upstreamData.error || upstreamData.data.error || |
| (upstreamData.data.inner && upstreamData.data.inner.error); |
| debugLog("上游错误: code=%d, detail=%s", errObj?.code, errObj?.detail); |
| |
| |
| const errorDetail = (errObj?.detail || "").toLowerCase(); |
| if (errorDetail.includes("something went wrong") || errorDetail.includes("try again later")) { |
| debugLog("🚨 Z.ai 服务器错误分析:"); |
| debugLog(" 📋 错误详情: %s", errObj?.detail); |
| debugLog(" 🖼️ 可能原因: 图片处理失败"); |
| debugLog(" 💡 建议解决方案:"); |
| debugLog(" 1. 使用更小的图片 (< 500KB)"); |
| debugLog(" 2. 尝试不同的图片格式 (JPEG 而不是 PNG)"); |
| debugLog(" 3. 稍后重试 (可能是服务器负载问题)"); |
| debugLog(" 4. 检查图片是否损坏"); |
| } |
| |
| |
| const endChunk: OpenAIResponse = { |
| id: `chatcmpl-${Date.now()}`, |
| object: "chat.completion.chunk", |
| created: Math.floor(Date.now() / 1000), |
| model: modelName, |
| choices: [ |
| { |
| index: 0, |
| delta: {}, |
| finish_reason: "stop" |
| } |
| ] |
| }; |
| |
| await writer.write(encoder.encode(`data: ${JSON.stringify(endChunk)}\n\n`)); |
| await writer.write(encoder.encode("data: [DONE]\n\n")); |
| return; |
| } |
| |
| debugLog("解析成功 - 类型: %s, 阶段: %s, 内容长度: %d, 完成: %v", |
| upstreamData.type, upstreamData.data.phase, |
| upstreamData.data.delta_content ? upstreamData.data.delta_content.length : 0, |
| upstreamData.data.done); |
| |
| |
| if (upstreamData.data.delta_content && upstreamData.data.delta_content !== "") { |
| let out = upstreamData.data.delta_content; |
| if (upstreamData.data.phase === "thinking") { |
| out = transformThinking(out); |
| } |
| |
| if (out !== "") { |
| debugLog("发送内容(%s): %s", upstreamData.data.phase, out); |
| |
| const chunk: OpenAIResponse = { |
| id: `chatcmpl-${Date.now()}`, |
| object: "chat.completion.chunk", |
| created: Math.floor(Date.now() / 1000), |
| model: modelName, |
| choices: [ |
| { |
| index: 0, |
| delta: { content: out } |
| } |
| ] |
| }; |
| |
| await writer.write(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); |
| } |
| } |
| |
| |
| if (upstreamData.data.done || upstreamData.data.phase === "done") { |
| debugLog("检测到流结束信号"); |
| |
| |
| const endChunk: OpenAIResponse = { |
| id: `chatcmpl-${Date.now()}`, |
| object: "chat.completion.chunk", |
| created: Math.floor(Date.now() / 1000), |
| model: modelName, |
| choices: [ |
| { |
| index: 0, |
| delta: {}, |
| finish_reason: "stop" |
| } |
| ] |
| }; |
| |
| await writer.write(encoder.encode(`data: ${JSON.stringify(endChunk)}\n\n`)); |
| await writer.write(encoder.encode("data: [DONE]\n\n")); |
| return; |
| } |
| } catch (error) { |
| debugLog("SSE数据解析失败: %v", error); |
| } |
| } |
| } |
| } |
| } finally { |
| writer.close(); |
| } |
| } |
|
|
| |
| async function collectFullResponse(body: ReadableStream<Uint8Array>): Promise<string> { |
| const reader = body.getReader(); |
| const decoder = new TextDecoder(); |
| let buffer = ""; |
| let fullContent = ""; |
| |
| try { |
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| |
| buffer += decoder.decode(value, { stream: true }); |
| const lines = buffer.split('\n'); |
| buffer = lines.pop() || ""; |
| |
| for (const line of lines) { |
| if (line.startsWith("data: ")) { |
| const dataStr = line.substring(6); |
| if (dataStr === "") continue; |
| |
| try { |
| const upstreamData = JSON.parse(dataStr) as UpstreamData; |
| |
| if (upstreamData.data.delta_content !== "") { |
| let out = upstreamData.data.delta_content; |
| if (upstreamData.data.phase === "thinking") { |
| out = transformThinking(out); |
| } |
| |
| if (out !== "") { |
| fullContent += out; |
| } |
| } |
| |
| |
| if (upstreamData.data.done || upstreamData.data.phase === "done") { |
| debugLog("检测到完成信号,停止收集"); |
| return fullContent; |
| } |
| } catch (error) { |
| |
| } |
| } |
| } |
| } |
| } finally { |
| reader.releaseLock(); |
| } |
| |
| return fullContent; |
| } |
|
|
| |
| |
| |
|
|
| function getIndexHTML(): string { |
| return `<!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>ZtoApi - OpenAI兼容API代理</title> |
| <style> |
| body { |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
| margin: 0; |
| padding: 0; |
| background-color: #f5f5f5; |
| line-height: 1.6; |
| } |
| .container { |
| max-width: 1200px; |
| margin: 0 auto; |
| background-color: white; |
| border-radius: 8px; |
| box-shadow: 0 2px 10px rgba(0,0,0,0.1); |
| padding: 40px; |
| margin-top: 40px; |
| } |
| header { |
| text-align: center; |
| margin-bottom: 40px; |
| } |
| h1 { |
| color: #333; |
| margin-bottom: 10px; |
| font-size: 2.5rem; |
| } |
| .subtitle { |
| color: #666; |
| font-size: 1.2rem; |
| margin-bottom: 30px; |
| } |
| .links { |
| display: grid; |
| grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); |
| gap: 20px; |
| margin-top: 40px; |
| } |
| .link-card { |
| background-color: #f8f9fa; |
| border-radius: 8px; |
| padding: 20px; |
| text-align: center; |
| transition: transform 0.3s ease, box-shadow 0.3s ease; |
| border: 1px solid #e9ecef; |
| } |
| .link-card:hover { |
| transform: translateY(-5px); |
| box-shadow: 0 5px 15px rgba(0,0,0,0.1); |
| } |
| .link-card h3 { |
| margin-top: 0; |
| color: #007bff; |
| } |
| .link-card p { |
| color: #666; |
| margin-bottom: 20px; |
| } |
| .link-card a { |
| display: inline-block; |
| background-color: #007bff; |
| color: white; |
| padding: 10px 20px; |
| border-radius: 4px; |
| text-decoration: none; |
| font-weight: bold; |
| transition: background-color 0.3s ease; |
| } |
| .link-card a:hover { |
| background-color: #0056b3; |
| } |
| .features { |
| margin-top: 60px; |
| } |
| .features h2 { |
| text-align: center; |
| color: #333; |
| margin-bottom: 30px; |
| } |
| .feature-list { |
| display: grid; |
| grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); |
| gap: 20px; |
| } |
| .feature-item { |
| text-align: center; |
| padding: 20px; |
| } |
| .feature-item i { |
| font-size: 2rem; |
| color: #007bff; |
| margin-bottom: 15px; |
| } |
| .feature-item h3 { |
| color: #333; |
| margin-bottom: 10px; |
| } |
| .feature-item p { |
| color: #666; |
| } |
| footer { |
| text-align: center; |
| margin-top: 60px; |
| padding-top: 20px; |
| border-top: 1px solid #e9ecef; |
| color: #666; |
| } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <header> |
| <h1>ZtoApi</h1> |
| <div class="subtitle">OpenAI兼容API代理 for Z.ai GLM-4.5</div> |
| <p>一个高性能、易于部署的API代理服务,让你能够使用OpenAI兼容的格式访问Z.ai的GLM-4.5模型。</p> |
| </header> |
| |
| <div class="links"> |
| <div class="link-card"> |
| <h3>📖 API文档</h3> |
| <p>查看完整的API文档,了解如何使用本服务。</p> |
| <a href="/docs">查看文档</a> |
| </div> |
| |
| <div class="link-card"> |
| <h3>📊 API调用看板</h3> |
| <p>实时监控API调用情况,查看请求统计和性能指标。</p> |
| <a href="/dashboard">查看看板</a> |
| </div> |
| |
| <div class="link-card"> |
| <h3>🤖 模型列表</h3> |
| <p>查看可用的AI模型列表及其详细信息。</p> |
| <a href="/v1/models">查看模型</a> |
| </div> |
| </div> |
| |
| <div class="features"> |
| <h2>功能特性</h2> |
| <div class="feature-list"> |
| <div class="feature-item"> |
| <div>🔄</div> |
| <h3>OpenAI API兼容</h3> |
| <p>完全兼容OpenAI的API格式,无需修改客户端代码</p> |
| </div> |
| |
| <div class="feature-item"> |
| <div>🌊</div> |
| <h3>流式响应支持</h3> |
| <p>支持实时流式输出,提供更好的用户体验</p> |
| </div> |
| |
| <div class="feature-item"> |
| <div>🔐</div> |
| <h3>身份验证</h3> |
| <p>支持API密钥验证,确保服务安全</p> |
| </div> |
| |
| <div class="feature-item"> |
| <div>🛠️</div> |
| <h3>灵活配置</h3> |
| <p>通过环境变量进行灵活配置</p> |
| </div> |
| |
| <div class="feature-item"> |
| <div>📝</div> |
| <h3>思考过程展示</h3> |
| <p>智能处理并展示模型的思考过程</p> |
| </div> |
| |
| <div class="feature-item"> |
| <div>📊</div> |
| <h3>实时监控</h3> |
| <p>提供Web仪表板,实时显示API转发情况和统计信息</p> |
| </div> |
| </div> |
| </div> |
| |
| <footer> |
| <p>© 2024 ZtoApi. Powered by Deno & Z.ai GLM-4.5</p> |
| </footer> |
| </div> |
| </body> |
| </html>`; |
| } |
|
|
| async function handleIndex(request: Request): Promise<Response> { |
| if (request.method !== "GET") { |
| return new Response("Method not allowed", { status: 405 }); |
| } |
| |
| return new Response(getIndexHTML(), { |
| status: 200, |
| headers: { |
| "Content-Type": "text/html; charset=utf-8" |
| } |
| }); |
| } |
|
|
| async function handleOptions(request: Request): Promise<Response> { |
| const headers = new Headers(); |
| setCORSHeaders(headers); |
| |
| if (request.method === "OPTIONS") { |
| return new Response(null, { status: 200, headers }); |
| } |
| |
| return new Response("Not Found", { status: 404, headers }); |
| } |
|
|
| async function handleModels(request: Request): Promise<Response> { |
| const headers = new Headers(); |
| setCORSHeaders(headers); |
| |
| if (request.method === "OPTIONS") { |
| return new Response(null, { status: 200, headers }); |
| } |
| |
| |
| const models = SUPPORTED_MODELS.map(model => ({ |
| id: model.name, |
| object: "model", |
| created: Math.floor(Date.now() / 1000), |
| owned_by: "z.ai" |
| })); |
| |
| const response: ModelsResponse = { |
| object: "list", |
| data: models |
| }; |
| |
| headers.set("Content-Type", "application/json"); |
| return new Response(JSON.stringify(response), { |
| status: 200, |
| headers |
| }); |
| } |
|
|
| async function handleChatCompletions(request: Request): Promise<Response> { |
| const startTime = Date.now(); |
| const url = new URL(request.url); |
| const path = url.pathname; |
| const userAgent = request.headers.get("User-Agent") || ""; |
| |
| debugLog("收到chat completions请求"); |
| debugLog("🌐 User-Agent: %s", userAgent); |
| |
| |
| const isCherryStudio = userAgent.toLowerCase().includes('cherry') || userAgent.toLowerCase().includes('studio'); |
| if (isCherryStudio) { |
| debugLog("🍒 检测到 Cherry Studio 客户端版本: %s", |
| userAgent.match(/CherryStudio\/([^\s]+)/)?.[1] || 'unknown'); |
| } |
| |
| const headers = new Headers(); |
| setCORSHeaders(headers); |
| |
| if (request.method === "OPTIONS") { |
| return new Response(null, { status: 200, headers }); |
| } |
| |
| |
| const authHeader = request.headers.get("Authorization"); |
| if (!validateApiKey(authHeader)) { |
| debugLog("缺少或无效的Authorization头"); |
| const duration = Date.now() - startTime; |
| recordRequestStats(startTime, path, 401); |
| addLiveRequest(request.method, path, 401, duration, userAgent); |
| return new Response("Missing or invalid Authorization header", { |
| status: 401, |
| headers |
| }); |
| } |
| |
| debugLog("API key验证通过"); |
| |
| |
| let body: string; |
| try { |
| body = await request.text(); |
| debugLog("📥 收到请求体长度: %d 字符", body.length); |
| |
| |
| const bodyPreview = body.length > 1000 ? body.substring(0, 1000) + "..." : body; |
| debugLog("📄 请求体预览: %s", bodyPreview); |
| } catch (error) { |
| debugLog("读取请求体失败: %v", error); |
| const duration = Date.now() - startTime; |
| recordRequestStats(startTime, path, 400); |
| addLiveRequest(request.method, path, 400, duration, userAgent); |
| return new Response("Failed to read request body", { |
| status: 400, |
| headers |
| }); |
| } |
| |
| |
| let req: OpenAIRequest; |
| try { |
| req = JSON.parse(body) as OpenAIRequest; |
| debugLog("✅ JSON解析成功"); |
| } catch (error) { |
| debugLog("JSON解析失败: %v", error); |
| const duration = Date.now() - startTime; |
| recordRequestStats(startTime, path, 400); |
| addLiveRequest(request.method, path, 400, duration, userAgent); |
| return new Response("Invalid JSON", { |
| status: 400, |
| headers |
| }); |
| } |
| |
| |
| if (!body.includes('"stream"')) { |
| req.stream = DEFAULT_STREAM; |
| debugLog("客户端未指定stream参数,使用默认值: %v", DEFAULT_STREAM); |
| } |
| |
| |
| const modelConfig = getModelConfig(req.model); |
| debugLog("请求解析成功 - 模型: %s (%s), 流式: %v, 消息数: %d", req.model, modelConfig.name, req.stream, req.messages.length); |
| |
| |
| debugLog("🔍 Cherry Studio 调试 - 检查原始消息:"); |
| for (let i = 0; i < req.messages.length; i++) { |
| const msg = req.messages[i]; |
| debugLog(" 消息[%d] role: %s", i, msg.role); |
| |
| if (typeof msg.content === 'string') { |
| debugLog(" 消息[%d] content: 字符串类型, 长度: %d", i, msg.content.length); |
| if (msg.content.length === 0) { |
| debugLog(" ⚠️ 消息[%d] 内容为空字符串!", i); |
| } else { |
| debugLog(" 消息[%d] 内容预览: %s", i, msg.content.substring(0, 100)); |
| } |
| } else if (Array.isArray(msg.content)) { |
| debugLog(" 消息[%d] content: 数组类型, 块数: %d", i, msg.content.length); |
| for (let j = 0; j < msg.content.length; j++) { |
| const block = msg.content[j]; |
| debugLog(" 块[%d] type: %s", j, block.type); |
| if (block.type === 'text' && block.text) { |
| debugLog(" 块[%d] text: %s", j, block.text.substring(0, 50)); |
| } else if (block.type === 'image_url' && block.image_url?.url) { |
| debugLog(" 块[%d] image_url: %s格式, 长度: %d", j, |
| block.image_url.url.startsWith('data:') ? 'base64' : 'url', |
| block.image_url.url.length); |
| } |
| } |
| } else { |
| debugLog(" ⚠️ 消息[%d] content 类型异常: %s", i, typeof msg.content); |
| } |
| } |
| |
| |
| const processedMessages = processMessages(req.messages, modelConfig); |
| debugLog("消息处理完成,处理后消息数: %d", processedMessages.length); |
| |
| |
| const hasMultimodal = processedMessages.some(msg => |
| Array.isArray(msg.content) && |
| msg.content.some(block => |
| ['image_url', 'video_url', 'document_url', 'audio_url'].includes(block.type) |
| ) |
| ); |
| |
| if (hasMultimodal) { |
| debugLog("🎯 检测到全方位多模态请求,模型: %s", modelConfig.name); |
| if (!modelConfig.capabilities.vision) { |
| debugLog("❌ 严重错误: 模型不支持多模态,但收到了多媒体内容!"); |
| debugLog("💡 Cherry Studio用户请检查: 确认选择了 'glm-4.5v' 而不是 'GLM-4.5'"); |
| debugLog("🔧 模型映射状态: %s → %s (vision: %s)", |
| req.model, modelConfig.upstreamId, modelConfig.capabilities.vision); |
| } else { |
| debugLog("✅ GLM-4.5V支持全方位多模态理解:图像、视频、文档、音频"); |
| |
| |
| if (!ZAI_TOKEN || ZAI_TOKEN.trim() === "") { |
| debugLog("⚠️ 重要警告: 正在使用匿名token处理多模态请求"); |
| debugLog("💡 Z.ai的匿名token可能不支持图像/视频/文档处理"); |
| debugLog("🔧 解决方案: 设置 ZAI_TOKEN 环境变量为正式的API Token"); |
| debugLog("📋 如果请求失败,这很可能是token权限问题"); |
| } else { |
| debugLog("✅ 使用正式API Token,支持完整多模态功能"); |
| } |
| } |
| } else if (modelConfig.capabilities.vision && modelConfig.id === 'glm-4.5v') { |
| debugLog("ℹ️ 使用GLM-4.5V模型但未检测到多媒体数据,仅处理文本内容"); |
| } |
| |
| |
| const chatID = `${Date.now()}-${Math.floor(Date.now() / 1000)}`; |
| const msgID = Date.now().toString(); |
| |
| |
| const upstreamReq: UpstreamRequest = { |
| stream: true, |
| chat_id: chatID, |
| id: msgID, |
| model: modelConfig.upstreamId, |
| messages: processedMessages, |
| params: modelConfig.defaultParams, |
| features: { |
| enable_thinking: modelConfig.capabilities.thinking, |
| image_generation: false, |
| web_search: false, |
| auto_web_search: false, |
| preview_mode: modelConfig.capabilities.vision |
| }, |
| background_tasks: { |
| title_generation: false, |
| tags_generation: false |
| }, |
| mcp_servers: modelConfig.capabilities.mcp ? [] : undefined, |
| model_item: { |
| id: modelConfig.upstreamId, |
| name: modelConfig.name, |
| owned_by: "openai", |
| openai: { |
| id: modelConfig.upstreamId, |
| name: modelConfig.upstreamId, |
| owned_by: "openai", |
| openai: { |
| id: modelConfig.upstreamId |
| }, |
| urlIdx: 1 |
| }, |
| urlIdx: 1, |
| info: { |
| id: modelConfig.upstreamId, |
| user_id: "api-user", |
| base_model_id: null, |
| name: modelConfig.name, |
| params: modelConfig.defaultParams, |
| meta: { |
| profile_image_url: "/static/favicon.png", |
| description: modelConfig.capabilities.vision ? "Advanced visual understanding and analysis" : "Most advanced model, proficient in coding and tool use", |
| capabilities: { |
| vision: modelConfig.capabilities.vision, |
| citations: false, |
| preview_mode: modelConfig.capabilities.vision, |
| web_search: false, |
| language_detection: false, |
| restore_n_source: false, |
| mcp: modelConfig.capabilities.mcp, |
| file_qa: modelConfig.capabilities.mcp, |
| returnFc: true, |
| returnThink: modelConfig.capabilities.thinking, |
| think: modelConfig.capabilities.thinking |
| } |
| } |
| } |
| }, |
| tool_servers: [], |
| variables: { |
| "{{USER_NAME}}": `Guest-${Date.now()}`, |
| "{{USER_LOCATION}}": "Unknown", |
| "{{CURRENT_DATETIME}}": new Date().toLocaleString('zh-CN'), |
| "{{CURRENT_DATE}}": new Date().toLocaleDateString('zh-CN'), |
| "{{CURRENT_TIME}}": new Date().toLocaleTimeString('zh-CN'), |
| "{{CURRENT_WEEKDAY}}": new Date().toLocaleDateString('zh-CN', { weekday: 'long' }), |
| "{{CURRENT_TIMEZONE}}": "Asia/Shanghai", |
| "{{USER_LANGUAGE}}": "zh-CN" |
| } |
| }; |
| |
| |
| let authToken = ZAI_TOKEN; |
| if (ANON_TOKEN_ENABLED) { |
| try { |
| const anonToken = await getAnonymousToken(); |
| authToken = anonToken; |
| debugLog("匿名token获取成功: %s...", anonToken.substring(0, 10)); |
| } catch (error) { |
| debugLog("匿名token获取失败,回退固定token: %v", error); |
| } |
| } |
| |
| |
| try { |
| if (req.stream) { |
| return await handleStreamResponse(upstreamReq, chatID, authToken, startTime, path, userAgent, req, modelConfig); |
| } else { |
| return await handleNonStreamResponse(upstreamReq, chatID, authToken, startTime, path, userAgent, req, modelConfig); |
| } |
| } catch (error) { |
| debugLog("调用上游失败: %v", error); |
| const duration = Date.now() - startTime; |
| recordRequestStats(startTime, path, 502); |
| addLiveRequest(request.method, path, 502, duration, userAgent); |
| return new Response("Failed to call upstream", { |
| status: 502, |
| headers |
| }); |
| } |
| } |
|
|
| async function handleStreamResponse( |
| upstreamReq: UpstreamRequest, |
| chatID: string, |
| authToken: string, |
| startTime: number, |
| path: string, |
| userAgent: string, |
| req: OpenAIRequest, |
| modelConfig: ModelConfig |
| ): Promise<Response> { |
| debugLog("开始处理流式响应 (chat_id=%s)", chatID); |
| |
| try { |
| const response = await callUpstreamWithHeaders(upstreamReq, chatID, authToken); |
| |
| if (!response.ok) { |
| debugLog("上游返回错误状态: %d", response.status); |
| const duration = Date.now() - startTime; |
| recordRequestStats(startTime, path, 502); |
| addLiveRequest("POST", path, 502, duration, userAgent); |
| return new Response("Upstream error", { status: 502 }); |
| } |
| |
| if (!response.body) { |
| debugLog("上游响应体为空"); |
| const duration = Date.now() - startTime; |
| recordRequestStats(startTime, path, 502); |
| addLiveRequest("POST", path, 502, duration, userAgent); |
| return new Response("Upstream response body is empty", { status: 502 }); |
| } |
| |
| |
| const { readable, writable } = new TransformStream(); |
| const writer = writable.getWriter(); |
| const encoder = new TextEncoder(); |
| |
| |
| const firstChunk: OpenAIResponse = { |
| id: `chatcmpl-${Date.now()}`, |
| object: "chat.completion.chunk", |
| created: Math.floor(Date.now() / 1000), |
| model: req.model, |
| choices: [ |
| { |
| index: 0, |
| delta: { role: "assistant" } |
| } |
| ] |
| }; |
| |
| |
| writer.write(encoder.encode(`data: ${JSON.stringify(firstChunk)}\n\n`)); |
| |
| |
| processUpstreamStream(response.body, writer, encoder, req.model).catch(error => { |
| debugLog("处理上游流时出错: %v", error); |
| }); |
| |
| |
| const duration = Date.now() - startTime; |
| recordRequestStats(startTime, path, 200); |
| addLiveRequest("POST", path, 200, duration, userAgent, modelConfig.name); |
| |
| return new Response(readable, { |
| status: 200, |
| headers: { |
| "Content-Type": "text/event-stream", |
| "Cache-Control": "no-cache", |
| "Connection": "keep-alive", |
| "Access-Control-Allow-Origin": "*", |
| "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", |
| "Access-Control-Allow-Headers": "Content-Type, Authorization", |
| "Access-Control-Allow-Credentials": "true" |
| } |
| }); |
| } catch (error) { |
| debugLog("处理流式响应时出错: %v", error); |
| const duration = Date.now() - startTime; |
| recordRequestStats(startTime, path, 502); |
| addLiveRequest("POST", path, 502, duration, userAgent); |
| return new Response("Failed to process stream response", { status: 502 }); |
| } |
| } |
|
|
| async function handleNonStreamResponse( |
| upstreamReq: UpstreamRequest, |
| chatID: string, |
| authToken: string, |
| startTime: number, |
| path: string, |
| userAgent: string, |
| req: OpenAIRequest, |
| modelConfig: ModelConfig |
| ): Promise<Response> { |
| debugLog("开始处理非流式响应 (chat_id=%s)", chatID); |
| |
| try { |
| const response = await callUpstreamWithHeaders(upstreamReq, chatID, authToken); |
| |
| if (!response.ok) { |
| debugLog("上游返回错误状态: %d", response.status); |
| const duration = Date.now() - startTime; |
| recordRequestStats(startTime, path, 502); |
| addLiveRequest("POST", path, 502, duration, userAgent); |
| return new Response("Upstream error", { status: 502 }); |
| } |
| |
| if (!response.body) { |
| debugLog("上游响应体为空"); |
| const duration = Date.now() - startTime; |
| recordRequestStats(startTime, path, 502); |
| addLiveRequest("POST", path, 502, duration, userAgent); |
| return new Response("Upstream response body is empty", { status: 502 }); |
| } |
| |
| |
| const finalContent = await collectFullResponse(response.body); |
| debugLog("内容收集完成,最终长度: %d", finalContent.length); |
| |
| |
| const openAIResponse: OpenAIResponse = { |
| id: `chatcmpl-${Date.now()}`, |
| object: "chat.completion", |
| created: Math.floor(Date.now() / 1000), |
| model: req.model, |
| choices: [ |
| { |
| index: 0, |
| message: { |
| role: "assistant", |
| content: finalContent |
| }, |
| finish_reason: "stop" |
| } |
| ], |
| usage: { |
| prompt_tokens: 0, |
| completion_tokens: 0, |
| total_tokens: 0 |
| } |
| }; |
| |
| |
| const duration = Date.now() - startTime; |
| recordRequestStats(startTime, path, 200); |
| addLiveRequest("POST", path, 200, duration, userAgent, modelConfig.name); |
| |
| return new Response(JSON.stringify(openAIResponse), { |
| status: 200, |
| headers: { |
| "Content-Type": "application/json", |
| "Access-Control-Allow-Origin": "*", |
| "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", |
| "Access-Control-Allow-Headers": "Content-Type, Authorization", |
| "Access-Control-Allow-Credentials": "true" |
| } |
| }); |
| } catch (error) { |
| debugLog("处理非流式响应时出错: %v", error); |
| const duration = Date.now() - startTime; |
| recordRequestStats(startTime, path, 502); |
| addLiveRequest("POST", path, 502, duration, userAgent); |
| return new Response("Failed to process non-stream response", { status: 502 }); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| function getDashboardHTML(): string { |
| return `<!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>API调用看板</title> |
| <style> |
| body { |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
| margin: 0; |
| padding: 20px; |
| background-color: #f5f5f5; |
| } |
| .container { |
| max-width: 1200px; |
| margin: 0 auto; |
| background-color: white; |
| border-radius: 8px; |
| box-shadow: 0 2px 10px rgba(0,0,0,0.1); |
| padding: 20px; |
| } |
| h1 { |
| color: #333; |
| text-align: center; |
| margin-bottom: 30px; |
| } |
| .stats-container { |
| display: grid; |
| grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); |
| gap: 20px; |
| margin-bottom: 30px; |
| } |
| .stat-card { |
| background-color: #f8f9fa; |
| border-radius: 6px; |
| padding: 15px; |
| text-align: center; |
| box-shadow: 0 1px 3px rgba(0,0,0,0.1); |
| } |
| .stat-value { |
| font-size: 24px; |
| font-weight: bold; |
| color: #007bff; |
| } |
| .stat-label { |
| font-size: 14px; |
| color: #6c757d; |
| margin-top: 5px; |
| } |
| .requests-container { |
| margin-top: 30px; |
| } |
| .requests-table { |
| width: 100%; |
| border-collapse: collapse; |
| } |
| .requests-table th, .requests-table td { |
| padding: 10px; |
| text-align: left; |
| border-bottom: 1px solid #ddd; |
| } |
| .requests-table th { |
| background-color: #f8f9fa; |
| } |
| .status-success { |
| color: #28a745; |
| } |
| .status-error { |
| color: #dc3545; |
| } |
| .refresh-info { |
| text-align: center; |
| margin-top: 20px; |
| color: #6c757d; |
| font-size: 14px; |
| } |
| .pagination-container { |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| margin-top: 20px; |
| gap: 10px; |
| } |
| .pagination-container button { |
| padding: 5px 10px; |
| background-color: #007bff; |
| color: white; |
| border: none; |
| border-radius: 4px; |
| cursor: pointer; |
| } |
| .pagination-container button:disabled { |
| background-color: #cccccc; |
| cursor: not-allowed; |
| } |
| .pagination-container button:hover:not(:disabled) { |
| background-color: #0056b3; |
| } |
| .chart-container { |
| margin-top: 30px; |
| height: 300px; |
| background-color: #f8f9fa; |
| border-radius: 6px; |
| padding: 15px; |
| box-shadow: 0 1px 3px rgba(0,0,0,0.1); |
| } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <h1>API调用看板</h1> |
| |
| <div class="stats-container"> |
| <div class="stat-card"> |
| <div class="stat-value" id="total-requests">0</div> |
| <div class="stat-label">总请求数</div> |
| </div> |
| <div class="stat-card"> |
| <div class="stat-value" id="successful-requests">0</div> |
| <div class="stat-label">成功请求</div> |
| </div> |
| <div class="stat-card"> |
| <div class="stat-value" id="failed-requests">0</div> |
| <div class="stat-label">失败请求</div> |
| </div> |
| <div class="stat-card"> |
| <div class="stat-value" id="avg-response-time">0s</div> |
| <div class="stat-label">平均响应时间</div> |
| </div> |
| </div> |
| |
| <div class="chart-container"> |
| <h2>请求统计图表</h2> |
| <canvas id="requestsChart"></canvas> |
| </div> |
| |
| <div class="requests-container"> |
| <h2>实时请求</h2> |
| <table class="requests-table"> |
| <thead> |
| <tr> |
| <th>时间</th> |
| <th>模型</th> |
| <th>方法</th> |
| <th>状态</th> |
| <th>耗时</th> |
| <th>User Agent</th> |
| </tr> |
| </thead> |
| <tbody id="requests-tbody"> |
| <!-- 请求记录将通过JavaScript动态添加 --> |
| </tbody> |
| </table> |
| <div class="pagination-container"> |
| <button id="prev-page" disabled>上一页</button> |
| <span id="page-info">第 1 页,共 1 页</span> |
| <button id="next-page" disabled>下一页</button> |
| </div> |
| </div> |
| |
| <div class="refresh-info"> |
| 数据每5秒自动刷新一次 |
| </div> |
| </div> |
| |
| <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> |
| <script> |
| // 全局变量 |
| let allRequests = []; |
| let currentPage = 1; |
| const itemsPerPage = 10; |
| let requestsChart = null; |
| |
| // 更新统计数据 |
| function updateStats() { |
| fetch('/dashboard/stats') |
| .then(response => response.json()) |
| .then(data => { |
| document.getElementById('total-requests').textContent = data.totalRequests || 0; |
| document.getElementById('successful-requests').textContent = data.successfulRequests || 0; |
| document.getElementById('failed-requests').textContent = data.failedRequests || 0; |
| document.getElementById('avg-response-time').textContent = ((data.averageResponseTime || 0) / 1000).toFixed(2) + 's'; |
| }) |
| .catch(error => console.error('Error fetching stats:', error)); |
| } |
| |
| // 更新请求列表 |
| function updateRequests() { |
| fetch('/dashboard/requests') |
| .then(response => response.json()) |
| .then(data => { |
| // 检查数据是否为数组 |
| if (!Array.isArray(data)) { |
| console.error('返回的数据不是数组:', data); |
| return; |
| } |
| |
| // 保存所有请求数据 |
| allRequests = data; |
| |
| // 按时间倒序排列 |
| allRequests.sort((a, b) => { |
| const timeA = new Date(a.timestamp); |
| const timeB = new Date(b.timestamp); |
| return timeB - timeA; |
| }); |
| |
| // 更新表格 |
| updateTable(); |
| |
| // 更新图表 |
| updateChart(); |
| |
| // 更新分页信息 |
| updatePagination(); |
| }) |
| .catch(error => console.error('Error fetching requests:', error)); |
| } |
| |
| // 更新表格显示 |
| function updateTable() { |
| const tbody = document.getElementById('requests-tbody'); |
| tbody.innerHTML = ''; |
| |
| // 计算当前页的数据范围 |
| const startIndex = (currentPage - 1) * itemsPerPage; |
| const endIndex = startIndex + itemsPerPage; |
| const currentRequests = allRequests.slice(startIndex, endIndex); |
| |
| currentRequests.forEach(request => { |
| const row = document.createElement('tr'); |
| |
| // 格式化时间 - 检查时间戳是否有效 |
| let timeStr = "Invalid Date"; |
| if (request.timestamp) { |
| try { |
| const time = new Date(request.timestamp); |
| if (!isNaN(time.getTime())) { |
| timeStr = time.toLocaleTimeString(); |
| } |
| } catch (e) { |
| console.error("时间格式化错误:", e); |
| } |
| } |
| |
| // 判断模型名称 |
| let modelName = "GLM-4.5"; |
| if (request.path && request.path.includes('glm-4.5v')) { |
| modelName = "GLM-4.5V"; |
| } else if (request.model) { |
| modelName = request.model; |
| } |
| |
| // 状态样式 |
| const statusClass = request.status >= 200 && request.status < 300 ? 'status-success' : 'status-error'; |
| const status = request.status || "undefined"; |
| |
| // 截断 User Agent,避免过长 |
| let userAgent = request.user_agent || "undefined"; |
| if (userAgent.length > 30) { |
| userAgent = userAgent.substring(0, 30) + "..."; |
| } |
| |
| row.innerHTML = "<td>" + timeStr + "</td>" + "<td>" + modelName + "</td>" + "<td>" + (request.method || "undefined") + "</td>" + "<td class='" + statusClass + "'>" + status + "</td>" + "<td>" + ((request.duration / 1000).toFixed(2) || "undefined") + "s</td>" + "<td title='" + (request.user_agent || "") + "'>" + userAgent + "</td>"; |
| |
| tbody.appendChild(row); |
| }); |
| } |
| |
| // 更新分页信息 |
| function updatePagination() { |
| const totalPages = Math.ceil(allRequests.length / itemsPerPage); |
| document.getElementById('page-info').textContent = "第 " + currentPage + " 页,共 " + totalPages + " 页"; |
| |
| document.getElementById('prev-page').disabled = currentPage <= 1; |
| document.getElementById('next-page').disabled = currentPage >= totalPages; |
| } |
| |
| // 更新图表 |
| function updateChart() { |
| const ctx = document.getElementById('requestsChart').getContext('2d'); |
| |
| // 准备图表数据 - 最近20条请求的响应时间 |
| const chartData = allRequests.slice(0, 20).reverse(); |
| const labels = chartData.map(req => { |
| const time = new Date(req.timestamp); |
| return time.toLocaleTimeString(); |
| }); |
| const responseTimes = chartData.map(req => req.duration); |
| |
| // 如果图表已存在,先销毁 |
| if (requestsChart) { |
| requestsChart.destroy(); |
| } |
| |
| // 创建新图表 |
| requestsChart = new Chart(ctx, { |
| type: 'line', |
| data: { |
| labels: labels, |
| datasets: [{ |
| label: '响应时间 (s)', |
| data: responseTimes.map(time => time / 1000), |
| borderColor: '#007bff', |
| backgroundColor: 'rgba(0, 123, 255, 0.1)', |
| tension: 0.1, |
| fill: true |
| }] |
| }, |
| options: { |
| responsive: true, |
| maintainAspectRatio: false, |
| scales: { |
| y: { |
| beginAtZero: true, |
| title: { |
| display: true, |
| text: '响应时间 (s)' |
| } |
| }, |
| x: { |
| title: { |
| display: true, |
| text: '时间' |
| } |
| } |
| }, |
| plugins: { |
| title: { |
| display: true, |
| text: '最近20条请求的响应时间趋势 (s)' |
| } |
| } |
| } |
| }); |
| } |
| |
| // 分页按钮事件 |
| document.getElementById('prev-page').addEventListener('click', function() { |
| if (currentPage > 1) { |
| currentPage--; |
| updateTable(); |
| updatePagination(); |
| } |
| }); |
| |
| document.getElementById('next-page').addEventListener('click', function() { |
| const totalPages = Math.ceil(allRequests.length / itemsPerPage); |
| if (currentPage < totalPages) { |
| currentPage++; |
| updateTable(); |
| updatePagination(); |
| } |
| }); |
| |
| // 初始加载 |
| updateStats(); |
| updateRequests(); |
| |
| // 定时刷新 |
| setInterval(updateStats, 5000); |
| setInterval(updateRequests, 5000); |
| </script> |
| </body> |
| </html>`; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async function handleDashboard(request: Request): Promise<Response> { |
| if (request.method !== "GET") { |
| return new Response("Method not allowed", { status: 405 }); |
| } |
|
|
| return new Response(getDashboardHTML(), { |
| status: 200, |
| headers: { |
| "Content-Type": "text/html; charset=utf-8" |
| } |
| }); |
| } |
|
|
| |
| async function handleDashboardStats(request: Request): Promise<Response> { |
| return new Response(getStatsData(), { |
| status: 200, |
| headers: { |
| "Content-Type": "application/json" |
| } |
| }); |
| } |
|
|
| async function handleDashboardRequests(request: Request): Promise<Response> { |
| return new Response(getLiveRequestsData(), { |
| status: 200, |
| headers: { |
| "Content-Type": "application/json" |
| } |
| }); |
| } |
|
|
|
|
| function getDocsHTML(): string { |
| return `<!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>ZtoApi 文档</title> |
| <style> |
| body { |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
| margin: 0; |
| padding: 20px; |
| background-color: #f5f5f5; |
| line-height: 1.6; |
| } |
| .container { |
| max-width: 1200px; |
| margin: 0 auto; |
| background-color: white; |
| border-radius: 8px; |
| box-shadow: 0 2px 10px rgba(0,0,0,0.1); |
| padding: 30px; |
| } |
| h1 { |
| color: #333; |
| text-align: center; |
| margin-bottom: 30px; |
| border-bottom: 2px solid #007bff; |
| padding-bottom: 10px; |
| } |
| h2 { |
| color: #007bff; |
| margin-top: 30px; |
| margin-bottom: 15px; |
| } |
| h3 { |
| color: #333; |
| margin-top: 25px; |
| margin-bottom: 10px; |
| } |
| .endpoint { |
| background-color: #f8f9fa; |
| border-radius: 6px; |
| padding: 15px; |
| margin-bottom: 20px; |
| border-left: 4px solid #007bff; |
| } |
| .method { |
| display: inline-block; |
| padding: 4px 8px; |
| border-radius: 4px; |
| color: white; |
| font-weight: bold; |
| margin-right: 10px; |
| font-size: 14px; |
| } |
| .get { background-color: #28a745; } |
| .post { background-color: #007bff; } |
| .path { |
| font-family: monospace; |
| background-color: #e9ecef; |
| padding: 2px 6px; |
| border-radius: 3px; |
| font-size: 16px; |
| } |
| .description { |
| margin: 15px 0; |
| } |
| .parameters { |
| margin: 15px 0; |
| } |
| table { |
| width: 100%; |
| border-collapse: collapse; |
| margin: 15px 0; |
| } |
| th, td { |
| padding: 10px; |
| text-align: left; |
| border-bottom: 1px solid #ddd; |
| } |
| th { |
| background-color: #f8f9fa; |
| font-weight: bold; |
| } |
| .example { |
| background-color: #f8f9fa; |
| border-radius: 6px; |
| padding: 15px; |
| margin: 15px 0; |
| font-family: monospace; |
| white-space: pre-wrap; |
| overflow-x: auto; |
| } |
| .note { |
| background-color: #fff3cd; |
| border-left: 4px solid #ffc107; |
| padding: 10px 15px; |
| margin: 15px 0; |
| border-radius: 0 4px 4px 0; |
| } |
| .response { |
| background-color: #f8f9fa; |
| border-radius: 6px; |
| padding: 15px; |
| margin: 15px 0; |
| font-family: monospace; |
| white-space: pre-wrap; |
| overflow-x: auto; |
| } |
| .tab { |
| overflow: hidden; |
| border: 1px solid #ccc; |
| background-color: #f1f1f1; |
| border-radius: 4px 4px 0 0; |
| } |
| .tab button { |
| background-color: inherit; |
| float: left; |
| border: none; |
| outline: none; |
| cursor: pointer; |
| padding: 14px 16px; |
| transition: 0.3s; |
| font-size: 16px; |
| } |
| .tab button:hover { |
| background-color: #ddd; |
| } |
| .tab button.active { |
| background-color: #ccc; |
| } |
| .tabcontent { |
| display: none; |
| padding: 6px 12px; |
| border: 1px solid #ccc; |
| border-top: none; |
| border-radius: 0 0 4px 4px; |
| } |
| .toc { |
| background-color: #f8f9fa; |
| border-radius: 6px; |
| padding: 15px; |
| margin-bottom: 20px; |
| } |
| .toc ul { |
| padding-left: 20px; |
| } |
| .toc li { |
| margin: 5px 0; |
| } |
| .toc a { |
| color: #007bff; |
| text-decoration: none; |
| } |
| .toc a:hover { |
| text-decoration: underline; |
| } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <h1>ZtoApi 文档</h1> |
| |
| <div class="toc"> |
| <h2>目录</h2> |
| <ul> |
| <li><a href="#overview">概述</a></li> |
| <li><a href="#authentication">身份验证</a></li> |
| <li><a href="#endpoints">API端点</a> |
| <ul> |
| <li><a href="#models">获取模型列表</a></li> |
| <li><a href="#chat-completions">聊天完成</a></li> |
| </ul> |
| </li> |
| <li><a href="#examples">使用示例</a></li> |
| <li><a href="#error-handling">错误处理</a></li> |
| </ul> |
| </div> |
| |
| <section id="overview"> |
| <h2>概述</h2> |
| <p>这是一个为Z.ai GLM-4.5模型提供OpenAI兼容API接口的代理服务器。它允许你使用标准的OpenAI API格式与Z.ai的GLM-4.5模型进行交互,支持流式和非流式响应。</p> |
| <p><strong>基础URL:</strong> <code>http://localhost:9090/v1</code></p> |
| <div class="note"> |
| <strong>注意:</strong> 默认端口为9090,可以通过环境变量PORT进行修改。 |
| </div> |
| </section> |
| |
| <section id="authentication"> |
| <h2>身份验证</h2> |
| <p>所有API请求都需要在请求头中包含有效的API密钥进行身份验证:</p> |
| <div class="example"> |
| Authorization: Bearer your-api-key</div> |
| <p>默认的API密钥为 <code>sk-your-key</code>,可以通过环境变量 <code>DEFAULT_KEY</code> 进行修改。</p> |
| </section> |
| |
| <section id="endpoints"> |
| <h2>API端点</h2> |
| |
| <div class="endpoint" id="models"> |
| <h3>获取模型列表</h3> |
| <div> |
| <span class="method get">GET</span> |
| <span class="path">/v1/models</span> |
| </div> |
| <div class="description"> |
| <p>获取可用模型列表。</p> |
| </div> |
| <div class="parameters"> |
| <h4>请求参数</h4> |
| <p>无</p> |
| </div> |
| <div class="response"> |
| { |
| "object": "list", |
| "data": [ |
| { |
| "id": "GLM-4.5", |
| "object": "model", |
| "created": 1756788845, |
| "owned_by": "z.ai" |
| } |
| ] |
| }</div> |
| </div> |
| |
| <div class="endpoint" id="chat-completions"> |
| <h3>聊天完成</h3> |
| <div> |
| <span class="method post">POST</span> |
| <span class="path">/v1/chat/completions</span> |
| </div> |
| <div class="description"> |
| <p>基于消息列表生成模型响应。支持流式和非流式两种模式。</p> |
| </div> |
| <div class="parameters"> |
| <h4>请求参数</h4> |
| <table> |
| <thead> |
| <tr> |
| <th>参数名</th> |
| <th>类型</th> |
| <th>必需</th> |
| <th>说明</th> |
| </tr> |
| </thead> |
| <tbody> |
| <tr> |
| <td>model</td> |
| <td>string</td> |
| <td>是</td> |
| <td>要使用的模型ID,例如 "GLM-4.5"</td> |
| </tr> |
| <tr> |
| <td>messages</td> |
| <td>array</td> |
| <td>是</td> |
| <td>消息列表,包含角色和内容</td> |
| </tr> |
| <tr> |
| <td>stream</td> |
| <td>boolean</td> |
| <td>否</td> |
| <td>是否使用流式响应,默认为true</td> |
| </tr> |
| <tr> |
| <td>temperature</td> |
| <td>number</td> |
| <td>否</td> |
| <td>采样温度,控制随机性</td> |
| </tr> |
| <tr> |
| <td>max_tokens</td> |
| <td>integer</td> |
| <td>否</td> |
| <td>生成的最大令牌数</td> |
| </tr> |
| </tbody> |
| </table> |
| </div> |
| <div class="parameters"> |
| <h4>消息格式</h4> |
| <table> |
| <thead> |
| <tr> |
| <th>字段</th> |
| <th>类型</th> |
| <th>说明</th> |
| </tr> |
| </thead> |
| <tbody> |
| <tr> |
| <td>role</td> |
| <td>string</td> |
| <td>消息角色,可选值:system、user、assistant</td> |
| </tr> |
| <tr> |
| <td>content</td> |
| <td>string</td> |
| <td>消息内容</td> |
| </tr> |
| </tbody> |
| </table> |
| </div> |
| </div> |
| </section> |
| |
| <section id="examples"> |
| <h2>使用示例</h2> |
| |
| <div class="tab"> |
| <button class="tablinks active" onclick="openTab(event, 'python-tab')">Python</button> |
| <button class="tablinks" onclick="openTab(event, 'curl-tab')">cURL</button> |
| <button class="tablinks" onclick="openTab(event, 'javascript-tab')">JavaScript</button> |
| </div> |
| |
| <div id="python-tab" class="tabcontent" style="display: block;"> |
| <h3>Python示例</h3> |
| <div class="example"> |
| import openai |
| |
| # 配置客户端 |
| client = openai.OpenAI( |
| api_key="your-api-key", # 对应 DEFAULT_KEY |
| base_url="http://localhost:9090/v1" |
| ) |
| |
| # 非流式请求 - 使用GLM-4.5 |
| response = client.chat.completions.create( |
| model="GLM-4.5", |
| messages=[{"role": "user", "content": "你好,请介绍一下自己"}] |
| ) |
| |
| print(response.choices[0].message.content) |
| |
| |
| # 流式请求 - 使用GLM-4.5 |
| response = client.chat.completions.create( |
| model="GLM-4.5", |
| messages=[{"role": "user", "content": "请写一首关于春天的诗"}], |
| stream=True |
| ) |
| |
| |
| for chunk in response: |
| if chunk.choices[0].delta.content: |
| print(chunk.choices[0].delta.content, end="")</div> |
| </div> |
| |
| <div id="curl-tab" class="tabcontent"> |
| <h3>cURL示例</h3> |
| <div class="example"> |
| # 非流式请求 |
| curl -X POST http://localhost:9090/v1/chat/completions \ |
| -H "Content-Type: application/json" \ |
| -H "Authorization: Bearer your-api-key" \ |
| -d '{ |
| "model": "GLM-4.5", |
| "messages": [{"role": "user", "content": "你好"}], |
| "stream": false |
| }' |
| |
| # 流式请求 |
| curl -X POST http://localhost:9090/v1/chat/completions \ |
| -H "Content-Type: application/json" \ |
| -H "Authorization: Bearer your-api-key" \ |
| -d '{ |
| "model": "GLM-4.5", |
| "messages": [{"role": "user", "content": "你好"}], |
| "stream": true |
| }'</div> |
| </div> |
| |
| <div id="javascript-tab" class="tabcontent"> |
| <h3>JavaScript示例</h3> |
| <div class="example"> |
| const fetch = require('node-fetch'); |
| |
| async function chatWithGLM(message, stream = false) { |
| const response = await fetch('http://localhost:9090/v1/chat/completions', { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| 'Authorization': 'Bearer your-api-key' |
| }, |
| body: JSON.stringify({ |
| model: 'GLM-4.5', |
| messages: [{ role: 'user', content: message }], |
| stream: stream |
| }) |
| }); |
| |
| if (stream) { |
| // 处理流式响应 |
| const reader = response.body.getReader(); |
| const decoder = new TextDecoder(); |
| |
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| |
| const chunk = decoder.decode(value); |
| const lines = chunk.split('\n'); |
| |
| for (const line of lines) { |
| if (line.startsWith('data: ')) { |
| const data = line.slice(6); |
| if (data === '[DONE]') { |
| console.log('\n流式响应完成'); |
| return; |
| } |
| |
| try { |
| const parsed = JSON.parse(data); |
| const content = parsed.choices[0]?.delta?.content; |
| if (content) { |
| process.stdout.write(content); |
| } |
| } catch (e) { |
| // 忽略解析错误 |
| } |
| } |
| } |
| } |
| } else { |
| // 处理非流式响应 |
| const data = await response.json(); |
| console.log(data.choices[0].message.content); |
| } |
| } |
| |
| // 使用示例 |
| chatWithGLM('你好,请介绍一下JavaScript', false);</div> |
| </div> |
| </section> |
| |
| <section id="error-handling"> |
| <h2>错误处理</h2> |
| <p>API使用标准HTTP状态码来表示请求的成功或失败:</p> |
| <table> |
| <thead> |
| <tr> |
| <th>状态码</th> |
| <th>说明</th> |
| </tr> |
| </thead> |
| <tbody> |
| <tr> |
| <td>200 OK</td> |
| <td>请求成功</td> |
| </tr> |
| <tr> |
| <td>400 Bad Request</td> |
| <td>请求格式错误或参数无效</td> |
| </tr> |
| <tr> |
| <td>401 Unauthorized</td> |
| <td>API密钥无效或缺失</td> |
| </tr> |
| <tr> |
| <td>502 Bad Gateway</td> |
| <td>上游服务错误</td> |
| </tr> |
| </tbody> |
| </table> |
| <div class="note"> |
| <strong>注意:</strong> 在调试模式下,服务器会输出详细的日志信息,可以通过设置环境变量 DEBUG_MODE=true 来启用。 |
| </div> |
| </section> |
| </div> |
| |
| <script> |
| function openTab(evt, tabName) { |
| var i, tabcontent, tablinks; |
| tabcontent = document.getElementsByClassName("tabcontent"); |
| for (i = 0; i < tabcontent.length; i++) { |
| tabcontent[i].style.display = "none"; |
| } |
| tablinks = document.getElementsByClassName("tablinks"); |
| for (i = 0; i < tablinks.length; i++) { |
| tablinks[i].className = tablinks[i].className.replace(" active", ""); |
| } |
| document.getElementById(tabName).style.display = "block"; |
| evt.currentTarget.className += " active"; |
| } |
| </script> |
| </body> |
| </html>`; |
| } |
|
|
| |
| async function handleDocs(request: Request): Promise<Response> { |
| if (request.method !== "GET") { |
| return new Response("Method not allowed", { status: 405 }); |
| } |
|
|
| return new Response(getDocsHTML(), { |
| status: 200, |
| headers: { |
| "Content-Type": "text/html; charset=utf-8" |
| } |
| }); |
| } |
|
|
| |
| async function main() { |
| console.log(`OpenAI兼容API服务器启动`); |
| console.log(`支持的模型: ${SUPPORTED_MODELS.map(m => `${m.id} (${m.name})`).join(', ')}`); |
| console.log(`上游: ${UPSTREAM_URL}`); |
| console.log(`Debug模式: ${DEBUG_MODE}`); |
| console.log(`默认流式响应: ${DEFAULT_STREAM}`); |
| console.log(`Dashboard启用: ${DASHBOARD_ENABLED}`); |
|
|
| |
| const isDenoDeploy = Deno.env.get("DENO_DEPLOYMENT_ID") !== undefined; |
|
|
| if (isDenoDeploy) { |
| |
| console.log("运行在Deno Deploy环境中"); |
| Deno.serve(handleRequest,{ port: 7860 }); |
| } else { |
| |
| const port = parseInt(Deno.env.get("PORT") || "7860"); |
| console.log(`运行在本地环境中,端口: ${port}`); |
| |
| if (DASHBOARD_ENABLED) { |
| console.log(`Dashboard已启用,访问地址: http://localhost:${port}/dashboard`); |
| } |
| |
| const server = Deno.listen({ port }); |
| |
| for await (const conn of server) { |
| handleHttp(conn); |
| } |
| } |
| } |
|
|
| |
| async function handleHttp(conn: Deno.Conn) { |
| const httpConn = Deno.serveHttp(conn); |
| |
| while (true) { |
| const requestEvent = await httpConn.nextRequest(); |
| if (!requestEvent) break; |
| |
| const { request, respondWith } = requestEvent; |
| const url = new URL(request.url); |
| const startTime = Date.now(); |
| const userAgent = request.headers.get("User-Agent") || ""; |
|
|
| try { |
| |
| if (url.pathname === "/") { |
| const response = await handleIndex(request); |
| await respondWith(response); |
| recordRequestStats(startTime, url.pathname, response.status); |
| addLiveRequest(request.method, url.pathname, response.status, Date.now() - startTime, userAgent); |
| } else if (url.pathname === "/v1/models") { |
| const response = await handleModels(request); |
| await respondWith(response); |
| recordRequestStats(startTime, url.pathname, response.status); |
| addLiveRequest(request.method, url.pathname, response.status, Date.now() - startTime, userAgent); |
| } else if (url.pathname === "/v1/chat/completions") { |
| const response = await handleChatCompletions(request); |
| await respondWith(response); |
| |
| } else if (url.pathname === "/docs") { |
| const response = await handleDocs(request); |
| await respondWith(response); |
| recordRequestStats(startTime, url.pathname, response.status); |
| addLiveRequest(request.method, url.pathname, response.status, Date.now() - startTime, userAgent); |
| } else if (url.pathname === "/dashboard" && DASHBOARD_ENABLED) { |
| const response = await handleDashboard(request); |
| await respondWith(response); |
| recordRequestStats(startTime, url.pathname, response.status); |
| addLiveRequest(request.method, url.pathname, response.status, Date.now() - startTime, userAgent); |
| } else if (url.pathname === "/dashboard/stats" && DASHBOARD_ENABLED) { |
| const response = await handleDashboardStats(request); |
| await respondWith(response); |
| recordRequestStats(startTime, url.pathname, response.status); |
| addLiveRequest(request.method, url.pathname, response.status, Date.now() - startTime, userAgent); |
| } else if (url.pathname === "/dashboard/requests" && DASHBOARD_ENABLED) { |
| const response = await handleDashboardRequests(request); |
| await respondWith(response); |
| recordRequestStats(startTime, url.pathname, response.status); |
| addLiveRequest(request.method, url.pathname, response.status, Date.now() - startTime, userAgent); |
| } else { |
| const response = await handleOptions(request); |
| await respondWith(response); |
| recordRequestStats(startTime, url.pathname, response.status); |
| addLiveRequest(request.method, url.pathname, response.status, Date.now() - startTime, userAgent); |
| } |
| } catch (error) { |
| debugLog("处理请求时出错: %v", error); |
| const response = new Response("Internal Server Error", { status: 500 }); |
| await respondWith(response); |
| recordRequestStats(startTime, url.pathname, 500); |
| addLiveRequest(request.method, url.pathname, 500, Date.now() - startTime, userAgent); |
| } |
| } |
| } |
|
|
| |
| async function handleRequest(request: Request): Promise<Response> { |
| const url = new URL(request.url); |
| const startTime = Date.now(); |
| const userAgent = request.headers.get("User-Agent") || ""; |
|
|
| try { |
| |
| if (url.pathname === "/") { |
| const response = await handleIndex(request); |
| recordRequestStats(startTime, url.pathname, response.status); |
| addLiveRequest(request.method, url.pathname, response.status, Date.now() - startTime, userAgent); |
| return response; |
| } else if (url.pathname === "/v1/models") { |
| const response = await handleModels(request); |
| recordRequestStats(startTime, url.pathname, response.status); |
| addLiveRequest(request.method, url.pathname, response.status, Date.now() - startTime, userAgent); |
| return response; |
| } else if (url.pathname === "/v1/chat/completions") { |
| const response = await handleChatCompletions(request); |
| |
| return response; |
| } else if (url.pathname === "/docs") { |
| const response = await handleDocs(request); |
| recordRequestStats(startTime, url.pathname, response.status); |
| addLiveRequest(request.method, url.pathname, response.status, Date.now() - startTime, userAgent); |
| return response; |
| } else if (url.pathname === "/dashboard" && DASHBOARD_ENABLED) { |
| const response = await handleDashboard(request); |
| recordRequestStats(startTime, url.pathname, response.status); |
| addLiveRequest(request.method, url.pathname, response.status, Date.now() - startTime, userAgent); |
| return response; |
| } else if (url.pathname === "/dashboard/stats" && DASHBOARD_ENABLED) { |
| const response = await handleDashboardStats(request); |
| recordRequestStats(startTime, url.pathname, response.status); |
| addLiveRequest(request.method, url.pathname, response.status, Date.now() - startTime, userAgent); |
| return response; |
| } else if (url.pathname === "/dashboard/requests" && DASHBOARD_ENABLED) { |
| const response = await handleDashboardRequests(request); |
| recordRequestStats(startTime, url.pathname, response.status); |
| addLiveRequest(request.method, url.pathname, response.status, Date.now() - startTime, userAgent); |
| return response; |
| } else { |
| const response = await handleOptions(request); |
| recordRequestStats(startTime, url.pathname, response.status); |
| addLiveRequest(request.method, url.pathname, response.status, Date.now() - startTime, userAgent); |
| return response; |
| } |
| } catch (error) { |
| debugLog("处理请求时出错: %v", error); |
| recordRequestStats(startTime, url.pathname, 500); |
| addLiveRequest(request.method, url.pathname, 500, Date.now() - startTime, userAgent); |
| return new Response("Internal Server Error", { status: 500 }); |
| } |
| } |
|
|
| |
| main(); |