@@ -10,15 +10,29 @@ import { createParser } from 'eventsource-parser'
import logger from '@/lib/logger.ts' ;
import util from '@/lib/util.ts' ;
// 模型名称
const MODEL_NAME = 'kimi' ;
// access_token有效期
const ACCESS_TOKEN_EXPIRES = 300 ;
// 伪装headers
const FAKE_HEADERS = {
'Sec-Ch-Ua' : '"Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"' ,
'Sec-Ch-Ua-Mobile' : '?0' ,
'Sec-Ch-Ua-Platform' : '"Windows"' ,
'User-Agent' : 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36'
} ;
// 文件最大大小
const FILE_MAX_SIZE = 100 * 1024 * 1024 ;
// access_token映射
const accessTokenMap = new Map ( ) ;
/**
* 请求access_token
*
* 使用refresh_token去刷新获得access_token
*
* @param refreshToken 用于刷新access_token的refresh_token
*/
async function requestToken ( refreshToken : string ) {
const result = await axios . get ( 'https://kimi.moonshot.cn/api/auth/token/refresh' , {
headers : {
@@ -26,6 +40,7 @@ async function requestToken(refreshToken: string) {
Referer : 'https://kimi.moonshot.cn' ,
. . . FAKE_HEADERS
} ,
timeout : 15000 ,
validateStatus : ( ) = > true
} ) ;
const {
@@ -39,6 +54,13 @@ async function requestToken(refreshToken: string) {
}
}
/**
* 获取缓存中的access_token
*
* 避免短时间大量刷新token, 未加锁, 如果有并发要求还需加锁
*
* @param refreshToken 用于刷新access_token的refresh_token
*/
async function acquireToken ( refreshToken : string ) : Promise < string > {
let result = accessTokenMap . get ( refreshToken ) ;
if ( ! result ) {
@@ -50,6 +72,13 @@ async function acquireToken(refreshToken: string): Promise<string> {
return result . accessToken ;
}
/**
* 创建会话
*
* 创建临时的会话用于对话补全
*
* @param refreshToken 用于刷新access_token的refresh_token
*/
async function createConversation ( name : string , refreshToken : string ) {
const token = await acquireToken ( refreshToken ) ;
const result = await axios . post ( 'https://kimi.moonshot.cn/api/chat' , {
@@ -61,6 +90,7 @@ async function createConversation(name: string, refreshToken: string) {
Referer : 'https://kimi.moonshot.cn' ,
. . . FAKE_HEADERS
} ,
timeout : 15000 ,
validateStatus : ( ) = > true
} ) ;
const {
@@ -69,6 +99,13 @@ async function createConversation(name: string, refreshToken: string) {
return convId ;
}
/**
* 移除会话
*
* 在对话流传输完毕后移除会话,避免创建的会话出现在用户的对话列表中
*
* @param refreshToken 用于刷新access_token的refresh_token
*/
async function removeConversation ( convId : string , refreshToken : string ) {
const token = await acquireToken ( refreshToken ) ;
const result = await axios . delete ( ` https://kimi.moonshot.cn/api/chat/ ${ convId } ` , {
@@ -77,16 +114,30 @@ async function removeConversation(convId: string, refreshToken: string) {
Referer : ` https://kimi.moonshot.cn/chat/ ${ convId } ` ,
. . . FAKE_HEADERS
} ,
timeout : 15000 ,
validateStatus : ( ) = > true
} ) ;
checkResult ( result , refreshToken ) ;
}
/**
* 同步对话补全
*
* @param messages 参考gpt系列消息格式, 多轮对话请完整提供上下文
* @param refreshToken 用于刷新access_token的refresh_token
* @param useSearch 是否开启联网搜索
*/
async function createCompletion ( messages : any [ ] , refreshToken : string , useSearch = true ) {
logger . info ( messages ) ;
// 提取引用文件URL并上传kimi获得引用的文件ID列表
const refFileUrls = extractRefFileUrls ( messages ) ;
const refs = refFileUrls . length ? await Promise . all ( refFileUrls . map ( fileUrl = > uploadFile ( fileUrl , refreshToken ) ) ) : [ ] ;
// 创建会话
const convId = await createConversation ( ` cmpl- ${ util . uuid ( false ) } ` , refreshToken ) ;
// 请求流
const token = await acquireToken ( refreshToken ) ;
const result = await axios . post ( ` https://kimi.moonshot.cn/api/chat/ ${ convId } /completion/stream ` , {
messages : messagesPrepare ( messages ) ,
@@ -98,26 +149,48 @@ async function createCompletion(messages: any[], refreshToken: string, useSearch
Referer : ` https://kimi.moonshot.cn/chat/ ${ convId } ` ,
. . . FAKE_HEADERS
} ,
// 120秒超时
timeout : 120000 ,
validateStatus : ( ) = > true ,
responseType : 'stream'
} ) ;
// 接收流为输出文本
const answer = await receiveStream ( convId , result . data ) ;
// 异步移除会话,如果消息不合规,此操作可能会抛出数据库错误异常,请忽略
removeConversation ( convId , refreshToken )
. catch ( err = > console . error ( err ) ) ;
. catch ( err = > console . error ( err ) ) ;
return answer ;
}
/**
* 流式对话补全
*
* @param messages 参考gpt系列消息格式, 多轮对话请完整提供上下文
* @param refreshToken 用于刷新access_token的refresh_token
* @param useSearch 是否开启联网搜索
*/
async function createCompletionStream ( messages : any [ ] , refreshToken : string , useSearch = true ) {
logger . info ( messages ) ;
// 提取引用文件URL并上传kimi获得引用的文件ID列表
const refFileUrls = extractRefFileUrls ( messages ) ;
const refs = refFileUrls . length ? await Promise . all ( refFileUrls . map ( fileUrl = > uploadFile ( fileUrl , refreshToken ) ) ) : [ ] ;
// 创建会话
const convId = await createConversation ( ` cmpl- ${ util . uuid ( false ) } ` , refreshToken ) ;
// 请求流
const token = await acquireToken ( refreshToken ) ;
const result = await axios . post ( ` https://kimi.moonshot.cn/api/chat/ ${ convId } /completion/stream ` , {
messages : messagesPrepare ( messages ) ,
refs ,
use_search : useSearch
} , {
// 120秒超时
timeout : 120000 ,
headers : {
Authorization : ` Bearer ${ token } ` ,
Referer : ` https://kimi.moonshot.cn/chat/ ${ convId } ` ,
@@ -126,25 +199,57 @@ async function createCompletionStream(messages: any[], refreshToken: string, use
validateStatus : ( ) = > true ,
responseType : 'stream'
} ) ;
// 创建转换流将消息格式转换为gpt兼容格式
return createTransStream ( convId , result . data , ( ) = > {
// 流传输结束后异步移除会话,如果消息不合规,此操作可能会抛出数据库错误异常,请忽略
removeConversation ( convId , refreshToken )
. catch ( err = > console . error ( err ) ) ;
} ) ;
}
/**
* 提取消息中引用的文件URL
*
* @param messages 参考gpt系列消息格式, 多轮对话请完整提供上下文
*/
function extractRefFileUrls ( messages : any [ ] ) {
return messages . reduce ( ( urls , message ) = > {
if ( message . type != 'file' || ! message . url )
return urls ;
urls . push ( message . url ) ;
if ( _ . isArray ( message . content ) ) {
message . content . forEach ( v = > {
if ( ! _ . isObject ( v ) || ! [ 'file' , 'image_url' ] . includes ( v [ 'type' ] ) )
return ;
// kimi-free-api支持格式
if ( v [ 'type' ] == 'file' && _ . isObject ( v [ 'file_url' ] ) && _ . isString ( v [ 'file_url' ] [ 'url' ] ) )
urls . push ( v [ 'file_url' ] [ 'url' ] ) ;
// 兼容gpt-4-vision-preview API格式
else if ( v [ 'type' ] == 'image_url' && _ . isObject ( v [ 'image_url' ] ) && _ . isString ( v [ 'image_url' ] [ 'url' ] ) )
urls . push ( v [ 'image_url' ] [ 'url' ] ) ;
} ) ;
}
return urls ;
} , [ ] ) ;
}
/**
* 消息预处理
*
* 由于接口只取第一条消息,此处会将多条消息合并为一条,实现多轮对话效果
* user:旧消息1
* assistant:旧消息2
* user:新消息
*
* @param messages 参考gpt系列消息格式, 多轮对话请完整提供上下文
*/
function messagesPrepare ( messages : any [ ] ) {
const content = messages . reduce ( ( content , message ) = > {
if ( message . type == 'file' )
return content ;
if ( _ . isArray ( message . content ) ) {
return message . content . reduce ( ( _content , v ) = > {
if ( ! _ . isObject ( v ) || v [ 'type' ] != 'text' )
return _content ;
return _content + ( v [ 'text' ] || '' ) ;
} , content ) ;
}
return content += ` ${ message . role || 'user' } : ${ wrapUrlsToTags ( message . content ) } \ n ` ;
} , '' ) ;
@@ -153,16 +258,30 @@ function messagesPrepare(messages: any[]) {
]
}
/**
* 将消息中的URL包装为HTML标签
*
* kimi网页版中会自动将url包装为url标签用于处理状态, 此处也得模仿处理, 否则无法成功解析
*
* @param content 消息内容
*/
function wrapUrlsToTags ( content : string ) {
return content . replace ( /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/gi , url = > ` <url id="" type="url" status="" title="" wc=""> ${ url } </url> ` ) ;
}
async function preSignUrl ( fileName : string , refreshToken : string ) {
/**
* 获取预签名的文件URL
*
* @param filename 文件名称
* @param refreshToken 用于刷新access_token的refresh_token
*/
async function preSignUrl ( filename : string , refreshToken : string ) {
const token = await acquireToken ( refreshToken ) ;
const result = await axios . post ( 'https://kimi.moonshot.cn/api/pre-sign-url' , {
action : 'file' ,
name : fileN ame
action : 'file' ,
name : filen ame
} , {
timeout : 15000 ,
headers : {
Authorization : ` Bearer ${ token } ` ,
Referer : ` https://kimi.moonshot.cn ` ,
@@ -173,30 +292,64 @@ async function preSignUrl(fileName: string, refreshToken: string) {
return checkResult ( result , refreshToken ) ;
}
/**
* 预检查文件URL有效性
*
* @param fileUrl 文件URL
*/
async function checkFileUrl ( fileUrl : string ) {
const result = await axios . head ( fileUrl , {
timeout : 15000 ,
validateStatus : ( ) = > true
} ) ;
return result . status = = 2 00 ? true : false ;
if ( result . status > = 4 00)
throw new APIException ( EX . API_FILE_URL_INVALID , ` File ${ fileUrl } is not valid: [ ${ result . status } ] ${ result . statusText } ` ) ;
// 检查文件大小
if ( result . headers && result . headers [ 'content-length' ] ) {
const fileSize = parseInt ( result . headers [ 'content-length' ] , 10 ) ;
if ( fileSize > FILE_MAX_SIZE )
throw new APIException ( EX . API_FILE_EXECEEDS_SIZE , ` File ${ fileUrl } is not valid ` ) ;
}
}
/**
* 上传文件
*
* @param fileUrl 文件URL
* @param refreshToken 用于刷新access_token的refresh_token
*/
async function uploadFile ( fileUrl : string , refreshToken : string ) {
if ( ! await checkFileUrl ( fileUrl ) )
throw new APIException ( EX . API_FILE_URL_INVALID , ` File ${ fileUrl } is not valid ` ) ;
const fileName = path . basename ( fileUrl ) ;
// 预检查远程文件URL可用性
await checkFileUrl ( fileUrl ) ;
// 下载文件到内存,如果您的服务器内存很小,建议考虑改造为流直传到下一个接口上,避免停留占用内存
const filename = path . basename ( fileUrl ) ;
const { data : fileData } = await axios . get ( fileUrl , {
responseType : 'arraybuffer'
responseType : 'arraybuffer' ,
// 100M限制
maxContentLength : FILE_MAX_SIZE ,
// 60秒超时
timeout : 60000
} ) ;
// 获取预签名文件URL
const {
url : uploadUrl ,
object_name : objectName
} = await preSignUrl ( fileN ame , refreshToken ) ;
const mimeType = mime . getType ( fileName ) ;
} = await preSignUrl ( filen ame , refreshToken ) ;
// 获取文件的MIME类型
const mimeType = mime . getType ( filename ) ;
// 上传文件到目标OSS
const token = await acquireToken ( refreshToken ) ;
let result = await axios . request ( {
method : 'PUT' ,
url : uploadUrl ,
data : fileData ,
// 100M限制
maxBodyLength : FILE_MAX_SIZE ,
// 60秒超时
timeout : 60000 ,
headers : {
'Content-Type' : mimeType ,
Authorization : ` Bearer ${ token } ` ,
@@ -206,10 +359,13 @@ async function uploadFile(fileUrl: string, refreshToken: string) {
validateStatus : ( ) = > true
} ) ;
checkResult ( result , refreshToken ) ;
// 获取文件上传结果
result = await axios . post ( 'https://kimi.moonshot.cn/api/file' , {
type : 'file' ,
name : fileN ame ,
object_name : objectName
name : filen ame ,
object_name : objectName ,
timeout : 15000
} , {
headers : {
Authorization : ` Bearer ${ token } ` ,
@@ -218,11 +374,31 @@ async function uploadFile(fileUrl: string, refreshToken: string) {
}
} ) ;
const { id : fileId } = checkResult ( result , refreshToken ) ;
// 处理文件转换
result = await axios . post ( 'https://kimi.moonshot.cn/api/file/parse_process' , {
ids : [ fileId ] ,
timeout : 120000
} , {
headers : {
Authorization : ` Bearer ${ token } ` ,
Referer : ` https://kimi.moonshot.cn ` ,
. . . FAKE_HEADERS
}
} ) ;
checkResult ( result , refreshToken ) ;
return fileId ;
}
/**
* 检查请求结果
*
* @param result 结果
* @param refreshToken 用于刷新access_token的refresh_token
*/
function checkResult ( result : AxiosResponse , refreshToken : string ) {
if ( result . status == 401 ) {
if ( result . status == 401 ) {
accessTokenMap . delete ( refreshToken ) ;
throw new APIException ( EX . API_REQUEST_FAILED ) ;
}
@@ -236,33 +412,45 @@ function checkResult(result: AxiosResponse, refreshToken: string) {
throw new APIException ( EX . API_REQUEST_FAILED , ` [请求kimi失败]: ${ message } ` ) ;
}
/**
* 从流接收完整的消息内容
*
* @param convId 会话ID
* @param stream 消息流
*/
async function receiveStream ( convId : string , stream : any ) {
return new Promise ( ( resolve , reject ) = > {
// 第一条消息初始化
const data = {
id : convId ,
model : 'kimi' ,
model : MODEL_NAME ,
object : 'chat.completion' ,
choices : [
{ index : 0 , message : { role : 'assistant' , content : '' } , finish_reason : 'stop' }
] ,
created : parseInt ( performance . now ( ) a s any )
usage : { prompt_tokens : 0 , completion_tokens : 0 , total_token s: 0 } ,
created : util.unixTimestamp ( )
} ;
let refContent = '' ;
const parser = createParser ( event = > {
try {
if ( event . type !== "event" ) return ;
// 解析JSON
const result = _ . attempt ( ( ) = > JSON . parse ( event . data ) ) ;
if ( _ . isError ( result ) )
throw new Error ( ` s tream response invalid: ${ event . data } ` ) ;
throw new Error ( ` S tream response invalid: ${ event . data } ` ) ;
// 处理消息
if ( result . event == 'cmpl' ) {
data . choices [ 0 ] . message . content += result . text ;
}
// 处理结束或错误
else if ( result . event == 'all_done' || result . event == 'error' ) {
data . choices [ 0 ] . message . content += ( result . event == 'error' ? '\n[内容由于不合规被停止生成,我们换个话题吧]' : '' ) + ( refContent ? ` \ n \ n搜索结果来自: \ n ${ refContent } ` : '' ) ;
refContent = '' ;
resolve ( data ) ;
}
else if ( result . event == 'search_plus' && result . msg && result . msg . type == 'get_res' )
// 处理联网搜索
else if ( result . event == 'search_plus' && result . msg && result . msg . type == 'get_res' )
refContent += ` ${ result . msg . title } ( ${ result . msg . url } ) \ n ` ;
// else
// logger.warn(result.event, result);
@@ -272,19 +460,31 @@ async function receiveStream(convId: string, stream: any) {
reject ( err ) ;
}
} ) ;
// 将流数据喂给SSE转换器
stream . on ( "data" , buffer = > parser . feed ( buffer . toString ( ) ) ) ;
stream . once ( "error" , err = > reject ( err ) ) ;
stream . once ( "close" , ( ) = > resolve ( data ) ) ;
} ) ;
}
/**
* 创建转换流
*
* 将流格式转换为gpt兼容流格式
*
* @param convId 会话ID
* @param stream 消息流
* @param endCallback 传输结束回调
*/
function createTransStream ( convId : string , stream : any , endCallback? : Function ) {
const created = parseInt ( performance . now ( ) as any ) ;
// 消息创建时间
const created = util . unixTimestamp ( ) ;
// 创建转换流
const transStream = new PassThrough ( ) ;
let searchFlag = false ;
! transStream . closed && transStream . write ( ` data: ${ JSON . stringify ( {
id : convId ,
model : 'kimi' ,
model : MODEL_NAME ,
object : 'chat.completion.chunk' ,
choices : [
{ index : 0 , delta : { role : 'assistant' , content : '' } , finish_reason: null }
@@ -294,50 +494,59 @@ function createTransStream(convId: string, stream: any, endCallback?: Function)
const parser = createParser ( event = > {
try {
if ( event . type !== "event" ) return ;
// 解析JSON
const result = _ . attempt ( ( ) = > JSON . parse ( event . data ) ) ;
if ( _ . isError ( result ) )
throw new Error ( ` s tream response invalid: ${ event . data } ` ) ;
throw new Error ( ` S tream response invalid: ${ event . data } ` ) ;
// 处理消息
if ( result . event == 'cmpl' ) {
const data = ` data: ${ JSON . stringify ( {
id : convId ,
model : 'kimi' ,
model : MODEL_NAME ,
object : 'chat.completion.chunk' ,
choices : [
{ index : 0 , delta : { content : ( searchFlag ? '\n' : '' ) + result . text } , finish_reason: null }
],
created
})} \ n \ n ` ;
if ( searchFlag )
if ( searchFlag )
searchFlag = false ;
! transStream . closed && transStream . write ( data ) ;
}
// 处理结束或错误
else if ( result . event == 'all_done' || result . event == 'error' ) {
const data = ` data: ${ JSON . stringify ( {
id : convId ,
model : 'kimi' ,
model : MODEL_NAME ,
object : 'chat.completion.chunk' ,
choices : [
{ index : 0 , delta : result.event == 'error' ? {
content : '\n[内容由于不合规被停止生成,我们换个话题吧]'
} : {}, finish_reason: 'stop' }
{
index : 0 , delta : result.event == 'error' ? {
content : '\n[内容由于不合规被停止生成,我们换个话题吧]'
} : {}, finish_reason: 'stop'
}
],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
created
})} \ n \ n ` ;
! transStream . closed && transStream . write ( data ) ;
! transStream . closed && transStream . end ( 'data: [DONE]\n\n' ) ;
endCallback && endCallback ( ) ;
}
else if ( result . event == 'search_plus' && result . msg && result . msg . type == 'get_res' ) {
if ( ! searchFlag )
// 处理联网搜索
else if ( result . event == 'search_plus' && result . msg && result . msg . type == 'get_res' ) {
if ( ! searchFlag )
searchFlag = true ;
const data = ` data: ${ JSON . stringify ( {
id : convId ,
model : 'kimi' ,
model : MODEL_NAME ,
object : 'chat.completion.chunk' ,
choices : [
{ index : 0 , delta : {
content : ` 检索 ${ result . msg . title } ( ${ result . msg . url } ) ... \ n `
} , finish_reason: null }
{
index : 0 , delta : {
content : ` 检索 ${ result . msg . title } ( ${ result . msg . url } ) ... \ n `
} , finish_reason: null
}
],
created
})} \ n \ n ` ;
@@ -351,6 +560,7 @@ function createTransStream(convId: string, stream: any, endCallback?: Function)
! transStream . closed && transStream . end ( '\n\n' ) ;
}
} ) ;
// 将流数据喂给SSE转换器
stream . on ( "data" , buffer = > parser . feed ( buffer . toString ( ) ) ) ;
stream . once ( "error" , ( ) = > ! transStream . closed && transStream . end ( 'data: [DONE]\n\n' ) ) ;
stream . once ( "close" , ( ) = > ! transStream . closed && transStream . end ( 'data: [DONE]\n\n' ) ) ;