|
|
import { PassThrough } from 'stream'; |
|
|
import path from 'path'; |
|
|
import _ from 'lodash'; |
|
|
import mime from 'mime'; |
|
|
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; |
|
|
|
|
|
import type IStreamMessage from '../interfaces/IStreamMessage.ts'; |
|
|
import APIException from '@/lib/exceptions/APIException.ts'; |
|
|
import EX from '@/api/consts/exceptions.ts'; |
|
|
import { createParser } from 'eventsource-parser' |
|
|
import logger from '@/lib/logger.ts'; |
|
|
import util from '@/lib/util.ts'; |
|
|
|
|
|
|
|
|
const MODEL_NAME = 'kimi'; |
|
|
|
|
|
const DEVICE_ID = Math.random() * 999999999999999999 + 7000000000000000000; |
|
|
|
|
|
const SESSION_ID = Math.random() * 99999999999999999 + 1700000000000000000; |
|
|
|
|
|
const ACCESS_TOKEN_EXPIRES = 300; |
|
|
|
|
|
const MAX_RETRY_COUNT = 3; |
|
|
|
|
|
const RETRY_DELAY = 5000; |
|
|
|
|
|
const BASE_URL = 'https://kimi.moonshot.cn'; |
|
|
|
|
|
const FAKE_HEADERS = { |
|
|
'Accept': '*/*', |
|
|
'Accept-Encoding': 'gzip, deflate, br, zstd', |
|
|
|
|
|
'Cache-Control': 'no-cache', |
|
|
'Pragma': 'no-cache', |
|
|
'Origin': BASE_URL, |
|
|
'Cookie': util.generateCookie(), |
|
|
'R-Timezone': 'Asia/Jakarta', |
|
|
'Sec-Ch-Ua': '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"', |
|
|
'Sec-Ch-Ua-Mobile': '?0', |
|
|
'Sec-Ch-Ua-Platform': '"Windows"', |
|
|
'Sec-Fetch-Dest': 'empty', |
|
|
'Sec-Fetch-Mode': 'cors', |
|
|
'Sec-Fetch-Site': 'same-origin', |
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', |
|
|
'Priority': 'u=1, i', |
|
|
'X-Language': 'en-US', |
|
|
'X-Msh-Device-Id': DEVICE_ID, |
|
|
'X-Msh-Platform': 'web', |
|
|
'X-Msh-Session-Id': SESSION_ID |
|
|
}; |
|
|
|
|
|
const FILE_MAX_SIZE = 100 * 1024 * 1024; |
|
|
|
|
|
const accessTokenMap = new Map(); |
|
|
|
|
|
const accessTokenRequestQueueMap: Record<string, Function[]> = {}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function requestToken(refreshToken: string) { |
|
|
if (accessTokenRequestQueueMap[refreshToken]) |
|
|
return new Promise(resolve => accessTokenRequestQueueMap[refreshToken].push(resolve)); |
|
|
accessTokenRequestQueueMap[refreshToken] = []; |
|
|
logger.info(`Refresh token: ${refreshToken}`); |
|
|
const result = await (async () => { |
|
|
const result = await axios.get(`${BASE_URL}/api/auth/token/refresh`, { |
|
|
headers: { |
|
|
Authorization: `Bearer ${refreshToken}`, |
|
|
...FAKE_HEADERS, |
|
|
}, |
|
|
timeout: 15000, |
|
|
validateStatus: () => true |
|
|
}); |
|
|
const { |
|
|
access_token, |
|
|
refresh_token |
|
|
} = checkResult(result, refreshToken); |
|
|
const userResult = await axios.get(`${BASE_URL}/api/user`, { |
|
|
headers: { |
|
|
Authorization: `Bearer ${access_token}`, |
|
|
...FAKE_HEADERS, |
|
|
}, |
|
|
timeout: 15000, |
|
|
validateStatus: () => true |
|
|
}); |
|
|
if(!userResult.data.id) |
|
|
throw new APIException(EX.API_REQUEST_FAILED, 'Failed to obtain user information'); |
|
|
return { |
|
|
userId: userResult.data.id, |
|
|
accessToken: access_token, |
|
|
refreshToken: refresh_token, |
|
|
refreshTime: util.unixTimestamp() + ACCESS_TOKEN_EXPIRES |
|
|
} |
|
|
})() |
|
|
.then(result => { |
|
|
if (accessTokenRequestQueueMap[refreshToken]) { |
|
|
accessTokenRequestQueueMap[refreshToken].forEach(resolve => resolve(result)); |
|
|
delete accessTokenRequestQueueMap[refreshToken]; |
|
|
} |
|
|
logger.success(`Refresh successful`); |
|
|
return result; |
|
|
}) |
|
|
.catch(err => { |
|
|
logger.error(err); |
|
|
if (accessTokenRequestQueueMap[refreshToken]) { |
|
|
accessTokenRequestQueueMap[refreshToken].forEach(resolve => resolve(err)); |
|
|
delete accessTokenRequestQueueMap[refreshToken]; |
|
|
} |
|
|
return err; |
|
|
}); |
|
|
if (_.isError(result)) |
|
|
throw result; |
|
|
return result; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function acquireToken(refreshToken: string): Promise<any> { |
|
|
let result = accessTokenMap.get(refreshToken); |
|
|
if (!result) { |
|
|
result = await requestToken(refreshToken); |
|
|
accessTokenMap.set(refreshToken, result); |
|
|
} |
|
|
if (util.unixTimestamp() > result.refreshTime) { |
|
|
result = await requestToken(refreshToken); |
|
|
accessTokenMap.set(refreshToken, result); |
|
|
} |
|
|
return result; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function request( |
|
|
method: string, |
|
|
uri: string, |
|
|
refreshToken: string, |
|
|
options: AxiosRequestConfig = {} |
|
|
) { |
|
|
const { |
|
|
accessToken, |
|
|
userId |
|
|
} = await acquireToken(refreshToken); |
|
|
logger.info(`url: ${uri}`); |
|
|
const result = await axios({ |
|
|
method, |
|
|
url: `${BASE_URL}${uri}`, |
|
|
params: options.params, |
|
|
data: options.data, |
|
|
headers: { |
|
|
Authorization: `Bearer ${accessToken}`, |
|
|
'X-Traffic-Id': userId, |
|
|
...FAKE_HEADERS, |
|
|
...(options.headers || {}) |
|
|
}, |
|
|
timeout: options.timeout || 15000, |
|
|
responseType: options.responseType, |
|
|
validateStatus: () => true |
|
|
}); |
|
|
return checkResult(result, refreshToken); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function createConversation(model: string, name: string, refreshToken: string) { |
|
|
const { |
|
|
id: convId |
|
|
} = await request('POST', '/api/chat', refreshToken, { |
|
|
data: { |
|
|
enter_method: 'new_chat', |
|
|
is_example: false, |
|
|
kimiplus_id: /^[0-9a-z]{20}$/.test(model) ? model : 'kimi', |
|
|
name |
|
|
} |
|
|
}); |
|
|
return convId; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function removeConversation(convId: string, refreshToken: string) { |
|
|
return await request('DELETE', `/api/chat/${convId}`, refreshToken); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function getSuggestion(query: string, refreshToken: string) { |
|
|
return await request('POST', '/api/suggestion', refreshToken, { |
|
|
data: { |
|
|
offset: 0, |
|
|
page_referer: 'chat', |
|
|
query: query.replace('user:', '').replace('assistant:', ''), |
|
|
scene: 'first_round', |
|
|
size: 10 |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function preN2s(model: string, messages: { role: string, content: string }[], refs: string[], refreshToken: string, refConvId?: string) { |
|
|
const isSearchModel = model.indexOf('search') != -1; |
|
|
return await request('POST', `/api/chat/${refConvId}/pre-n2s`, refreshToken, { |
|
|
data: { |
|
|
is_pro_search: false, |
|
|
kimiplus_id: /^[0-9a-z]{20}$/.test(model) ? model : 'kimi', |
|
|
messages, |
|
|
refs, |
|
|
use_search: isSearchModel |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function tokenSize(query: string, refs: string[], refreshToken: string, refConvId: string) { |
|
|
return await request('POST', `/api/chat/${refConvId}/token_size`, refreshToken, { |
|
|
data: { |
|
|
content: query, |
|
|
refs: [] |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function getResearchUsage(refreshToken: string): Promise<{ |
|
|
remain, |
|
|
total, |
|
|
used |
|
|
}> { |
|
|
return await request('GET', '/api/chat/research/usage', refreshToken); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function createCompletion(model = MODEL_NAME, messages: any[], refreshToken: string, refConvId?: string, retryCount = 0, segmentId?: string): Promise<IStreamMessage> { |
|
|
return (async () => { |
|
|
logger.info(messages); |
|
|
|
|
|
|
|
|
const convId = /[0-9a-zA-Z]{20}/.test(refConvId) ? refConvId : await createConversation(model, "unnamed", refreshToken); |
|
|
|
|
|
|
|
|
const refFileUrls = extractRefFileUrls(messages); |
|
|
const refResults = refFileUrls.length ? await Promise.all(refFileUrls.map(fileUrl => uploadFile(fileUrl, refreshToken, convId))) : []; |
|
|
const refs = refResults.map(result => result.id); |
|
|
const refsFile = refResults.map(result => ({ |
|
|
detail: result, |
|
|
done: true, |
|
|
file: {}, |
|
|
file_info: result, |
|
|
id: result.id, |
|
|
name: result.name, |
|
|
parse_status: 'success', |
|
|
size: result.size, |
|
|
upload_progress: 100, |
|
|
upload_status: 'success' |
|
|
})); |
|
|
|
|
|
|
|
|
fakeRequest(refreshToken) |
|
|
.catch(err => logger.error(err)); |
|
|
|
|
|
|
|
|
const sendMessages = messagesPrepare(messages, !!refConvId); |
|
|
|
|
|
!segmentId && preN2s(model, sendMessages, refs, refreshToken, convId) |
|
|
.catch(err => logger.error(err)); |
|
|
getSuggestion(sendMessages[0].content, refreshToken) |
|
|
.catch(err => logger.error(err)); |
|
|
tokenSize(sendMessages[0].content, refs, refreshToken, convId) |
|
|
.catch(err => logger.error(err)); |
|
|
|
|
|
const isMath = model.indexOf('math') != -1; |
|
|
const isSearchModel = model.indexOf('search') != -1; |
|
|
const isResearchModel = model.indexOf('research') != -1; |
|
|
const isK1Model = model.indexOf('k1') != -1; |
|
|
|
|
|
logger.info(`Model: ${model}, Search: ${isSearchModel}, Explore: ${isResearchModel}, K1: ${isK1Model}, Math: ${isMath}`) ; |
|
|
|
|
|
if(segmentId) |
|
|
logger.info(`Continue request, segmentId: ${segmentId}`); |
|
|
|
|
|
|
|
|
if(isResearchModel) { |
|
|
const { |
|
|
total, |
|
|
used |
|
|
} = await getResearchUsage(refreshToken); |
|
|
if(used >= total) |
|
|
throw new APIException(EX.API_RESEARCH_EXCEEDS_LIMIT, `Discovery Edition usage has reached the upper limit`); |
|
|
logger.info(`Current quota of Discovery Edition: ${used}/${total}`); |
|
|
} |
|
|
|
|
|
const kimiplusId = isK1Model ? 'crm40ee9e5jvhsn7ptcg' : (/^[0-9a-z]{20}$/.test(model) ? model : 'kimi'); |
|
|
|
|
|
|
|
|
const stream = await request('POST', `/api/chat/${convId}/completion/stream`, refreshToken, { |
|
|
data: segmentId ? { |
|
|
segment_id: segmentId, |
|
|
action: 'continue', |
|
|
messages: [{ role: 'user', content: ' ' }], |
|
|
kimiplus_id: kimiplusId, |
|
|
extend: { sidebar: true } |
|
|
} : { |
|
|
kimiplus_id: kimiplusId, |
|
|
messages: sendMessages, |
|
|
refs, |
|
|
refs_file: refsFile, |
|
|
use_math: isMath, |
|
|
use_research: isResearchModel, |
|
|
use_search: isSearchModel, |
|
|
extend: { sidebar: true } |
|
|
}, |
|
|
headers: { |
|
|
Referer: `https://kimi.moonshot.cn/chat/${convId}` |
|
|
}, |
|
|
responseType: 'stream' |
|
|
}); |
|
|
|
|
|
const streamStartTime = util.timestamp(); |
|
|
|
|
|
|
|
|
const answer = await receiveStream(model, convId, stream); |
|
|
|
|
|
|
|
|
if(answer.choices[0].finish_reason == 'length' && answer.segment_id) { |
|
|
const continueAnswer = await createCompletion(model, [], refreshToken, convId, retryCount, answer.segment_id); |
|
|
answer.choices[0].message.content += continueAnswer.choices[0].message.content; |
|
|
} |
|
|
|
|
|
logger.success(`Stream has completed transfer ${util.timestamp() - streamStartTime}ms`); |
|
|
|
|
|
|
|
|
|
|
|
!refConvId && removeConversation(convId, refreshToken) |
|
|
.catch(err => console.error(err)); |
|
|
|
|
|
return answer; |
|
|
})() |
|
|
.catch(err => { |
|
|
if (retryCount < MAX_RETRY_COUNT) { |
|
|
logger.error(`Stream response error: ${err.message}`); |
|
|
logger.warn(`Try again after ${RETRY_DELAY / 1000}s...`); |
|
|
return (async () => { |
|
|
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY)); |
|
|
return createCompletion(model, messages, refreshToken, refConvId, retryCount + 1); |
|
|
})(); |
|
|
} |
|
|
throw err; |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function createCompletionStream(model = MODEL_NAME, messages: any[], refreshToken: string, refConvId?: string, retryCount = 0) { |
|
|
return (async () => { |
|
|
logger.info(messages); |
|
|
|
|
|
|
|
|
const convId = /[0-9a-zA-Z]{20}/.test(refConvId) ? refConvId : await createConversation(model, "unnamed", refreshToken); |
|
|
|
|
|
|
|
|
const refFileUrls = extractRefFileUrls(messages); |
|
|
const refResults = refFileUrls.length ? await Promise.all(refFileUrls.map(fileUrl => uploadFile(fileUrl, refreshToken, convId))) : []; |
|
|
const refs = refResults.map(result => result.id); |
|
|
const refsFile = refResults.map(result => ({ |
|
|
detail: result, |
|
|
done: true, |
|
|
file: {}, |
|
|
file_info: result, |
|
|
id: result.id, |
|
|
name: result.name, |
|
|
parse_status: 'success', |
|
|
size: result.size, |
|
|
upload_progress: 100, |
|
|
upload_status: 'success' |
|
|
})); |
|
|
|
|
|
|
|
|
fakeRequest(refreshToken) |
|
|
.catch(err => logger.error(err)); |
|
|
|
|
|
const sendMessages = messagesPrepare(messages, !!refConvId); |
|
|
|
|
|
preN2s(model, sendMessages, refs, refreshToken, convId) |
|
|
.catch(err => logger.error(err)); |
|
|
getSuggestion(sendMessages[0].content, refreshToken) |
|
|
.catch(err => logger.error(err)); |
|
|
tokenSize(sendMessages[0].content, refs, refreshToken, convId) |
|
|
.catch(err => logger.error(err)); |
|
|
|
|
|
const isMath = model.indexOf('math') != -1; |
|
|
const isSearchModel = model.indexOf('search') != -1; |
|
|
const isResearchModel = model.indexOf('research') != -1; |
|
|
const isK1Model = model.indexOf('k1') != -1; |
|
|
|
|
|
logger.info(`Model: ${model}, Search: ${isSearchModel}, Explore: ${isResearchModel}, K1: ${isK1Model}, Math: ${isMath}`) ; |
|
|
|
|
|
|
|
|
if(isResearchModel) { |
|
|
const { |
|
|
total, |
|
|
used |
|
|
} = await getResearchUsage(refreshToken); |
|
|
if(used >= total) |
|
|
throw new APIException(EX.API_RESEARCH_EXCEEDS_LIMIT, `Exploration version usage has reached the upper limit`); |
|
|
logger.info(`Current quota of exploration version: ${used}/${total}`); |
|
|
} |
|
|
|
|
|
const kimiplusId = isK1Model ? 'crm40ee9e5jvhsn7ptcg' : (/^[0-9a-z]{20}$/.test(model) ? model : 'kimi'); |
|
|
|
|
|
|
|
|
const stream = await request('POST', `/api/chat/${convId}/completion/stream`, refreshToken, { |
|
|
data: { |
|
|
kimiplus_id: kimiplusId, |
|
|
messages: sendMessages, |
|
|
refs, |
|
|
refs_file: refsFile, |
|
|
use_math: isMath, |
|
|
use_research: isResearchModel, |
|
|
use_search: isSearchModel, |
|
|
extend: { sidebar: true } |
|
|
}, |
|
|
headers: { |
|
|
Referer: `https://kimi.moonshot.cn/chat/${convId}` |
|
|
}, |
|
|
responseType: 'stream' |
|
|
}); |
|
|
|
|
|
const streamStartTime = util.timestamp(); |
|
|
|
|
|
return createTransStream(model, convId, stream, () => { |
|
|
logger.success(`Stream has completed transfer ${util.timestamp() - streamStartTime}ms`); |
|
|
|
|
|
|
|
|
!refConvId && removeConversation(convId, refreshToken) |
|
|
.catch(err => console.error(err)); |
|
|
}); |
|
|
})() |
|
|
.catch(err => { |
|
|
if (retryCount < MAX_RETRY_COUNT) { |
|
|
logger.error(`Stream response error: ${err.message}`); |
|
|
logger.warn(`Try again after ${RETRY_DELAY / 1000}s...`); |
|
|
return (async () => { |
|
|
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY)); |
|
|
return createCompletionStream(model, messages, refreshToken, refConvId, retryCount + 1); |
|
|
})(); |
|
|
} |
|
|
throw err; |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function fakeRequest(refreshToken: string) { |
|
|
await [ |
|
|
() => request('GET', '/api/user', refreshToken), |
|
|
() => request('POST', '/api/user/usage', refreshToken, { |
|
|
data: { |
|
|
usage: ['kimiv', 'math'] |
|
|
} |
|
|
}), |
|
|
() => request('GET', '/api/chat_1m/user/status', refreshToken), |
|
|
() => request('GET', '/api/kimi_mv/user/status', refreshToken), |
|
|
() => request('POST', '/api/kimiplus/history', refreshToken), |
|
|
() => request('POST', '/api/kimiplus/search', refreshToken, { |
|
|
data: { |
|
|
offset: 0, |
|
|
size: 20 |
|
|
} |
|
|
}), |
|
|
() => request('POST', '/api/chat/list', refreshToken, { |
|
|
data: { |
|
|
offset: 0, |
|
|
size: 50 |
|
|
} |
|
|
}), |
|
|
][Math.floor(Math.random() * 7)](); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function extractRefFileUrls(messages: any[]) { |
|
|
const urls = []; |
|
|
|
|
|
if (!messages.length) { |
|
|
return urls; |
|
|
} |
|
|
|
|
|
const lastMessage = messages[messages.length - 1]; |
|
|
if (_.isArray(lastMessage.content)) { |
|
|
lastMessage.content.forEach(v => { |
|
|
if (!_.isObject(v) || !['file', 'image_url'].includes(v['type'])) |
|
|
return; |
|
|
|
|
|
if (v['type'] == 'file' && _.isObject(v['file_url']) && _.isString(v['file_url']['url'])) |
|
|
urls.push(v['file_url']['url']); |
|
|
|
|
|
else if (v['type'] == 'image_url' && _.isObject(v['image_url']) && _.isString(v['image_url']['url'])) |
|
|
urls.push(v['image_url']['url']); |
|
|
}); |
|
|
} |
|
|
logger.info("本次请求上传:" + urls.length + "个文件"); |
|
|
return urls; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function messagesPrepare(messages: any[], isRefConv = false) { |
|
|
let content; |
|
|
if (isRefConv || messages.length < 2) { |
|
|
content = messages.reduce((content, message) => { |
|
|
if (_.isArray(message.content)) { |
|
|
return message.content.reduce((_content, v) => { |
|
|
if (!_.isObject(v) || v['type'] != 'text') return _content; |
|
|
return _content + `${v["text"] || ""}\n`; |
|
|
}, content); |
|
|
} |
|
|
return content += `${message.role == 'user' ? wrapUrlsToTags(message.content) : message.content}\n`; |
|
|
}, '') |
|
|
logger.info("\nTransparent transmission content:\n" + content); |
|
|
} |
|
|
else { |
|
|
|
|
|
let latestMessage = messages[messages.length - 1]; |
|
|
let hasFileOrImage = Array.isArray(latestMessage.content) |
|
|
&& latestMessage.content.some(v => (typeof v === 'object' && ['file', 'image_url'].includes(v['type']))); |
|
|
|
|
|
if (hasFileOrImage) { |
|
|
let newFileMessage = { |
|
|
"content": "Follow the latest files and messages sent by users", |
|
|
"role": "system" |
|
|
}; |
|
|
messages.splice(messages.length - 1, 0, newFileMessage); |
|
|
logger.info("Injecting system prompt"); |
|
|
} else { |
|
|
let newTextMessage = { |
|
|
"content": "Follow the latest news from users", |
|
|
"role": "system" |
|
|
}; |
|
|
messages.splice(messages.length - 1, 0, newTextMessage); |
|
|
logger.info("Injecting system prompt"); |
|
|
} |
|
|
content = messages.reduce((content, message) => { |
|
|
if (_.isArray(message.content)) { |
|
|
return message.content.reduce((_content, v) => { |
|
|
if (!_.isObject(v) || v['type'] != 'text') return _content; |
|
|
return _content + `${message.role || "user"}:${v["text"] || ""}\n`; |
|
|
}, content); |
|
|
} |
|
|
return content += `${message.role || "user"}:${message.role == 'user' ? wrapUrlsToTags(message.content) : message.content}\n`; |
|
|
}, '') |
|
|
logger.info("\nConversation merge:\n" + content); |
|
|
} |
|
|
|
|
|
return [ |
|
|
{ role: 'user', 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(action: string, filename: string, refreshToken: string) { |
|
|
const { |
|
|
accessToken, |
|
|
userId |
|
|
} = await acquireToken(refreshToken); |
|
|
const result = await axios.post('https://kimi.moonshot.cn/api/pre-sign-url', { |
|
|
action, |
|
|
name: filename |
|
|
}, { |
|
|
timeout: 15000, |
|
|
headers: { |
|
|
Authorization: `Bearer ${accessToken}`, |
|
|
Referer: `https://kimi.moonshot.cn/`, |
|
|
'X-Traffic-Id': userId, |
|
|
...FAKE_HEADERS |
|
|
}, |
|
|
validateStatus: () => true |
|
|
}); |
|
|
return checkResult(result, refreshToken); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function checkFileUrl(fileUrl: string) { |
|
|
if (util.isBASE64Data(fileUrl)) |
|
|
return; |
|
|
const result = await axios.head(fileUrl, { |
|
|
timeout: 15000, |
|
|
validateStatus: () => true |
|
|
}); |
|
|
if (result.status >= 400) |
|
|
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`); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function uploadFile(fileUrl: string, refreshToken: string, refConvId?: string) { |
|
|
|
|
|
await checkFileUrl(fileUrl); |
|
|
|
|
|
let filename, fileData, mimeType; |
|
|
|
|
|
if (util.isBASE64Data(fileUrl)) { |
|
|
mimeType = util.extractBASE64DataFormat(fileUrl); |
|
|
const ext = mime.getExtension(mimeType); |
|
|
filename = `${util.uuid()}.${ext}`; |
|
|
fileData = Buffer.from(util.removeBASE64DataHeader(fileUrl), 'base64'); |
|
|
} |
|
|
|
|
|
else { |
|
|
filename = path.basename(fileUrl); |
|
|
({ data: fileData } = await axios.get(fileUrl, { |
|
|
responseType: 'arraybuffer', |
|
|
|
|
|
maxContentLength: FILE_MAX_SIZE, |
|
|
|
|
|
timeout: 60000 |
|
|
})); |
|
|
} |
|
|
|
|
|
const fileType = (mimeType || '').includes('image') ? 'image' : 'file'; |
|
|
|
|
|
|
|
|
let { |
|
|
url: uploadUrl, |
|
|
object_name: objectName, |
|
|
file_id: fileId |
|
|
} = await preSignUrl(fileType, filename, refreshToken); |
|
|
|
|
|
|
|
|
mimeType = mimeType || mime.getType(filename); |
|
|
|
|
|
const { |
|
|
accessToken, |
|
|
userId |
|
|
} = await acquireToken(refreshToken); |
|
|
let result = await axios.request({ |
|
|
method: 'PUT', |
|
|
url: uploadUrl, |
|
|
data: fileData, |
|
|
|
|
|
maxBodyLength: FILE_MAX_SIZE, |
|
|
|
|
|
timeout: 120000, |
|
|
headers: { |
|
|
'Content-Type': mimeType, |
|
|
Authorization: `Bearer ${accessToken}`, |
|
|
Referer: `https://kimi.moonshot.cn/`, |
|
|
'X-Traffic-Id': userId, |
|
|
...FAKE_HEADERS |
|
|
}, |
|
|
validateStatus: () => true |
|
|
}); |
|
|
checkResult(result, refreshToken); |
|
|
|
|
|
let status, startTime = Date.now(); |
|
|
let fileDetail; |
|
|
while (status != 'initialized' && status != 'parsed') { |
|
|
if (Date.now() - startTime > 30000) |
|
|
throw new Error('文件等待处理超时'); |
|
|
|
|
|
result = await axios.post('https://kimi.moonshot.cn/api/file', fileType == 'image' ? { |
|
|
type: 'image', |
|
|
file_id: fileId, |
|
|
name: filename |
|
|
} : { |
|
|
type: 'file', |
|
|
name: filename, |
|
|
object_name: objectName, |
|
|
file_id: '', |
|
|
chat_id: refConvId |
|
|
}, { |
|
|
headers: { |
|
|
Authorization: `Bearer ${accessToken}`, |
|
|
Referer: `https://kimi.moonshot.cn/`, |
|
|
'X-Traffic-Id': userId, |
|
|
...FAKE_HEADERS |
|
|
} |
|
|
}); |
|
|
fileDetail = checkResult(result, refreshToken); |
|
|
({ id: fileId, status } = fileDetail); |
|
|
} |
|
|
|
|
|
startTime = Date.now(); |
|
|
let parseFinish = status == 'parsed'; |
|
|
while (!parseFinish) { |
|
|
if (Date.now() - startTime > 30000) |
|
|
throw new Error('文件等待处理超时'); |
|
|
|
|
|
parseFinish = await new Promise(resolve => { |
|
|
axios.post('https://kimi.moonshot.cn/api/file/parse_process', { |
|
|
ids: [fileId], |
|
|
timeout: 120000 |
|
|
}, { |
|
|
headers: { |
|
|
Authorization: `Bearer ${accessToken}`, |
|
|
Referer: `https://kimi.moonshot.cn/`, |
|
|
'X-Traffic-Id': userId, |
|
|
...FAKE_HEADERS |
|
|
} |
|
|
}) |
|
|
.then(() => resolve(true)) |
|
|
.catch(() => resolve(false)); |
|
|
}); |
|
|
} |
|
|
|
|
|
return fileDetail; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function checkResult(result: AxiosResponse, refreshToken: string) { |
|
|
if (result.status == 401) { |
|
|
accessTokenMap.delete(refreshToken); |
|
|
throw new APIException(EX.API_REQUEST_FAILED); |
|
|
} |
|
|
if (!result.data) |
|
|
return null; |
|
|
const { error_type, message } = result.data; |
|
|
if (!_.isString(error_type)) |
|
|
return result.data; |
|
|
if (error_type == 'auth.token.invalid') |
|
|
accessTokenMap.delete(refreshToken); |
|
|
if (error_type == 'chat.user_stream_pushing') |
|
|
throw new APIException(EX.API_CHAT_STREAM_PUSHING); |
|
|
throw new APIException(EX.API_REQUEST_FAILED, `[请求kimi失败]: ${message}`); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function receiveStream(model: string, convId: string, stream: any): Promise<IStreamMessage> { |
|
|
let webSearchCount = 0; |
|
|
let temp = Buffer.from(''); |
|
|
return new Promise((resolve, reject) => { |
|
|
|
|
|
const data = { |
|
|
id: convId, |
|
|
model, |
|
|
object: 'chat.completion', |
|
|
choices: [ |
|
|
{ index: 0, message: { role: 'assistant', content: '' }, finish_reason: 'stop' } |
|
|
], |
|
|
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, |
|
|
segment_id: '', |
|
|
created: util.unixTimestamp() |
|
|
}; |
|
|
let refContent = ''; |
|
|
const silentSearch = model.indexOf('silent') != -1; |
|
|
const parser = createParser(event => { |
|
|
try { |
|
|
if (event.type !== "event") return; |
|
|
|
|
|
const result = _.attempt(() => JSON.parse(event.data)); |
|
|
if (_.isError(result)) |
|
|
throw new Error(`Stream response invalid: ${event.data}`); |
|
|
|
|
|
if (result.event == 'cmpl' && result.text) { |
|
|
data.choices[0].message.content += result.text; |
|
|
} |
|
|
|
|
|
else if(result.event == 'req') { |
|
|
data.segment_id = result.id; |
|
|
} |
|
|
|
|
|
else if(result.event == 'length') { |
|
|
logger.warn('此次生成达到max_tokens,稍候将继续请求拼接完整响应'); |
|
|
data.choices[0].finish_reason = 'length'; |
|
|
} |
|
|
|
|
|
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 (!silentSearch && result.event == 'search_plus' && result.msg && result.msg.type == 'get_res') { |
|
|
webSearchCount += 1; |
|
|
refContent += `【检索 ${webSearchCount}】 [${result.msg.title}](${result.msg.url})\n\n`; |
|
|
} |
|
|
|
|
|
|
|
|
} |
|
|
catch (err) { |
|
|
logger.error(err); |
|
|
reject(err); |
|
|
} |
|
|
}); |
|
|
|
|
|
stream.on("data", buffer => { |
|
|
|
|
|
if (buffer.toString().indexOf('�') != -1) { |
|
|
|
|
|
temp = Buffer.concat([temp, buffer]); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (temp.length > 0) { |
|
|
buffer = Buffer.concat([temp, buffer]); |
|
|
temp = Buffer.from(''); |
|
|
} |
|
|
parser.feed(buffer.toString()); |
|
|
}); |
|
|
stream.once("error", err => reject(err)); |
|
|
stream.once("close", () => resolve(data)); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function createTransStream(model: string, convId: string, stream: any, endCallback?: Function) { |
|
|
|
|
|
const created = util.unixTimestamp(); |
|
|
|
|
|
const transStream = new PassThrough(); |
|
|
let webSearchCount = 0; |
|
|
let searchFlag = false; |
|
|
let lengthExceed = false; |
|
|
let segmentId = ''; |
|
|
const silentSearch = model.indexOf('silent') != -1; |
|
|
!transStream.closed && transStream.write(`data: ${JSON.stringify({ |
|
|
id: convId, |
|
|
model, |
|
|
object: 'chat.completion.chunk', |
|
|
choices: [ |
|
|
{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null } |
|
|
], |
|
|
segment_id: '', |
|
|
created |
|
|
})}\n\n`); |
|
|
const parser = createParser(event => { |
|
|
try { |
|
|
if (event.type !== "event") return; |
|
|
|
|
|
const result = _.attempt(() => JSON.parse(event.data)); |
|
|
if (_.isError(result)) |
|
|
throw new Error(`Stream response invalid: ${event.data}`); |
|
|
|
|
|
if (result.event == 'cmpl') { |
|
|
const exceptCharIndex = result.text.indexOf("�"); |
|
|
const chunk = result.text.substring(0, exceptCharIndex == -1 ? result.text.length : exceptCharIndex); |
|
|
const data = `data: ${JSON.stringify({ |
|
|
id: convId, |
|
|
model, |
|
|
object: 'chat.completion.chunk', |
|
|
choices: [ |
|
|
{ index: 0, delta: { content: (searchFlag ? '\n' : '') + chunk }, finish_reason: null } |
|
|
], |
|
|
segment_id: segmentId, |
|
|
created |
|
|
})}\n\n`; |
|
|
if (searchFlag) |
|
|
searchFlag = false; |
|
|
!transStream.closed && transStream.write(data); |
|
|
} |
|
|
|
|
|
else if(result.event == 'req') { |
|
|
segmentId = result.id; |
|
|
} |
|
|
|
|
|
else if (result.event == 'length') { |
|
|
lengthExceed = true; |
|
|
} |
|
|
|
|
|
else if (result.event == 'all_done' || result.event == 'error') { |
|
|
const data = `data: ${JSON.stringify({ |
|
|
id: convId, |
|
|
model, |
|
|
object: 'chat.completion.chunk', |
|
|
choices: [ |
|
|
{ |
|
|
index: 0, delta: result.event == 'error' ? { |
|
|
content: '\n[Content generation was stopped due to non-compliance]' |
|
|
} : {}, finish_reason: lengthExceed ? 'length' : 'stop' |
|
|
} |
|
|
], |
|
|
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, |
|
|
segment_id: segmentId, |
|
|
created |
|
|
})}\n\n`; |
|
|
!transStream.closed && transStream.write(data); |
|
|
!transStream.closed && transStream.end('data: [DONE]\n\n'); |
|
|
endCallback && endCallback(); |
|
|
} |
|
|
|
|
|
else if (!silentSearch && result.event == 'search_plus' && result.msg && result.msg.type == 'get_res') { |
|
|
if (!searchFlag) |
|
|
searchFlag = true; |
|
|
webSearchCount += 1; |
|
|
const data = `data: ${JSON.stringify({ |
|
|
id: convId, |
|
|
model, |
|
|
object: 'chat.completion.chunk', |
|
|
choices: [ |
|
|
{ |
|
|
index: 0, delta: { |
|
|
content: `【检索 ${webSearchCount}】 [${result.msg.title}](${result.msg.url})\n` |
|
|
}, finish_reason: null |
|
|
} |
|
|
], |
|
|
segment_id: segmentId, |
|
|
created |
|
|
})}\n\n`; |
|
|
!transStream.closed && transStream.write(data); |
|
|
} |
|
|
|
|
|
|
|
|
} |
|
|
catch (err) { |
|
|
logger.error(err); |
|
|
!transStream.closed && transStream.end('\n\n'); |
|
|
} |
|
|
}); |
|
|
|
|
|
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')); |
|
|
return transStream; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function tokenSplit(authorization: string) { |
|
|
return authorization.replace('Bearer ', '').split(','); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function getTokenLiveStatus(refreshToken: string) { |
|
|
const result = await axios.get('https://kimi.moonshot.cn/api/auth/token/refresh', { |
|
|
headers: { |
|
|
Authorization: `Bearer ${refreshToken}`, |
|
|
Referer: 'https://kimi.moonshot.cn/', |
|
|
...FAKE_HEADERS |
|
|
}, |
|
|
timeout: 15000, |
|
|
validateStatus: () => true |
|
|
}); |
|
|
try { |
|
|
const { |
|
|
access_token, |
|
|
refresh_token |
|
|
} = checkResult(result, refreshToken); |
|
|
return !!(access_token && refresh_token) |
|
|
} |
|
|
catch (err) { |
|
|
return false; |
|
|
} |
|
|
} |
|
|
|
|
|
export default { |
|
|
createConversation, |
|
|
createCompletion, |
|
|
createCompletionStream, |
|
|
getTokenLiveStatus, |
|
|
tokenSplit |
|
|
}; |
|
|
|