| 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) |
| })() |
|
|
| |
|
|