|
import { createServerAdapter } from '@whatwg-node/server' |
|
import { AutoRouter, json, error, cors } from 'itty-router' |
|
import { createServer } from 'http' |
|
import dotenv from 'dotenv' |
|
|
|
dotenv.config() |
|
|
|
class Config { |
|
constructor() { |
|
this.API_PREFIX = process.env.API_PREFIX || '/' |
|
this.API_KEY = process.env.API_KEY || '' |
|
this.MAX_RETRY_COUNT = process.env.MAX_RETRY_COUNT || 3 |
|
this.RETRY_DELAY = process.env.RETRY_DELAY || 5000 |
|
this.FAKE_HEADERS = process.env.FAKE_HEADERS || { |
|
Accept: '*/*', |
|
'Accept-Encoding': 'gzip, deflate, br, zstd', |
|
'Accept-Language': 'zh-CN,zh;q=0.9', |
|
Origin: 'https://duckduckgo.com/', |
|
Cookie: 'l=wt-wt; ah=wt-wt; dcm=6', |
|
Dnt: '1', |
|
Priority: 'u=1, i', |
|
Referer: 'https://duckduckgo.com/', |
|
'Sec-Ch-Ua': '"Microsoft Edge";v="129", "Not(A:Brand";v="8", "Chromium";v="129"', |
|
'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/127.0.0.0 Safari/537.36', |
|
} |
|
} |
|
} |
|
|
|
const config = new Config() |
|
|
|
const { preflight, corsify } = cors({ |
|
origin: '*', |
|
allowMethods: '*', |
|
exposeHeaders: '*', |
|
}) |
|
|
|
const withBenchmarking = (request) => { |
|
request.start = Date.now() |
|
} |
|
|
|
const withAuth = (request) => { |
|
if (config.API_KEY) { |
|
const authHeader = request.headers.get('Authorization') |
|
if (!authHeader || !authHeader.startsWith('Bearer ')) { |
|
return error(401, 'Unauthorized: Missing or invalid Authorization header') |
|
} |
|
const token = authHeader.substring(7) |
|
if (token !== config.API_KEY) { |
|
return error(403, 'Forbidden: Invalid API key') |
|
} |
|
} |
|
} |
|
|
|
const logger = (res, req) => { |
|
console.log(req.method, res.status, req.url, Date.now() - req.start, 'ms') |
|
} |
|
|
|
const router = AutoRouter({ |
|
before: [withBenchmarking, preflight, withAuth], |
|
missing: () => error(404, '404 not found.'), |
|
finally: [corsify, logger], |
|
}) |
|
|
|
router.get('/', () => json({ message: 'API 服务运行中~' })) |
|
router.get('/ping', () => json({ message: 'pong' })) |
|
router.get(config.API_PREFIX + '/v1/models', () => |
|
json({ |
|
object: 'list', |
|
data: [ |
|
{ id: 'gpt-4o-mini', object: 'model', owned_by: 'ddg' }, |
|
{ id: 'claude-3-haiku', object: 'model', owned_by: 'ddg' }, |
|
{ id: 'llama-3.1-70b', object: 'model', owned_by: 'ddg' }, |
|
{ id: 'mixtral-8x7b', object: 'model', owned_by: 'ddg' }, |
|
], |
|
}) |
|
) |
|
|
|
router.post(config.API_PREFIX + '/v1/chat/completions', (req) => handleCompletion(req)) |
|
|
|
async function handleCompletion(request) { |
|
try { |
|
const { model: inputModel, messages, stream: returnStream } = await request.json() |
|
const model = convertModel(inputModel) |
|
const content = messagesPrepare(messages) |
|
return createCompletion(model, content, returnStream) |
|
} catch (err) { |
|
error(500, err.message) |
|
} |
|
} |
|
|
|
async function createCompletion(model, content, returnStream, retryCount = 0) { |
|
const token = await requestToken() |
|
try { |
|
const response = await fetch(`https://duckduckgo.com/duckchat/v1/chat`, { |
|
method: 'POST', |
|
headers: { |
|
...config.FAKE_HEADERS, |
|
Accept: 'text/event-stream', |
|
'Content-Type': 'application/json', |
|
'x-vqd-4': token, |
|
}, |
|
body: JSON.stringify({ |
|
model: model, |
|
messages: [ |
|
{ |
|
role: 'user', |
|
content: content, |
|
}, |
|
], |
|
}), |
|
}) |
|
|
|
if (!response.ok) { |
|
throw new Error(`HTTP error! status: ${response.status}`) |
|
} |
|
return handlerStream(model, response.body, returnStream) |
|
} catch (err) { |
|
console.log(err) |
|
if (retryCount < config.MAX_RETRY_COUNT) { |
|
console.log('Retrying... count', ++retryCount) |
|
await new Promise((resolve) => setTimeout(resolve, config.RETRY_DELAY)) |
|
return await createCompletion(model, content, returnStream, retryCount) |
|
} |
|
throw err |
|
} |
|
} |
|
|
|
async function handlerStream(model, rb, returnStream) { |
|
let bwzChunk = '' |
|
let previousText = '' |
|
const handChunkData = (chunk) => { |
|
chunk = chunk.trim() |
|
if (bwzChunk != '') { |
|
chunk = bwzChunk + chunk |
|
bwzChunk = '' |
|
} |
|
|
|
if (chunk.includes('[DONE]')) { |
|
return chunk |
|
} |
|
|
|
if (chunk.slice(-2) !== '"}') { |
|
bwzChunk = chunk |
|
} |
|
return chunk |
|
} |
|
const reader = rb.getReader() |
|
const decoder = new TextDecoder() |
|
const encoder = new TextEncoder() |
|
const stream = new ReadableStream({ |
|
async start(controller) { |
|
while (true) { |
|
const { done, value } = await reader.read() |
|
if (done) { |
|
return controller.close() |
|
} |
|
const chunkStr = handChunkData(decoder.decode(value)) |
|
if (bwzChunk !== '') { |
|
continue |
|
} |
|
|
|
chunkStr.split('\n').forEach((line) => { |
|
if (line.length < 6) { |
|
return |
|
} |
|
line = line.slice(6) |
|
if (line !== '[DONE]') { |
|
const originReq = JSON.parse(line) |
|
|
|
if (originReq.action !== 'success') { |
|
return controller.error(new Error('Error: originReq stream chunk is not success')) |
|
} |
|
|
|
if (originReq.message) { |
|
previousText += originReq.message |
|
if (returnStream) { |
|
controller.enqueue( |
|
encoder.encode(`data: ${JSON.stringify(newChatCompletionChunkWithModel(originReq.message, originReq.model))}\n\n`) |
|
) |
|
} |
|
} |
|
} else { |
|
if (returnStream) { |
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(newStopChunkWithModel('stop', model))}\n\n`)) |
|
} else { |
|
controller.enqueue(encoder.encode(JSON.stringify(newChatCompletionWithModel(previousText, model)))) |
|
} |
|
return controller.close() |
|
} |
|
}) |
|
continue |
|
} |
|
}, |
|
}) |
|
|
|
return new Response(stream, { |
|
headers: { |
|
'Content-Type': returnStream ? 'text/event-stream' : 'application/json', |
|
}, |
|
}) |
|
} |
|
|
|
function messagesPrepare(messages) { |
|
let content = '' |
|
for (const message of messages) { |
|
let role = message.role === 'system' ? 'user' : message.role |
|
|
|
if (['user', 'assistant'].includes(role)) { |
|
const contentStr = Array.isArray(message.content) |
|
? message.content |
|
.filter((item) => item.text) |
|
.map((item) => item.text) |
|
.join('') || '' |
|
: message.content |
|
content += `${role}:${contentStr};\r\n` |
|
} |
|
} |
|
return content |
|
} |
|
|
|
async function requestToken() { |
|
const response = await fetch(`https://duckduckgo.com/duckchat/v1/status`, { |
|
method: 'GET', |
|
headers: { |
|
...config.FAKE_HEADERS, |
|
'x-vqd-accept': '1', |
|
}, |
|
}) |
|
|
|
if (!response.ok) { |
|
throw new Error(`HTTP error! status: ${response.status}`) |
|
} |
|
|
|
const token = response.headers.get('x-vqd-4') |
|
return token |
|
} |
|
|
|
function convertModel(inputModel) { |
|
let model |
|
switch (inputModel.toLowerCase()) { |
|
case 'claude-3-haiku': |
|
model = 'claude-3-haiku-20240307' |
|
break |
|
case 'llama-3.1-70b': |
|
model = 'meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo' |
|
break |
|
case 'mixtral-8x7b': |
|
model = 'mistralai/Mixtral-8x7B-Instruct-v0.1' |
|
break |
|
} |
|
return model || 'gpt-4o-mini' |
|
} |
|
|
|
function newChatCompletionChunkWithModel(text, model) { |
|
return { |
|
id: 'chatcmpl-QXlha2FBbmROaXhpZUFyZUF3ZXNvbWUK', |
|
object: 'chat.completion.chunk', |
|
created: 0, |
|
model, |
|
choices: [ |
|
{ |
|
index: 0, |
|
delta: { |
|
content: text, |
|
}, |
|
finish_reason: null, |
|
}, |
|
], |
|
} |
|
} |
|
|
|
function newStopChunkWithModel(reason, model) { |
|
return { |
|
id: 'chatcmpl-QXlha2FBbmROaXhpZUFyZUF3ZXNvbWUK', |
|
object: 'chat.completion.chunk', |
|
created: 0, |
|
model, |
|
choices: [ |
|
{ |
|
index: 0, |
|
finish_reason: reason, |
|
}, |
|
], |
|
} |
|
} |
|
|
|
function newChatCompletionWithModel(text, model) { |
|
return { |
|
id: 'chatcmpl-QXlha2FBbmROaXhpZUFyZUF3ZXNvbWUK', |
|
object: 'chat.completion', |
|
created: 0, |
|
model, |
|
usage: { |
|
prompt_tokens: 0, |
|
completion_tokens: 0, |
|
total_tokens: 0, |
|
}, |
|
choices: [ |
|
{ |
|
message: { |
|
content: text, |
|
role: 'assistant', |
|
}, |
|
index: 0, |
|
}, |
|
], |
|
} |
|
} |
|
|
|
|
|
|
|
;(async () => { |
|
|
|
if (typeof addEventListener === 'function') return |
|
|
|
const ittyServer = createServerAdapter(router.fetch) |
|
console.log(`Listening on http://localhost:${process.env.PORT || 8787}`) |
|
const httpServer = createServer(ittyServer) |
|
httpServer.listen(8787) |
|
})() |
|
|
|
|
|
|