@@ -3,6 +3,7 @@ import { streamText, UIMessage, convertToModelMessages } from "ai";
33import { getModel , requiresApiKey , type AIProvider } from "@/lib/ai/models" ;
44import { buildSystemMessage } from "@/lib/ai/prompt" ;
55import { source } from "@/lib/source" ;
6+ import { limitChat , rateLimitResponse } from "@/lib/rate-limit" ;
67import fs from "fs/promises" ;
78import path from "path" ;
89
@@ -29,6 +30,22 @@ interface ChatRequest {
2930import { resolveUserId } from "@/lib/server-auth" ;
3031
3132export async function POST ( req : Request ) {
33+ // 0. Rate limit:免费模型 GLM-4.6V-Flash 并发极低(≈ 5),
34+ // 单用户开几个 tab 就能打爆。per-IP 滑动窗口限流先挡一层。
35+ // (L2 防护;如果 Upstash env 漏配会自动降级为放行+warn)
36+ //
37+ // 预读 body 判断是否带图(hasImage=true 会触发更严的 5 req/60s 窗口)。
38+ // 为此多克一次请求,后续 proxyReq/req.json() 仍可独立读(Copilot CR #4)。
39+ let hasImage = false ;
40+ try {
41+ const body = ( await req . clone ( ) . json ( ) ) as Partial < ChatRequest > ;
42+ hasImage = messagesHaveImage ( body . messages ) ;
43+ } catch {
44+ // body 不是合法 JSON:按无图处理,继续让下游的 req.json() 去报真正的错
45+ }
46+ const rl = await limitChat ( req , hasImage ) ;
47+ if ( ! rl . success ) return rateLimitResponse ( rl ) ;
48+
3249 // 1. 克隆请求,因为如果代理失败,后面的代码还需要读取 req.json()
3350 const proxyReq = req . clone ( ) ;
3451
@@ -234,13 +251,114 @@ export async function POST(req: Request) {
234251 return Response . json ( { error : error . message } , { status : 400 } ) ;
235252 }
236253
254+ // 识别上游(智谱 GLM)限流/欠费/鉴权错误,给出结构化 code 让前端友好提示。
255+ // 智谱业务码参考:
256+ // 1302 - 接口请求并发超额(与 HTTP 429 对应)
257+ // 1113 - 账户余额不足 / 免费额度耗尽
258+ // 1001/1002/1003 - 鉴权失败
259+ const mapped = mapUpstreamError ( error ) ;
260+ if ( mapped ) {
261+ return Response . json (
262+ { error : mapped . message , code : mapped . code } ,
263+ { status : mapped . status } ,
264+ ) ;
265+ }
266+
237267 return Response . json (
238268 { error : "Failed to process chat request" } ,
239269 { status : 500 } ,
240270 ) ;
241271 }
242272}
243273
274+ /**
275+ * 判断一组 UIMessage 里是否含图片 part。支持 AI SDK v5 的多种图片表达:
276+ * `type === "image"` / `type === "image_url"` / `type === "file"` 且 mediaType 起头 image。
277+ * 任何异常结构都当作无图,宁可放过也不误杀。
278+ */
279+ function messagesHaveImage ( messages : unknown ) : boolean {
280+ if ( ! Array . isArray ( messages ) ) return false ;
281+ return messages . some ( ( msg ) => {
282+ if ( ! msg || typeof msg !== "object" ) return false ;
283+ const parts = ( msg as { parts ?: unknown } ) . parts ;
284+ if ( ! Array . isArray ( parts ) ) return false ;
285+ return parts . some ( ( part ) => {
286+ if ( ! part || typeof part !== "object" ) return false ;
287+ const type = ( part as { type ?: unknown } ) . type ;
288+ if ( type === "image" || type === "image_url" ) return true ;
289+ if ( type === "file" ) {
290+ const mediaType = ( part as { mediaType ?: unknown } ) . mediaType ;
291+ return typeof mediaType === "string" && mediaType . startsWith ( "image/" ) ;
292+ }
293+ return false ;
294+ } ) ;
295+ } ) ;
296+ }
297+
298+ interface MappedUpstreamError {
299+ status : number ;
300+ code : "rate_limited" | "quota_exhausted" | "upstream_auth" | "upstream_down" ;
301+ message : string ;
302+ }
303+
304+ function mapUpstreamError ( err : unknown ) : MappedUpstreamError | null {
305+ if ( ! err ) return null ;
306+
307+ // 仅使用 message / response payload,**不要拼 stack** —— stack 里带行号
308+ // 形如 `:429:` / `:1302:` 会误匹配业务码正则(Copilot CR #5)。
309+ // JSON.stringify 对循环引用会抛错,用 try/catch 兜底(Copilot CR #6)。
310+ let raw : string ;
311+ if ( err instanceof Error ) {
312+ raw = err . message ;
313+ } else if ( typeof err === "string" ) {
314+ raw = err ;
315+ } else {
316+ try {
317+ raw = JSON . stringify ( err ) ;
318+ } catch {
319+ raw = String ( err ) ;
320+ }
321+ }
322+
323+ // 业务码正则:全部用 `[^\s]{0,N}?` 代替 `.*`,限死回溯深度避免 ReDoS
324+ // (CodeQL polynomial regex 告警)。关键词语义够短,10~20 字符窗口足够。
325+ const hasStatus429 = / \b 4 2 9 \b | r a t e [ - _ ] ? l i m i t | t o o m a n y r e q u e s t s / i. test ( raw ) ;
326+ const has1302 = / \b 1 3 0 2 \b | 并 发 超 额 | 速 率 限 制 | 控 制 请 求 频 率 / . test ( raw ) ;
327+ const has1113 =
328+ / \b 1 1 1 3 \b | 余 额 不 足 | 额 度 [ ^ \s ] { 0 , 10 } ?耗 尽 | q u o t a [ ^ \s ] { 0 , 10 } ?e x h a u s t / i. test (
329+ raw ,
330+ ) ;
331+ const hasAuth =
332+ / \b 1 0 0 1 \b | \b 1 0 0 2 \b | \b 1 0 0 3 \b | \b 4 0 1 \b | u n a u t h o r i z e d | i n v a l i d [ ^ \s ] { 0 , 10 } ?a p i [ ^ \s ] { 0 , 10 } ?k e y / i. test (
333+ raw ,
334+ ) ;
335+
336+ if ( has1302 || hasStatus429 ) {
337+ return {
338+ status : 429 ,
339+ code : "rate_limited" ,
340+ message : "AI 服务被挤爆了,排队中,请 30 秒后再试。(上游并发限流)" ,
341+ } ;
342+ }
343+ if ( has1113 ) {
344+ return {
345+ status : 503 ,
346+ code : "quota_exhausted" ,
347+ message :
348+ "免费模型今日额度已用完,请明天再来,或在设置里切到你自己的 OpenAI/Gemini。" ,
349+ } ;
350+ }
351+ if ( hasAuth ) {
352+ return {
353+ status : 502 ,
354+ code : "upstream_auth" ,
355+ message :
356+ "AI 服务密钥配置异常,站点管理员已收到通知。请稍后重试或切换到自有 API Key。" ,
357+ } ;
358+ }
359+ return null ;
360+ }
361+
244362// 提取纯文本内容,过滤掉 MDX 语法
245363function extractTextFromMDX ( content : string ) : string {
246364 let text = content
0 commit comments