Upload 17 files
Browse files- .github/workflows/commitlint.yml +13 -0
- .github/workflows/deno.yml +13 -0
- .github/workflows/docker.yml +33 -0
- src/api/chat.ts +171 -0
- src/api/models.ts +28 -0
- src/auth.ts +12 -0
- src/cache.ts +47 -0
- src/cron.ts +14 -0
- src/limit.ts +22 -0
.github/workflows/commitlint.yml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Lint Commit Messages
|
| 2 |
+
on: [pull_request, push]
|
| 3 |
+
|
| 4 |
+
permissions:
|
| 5 |
+
contents: read
|
| 6 |
+
pull-requests: read
|
| 7 |
+
|
| 8 |
+
jobs:
|
| 9 |
+
commitlint:
|
| 10 |
+
runs-on: ubuntu-latest
|
| 11 |
+
steps:
|
| 12 |
+
- uses: actions/checkout@v4
|
| 13 |
+
- uses: wagoid/commitlint-github-action@v6
|
.github/workflows/deno.yml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Deno Check
|
| 2 |
+
|
| 3 |
+
on: [push, pull_request]
|
| 4 |
+
|
| 5 |
+
jobs:
|
| 6 |
+
build:
|
| 7 |
+
runs-on: ubuntu-latest
|
| 8 |
+
steps:
|
| 9 |
+
- uses: actions/checkout@v4
|
| 10 |
+
- uses: denoland/setup-deno@v1
|
| 11 |
+
- run: deno fmt --check **/*.ts
|
| 12 |
+
- run: deno lint
|
| 13 |
+
|
.github/workflows/docker.yml
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Build Docker Image
|
| 2 |
+
on:
|
| 3 |
+
push:
|
| 4 |
+
tags:
|
| 5 |
+
- 'v[0-9]+.[0-9]+.[0-9]+'
|
| 6 |
+
workflow_dispatch:
|
| 7 |
+
|
| 8 |
+
jobs:
|
| 9 |
+
docker:
|
| 10 |
+
runs-on: ubuntu-latest
|
| 11 |
+
steps:
|
| 12 |
+
- name: Checkout
|
| 13 |
+
uses: actions/checkout@v4
|
| 14 |
+
|
| 15 |
+
- name: Set up QEMU
|
| 16 |
+
uses: docker/setup-qemu-action@v3
|
| 17 |
+
|
| 18 |
+
- name: Set up Docker Buildx
|
| 19 |
+
id: buildx
|
| 20 |
+
uses: docker/setup-buildx-action@v3
|
| 21 |
+
|
| 22 |
+
- name: Login to DockerHub
|
| 23 |
+
uses: docker/login-action@v3
|
| 24 |
+
with:
|
| 25 |
+
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
| 26 |
+
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
| 27 |
+
|
| 28 |
+
- name: Build and push
|
| 29 |
+
uses: docker/build-push-action@v6
|
| 30 |
+
with:
|
| 31 |
+
platforms: linux/amd64,linux/arm64/v8
|
| 32 |
+
push: true
|
| 33 |
+
tags: mumulhl/duckduckgo-ai-chat-service:latest
|
src/api/chat.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Chat, initChat, ModelAlias } from "jsr:@mumulhl/duckduckgo-ai-chat@3";
|
| 2 |
+
import { events } from "jsr:@lukeed/fetch-event-stream";
|
| 3 |
+
|
| 4 |
+
import { Hono } from "jsr:@hono/hono";
|
| 5 |
+
import { BlankEnv, BlankSchema } from "jsr:@hono/hono/types";
|
| 6 |
+
import { SSEStreamingApi, streamSSE } from "jsr:@hono/hono/streaming";
|
| 7 |
+
|
| 8 |
+
import * as cache from "./../cache.ts";
|
| 9 |
+
|
| 10 |
+
type Messages = { content: string; role: "user" | "assistant" | "system" }[];
|
| 11 |
+
|
| 12 |
+
type MessageData = {
|
| 13 |
+
id: string;
|
| 14 |
+
model: string;
|
| 15 |
+
created: number;
|
| 16 |
+
role?: "assistant";
|
| 17 |
+
message?: string;
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
class DataStream {
|
| 21 |
+
id: string;
|
| 22 |
+
model: string;
|
| 23 |
+
created: number;
|
| 24 |
+
choices: {
|
| 25 |
+
delta: { content?: string; role?: "assistant" };
|
| 26 |
+
}[];
|
| 27 |
+
|
| 28 |
+
constructor(messageData: MessageData) {
|
| 29 |
+
this.id = messageData.id;
|
| 30 |
+
this.model = messageData.model;
|
| 31 |
+
this.created = messageData.created;
|
| 32 |
+
this.choices = [{ delta: { content: messageData.message } }];
|
| 33 |
+
if (messageData.role === "assistant") {
|
| 34 |
+
this.choices[0].delta.role = "assistant";
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
async function fetchFull(chat: Chat, messages: Messages) {
|
| 40 |
+
let message: Response | undefined;
|
| 41 |
+
let text: string = ""; // Initialize text
|
| 42 |
+
let messageData: MessageData | undefined;
|
| 43 |
+
|
| 44 |
+
for (let i = 0; i < messages.length; i += 2) {
|
| 45 |
+
text = ""; // Reset the text at each loop to avoid stacking
|
| 46 |
+
const content = messages[i].content;
|
| 47 |
+
message = await chat.fetch(content);
|
| 48 |
+
|
| 49 |
+
const stream = events(message as Response);
|
| 50 |
+
for await (const event of stream) {
|
| 51 |
+
if (!event.data || event.data === "[DONE]") {
|
| 52 |
+
break; // End the loop if there's no data or received end message
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
try {
|
| 56 |
+
messageData = JSON.parse(event.data) as MessageData;
|
| 57 |
+
if (messageData.message === undefined) {
|
| 58 |
+
break;
|
| 59 |
+
} else {
|
| 60 |
+
text += messageData.message; // Append message content
|
| 61 |
+
}
|
| 62 |
+
} catch (e) {
|
| 63 |
+
console.error("Failed to parse JSON:", e);
|
| 64 |
+
break; // End the loop on parse error
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
const newVqd = message.headers.get("x-vqd-4") as string;
|
| 69 |
+
chat.oldVqd = chat.newVqd;
|
| 70 |
+
chat.newVqd = newVqd;
|
| 71 |
+
|
| 72 |
+
chat.messages.push({ content: text, role: "assistant" });
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
const { id, created, model } = messageData as MessageData;
|
| 76 |
+
|
| 77 |
+
return { id, created, model, text };
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
function fetchStream(chat: Chat, messages: Messages) {
|
| 81 |
+
return async (s: SSEStreamingApi) => {
|
| 82 |
+
for (let i = 0; i < messages.length; i += 2) {
|
| 83 |
+
let text = ""; // Reset the text at each loop to avoid stacking
|
| 84 |
+
let messageData: MessageData | undefined;
|
| 85 |
+
|
| 86 |
+
const content = messages[i].content;
|
| 87 |
+
const message = await chat.fetch(content);
|
| 88 |
+
|
| 89 |
+
const stream = events(message as Response);
|
| 90 |
+
|
| 91 |
+
for await (const event of stream) {
|
| 92 |
+
if (!event.data || event.data === "[DONE]") {
|
| 93 |
+
break; // End the loop if there's no data or received end message
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
try {
|
| 97 |
+
messageData = JSON.parse(event.data);
|
| 98 |
+
if (messageData?.message === undefined) {
|
| 99 |
+
break;
|
| 100 |
+
} else {
|
| 101 |
+
text += messageData.message; // Append message content
|
| 102 |
+
}
|
| 103 |
+
} catch (e) {
|
| 104 |
+
console.error("Failed to parse JSON:", e);
|
| 105 |
+
break; // End the loop on parse error
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
if (i === messages.length - 1) {
|
| 109 |
+
const dataStream = new DataStream(messageData);
|
| 110 |
+
await s.writeSSE({
|
| 111 |
+
data: JSON.stringify(dataStream),
|
| 112 |
+
});
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
const newVqd = message.headers.get("x-vqd-4") as string;
|
| 117 |
+
chat.oldVqd = chat.newVqd;
|
| 118 |
+
chat.newVqd = newVqd;
|
| 119 |
+
|
| 120 |
+
chat.messages.push({ content: text, role: "assistant" });
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
if (chat.messages.length >= 4) {
|
| 124 |
+
cache.setCache(chat);
|
| 125 |
+
cache.setRedoCache(chat);
|
| 126 |
+
}
|
| 127 |
+
};
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
function chat(app: Hono<BlankEnv, BlankSchema, "/">) {
|
| 131 |
+
app.post("/v1/chat/completions", async (c) => {
|
| 132 |
+
const body = await c.req.json();
|
| 133 |
+
const stream: boolean = body.stream;
|
| 134 |
+
const model_name: ModelAlias = body.model;
|
| 135 |
+
let messages: Messages = body.messages;
|
| 136 |
+
|
| 137 |
+
if (messages[0].role === "system") {
|
| 138 |
+
messages[1].content = messages[0].content + messages[1].content;
|
| 139 |
+
messages = messages.slice(1);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
let chat = cache.findCache(messages);
|
| 143 |
+
if (chat === undefined) {
|
| 144 |
+
chat = await initChat(model_name);
|
| 145 |
+
} else {
|
| 146 |
+
messages = messages.slice(-1);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
if (stream) {
|
| 150 |
+
return streamSSE(c, fetchStream(chat, messages));
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
const { id, model, created, text } = await fetchFull(chat, messages);
|
| 154 |
+
|
| 155 |
+
if (chat.messages.length >= 4) {
|
| 156 |
+
cache.setCache(chat);
|
| 157 |
+
cache.setRedoCache(chat);
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
return c.json({
|
| 161 |
+
id,
|
| 162 |
+
model,
|
| 163 |
+
created,
|
| 164 |
+
choices: [{
|
| 165 |
+
message: { role: "assistant", content: text },
|
| 166 |
+
}],
|
| 167 |
+
});
|
| 168 |
+
});
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
export { chat };
|
src/api/models.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Hono } from "jsr:@hono/hono";
|
| 2 |
+
import { BlankEnv, BlankSchema } from "jsr:@hono/hono/types";
|
| 3 |
+
|
| 4 |
+
function models(app: Hono<BlankEnv, BlankSchema, "/">) {
|
| 5 |
+
app.get("/v1/models", (c) => {
|
| 6 |
+
return c.json({
|
| 7 |
+
data: [{
|
| 8 |
+
"id": "gpt-4o-mini",
|
| 9 |
+
"object": "model",
|
| 10 |
+
"owned_by": "duckduckgo-chat-ai",
|
| 11 |
+
}, {
|
| 12 |
+
"id": "claude-3-haiku",
|
| 13 |
+
"object": "model",
|
| 14 |
+
"owned_by": "duckduckgo-chat-ai",
|
| 15 |
+
}, {
|
| 16 |
+
"id": "llama",
|
| 17 |
+
"object": "model",
|
| 18 |
+
"owned_by": "duckduckgo-chat-ai",
|
| 19 |
+
}, {
|
| 20 |
+
"id": "mixtral",
|
| 21 |
+
"object": "model",
|
| 22 |
+
"owned_by": "duckduckgo-chat-ai",
|
| 23 |
+
}],
|
| 24 |
+
});
|
| 25 |
+
});
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export { models };
|
src/auth.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Hono } from "jsr:@hono/hono";
|
| 2 |
+
import { bearerAuth } from "jsr:@hono/hono/bearer-auth";
|
| 3 |
+
import { BlankEnv, BlankSchema } from "jsr:@hono/hono/types";
|
| 4 |
+
|
| 5 |
+
function auth(app: Hono<BlankEnv, BlankSchema, "/">) {
|
| 6 |
+
const token = Deno.env.get("TOKEN");
|
| 7 |
+
if (token) {
|
| 8 |
+
app.use("/v1/*", bearerAuth({ token }));
|
| 9 |
+
}
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export { auth };
|
src/cache.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Chat } from "jsr:@mumulhl/duckduckgo-ai-chat@3";
|
| 2 |
+
|
| 3 |
+
type Messages = { content: string; role: "user" | "assistant" | "system" }[];
|
| 4 |
+
|
| 5 |
+
const chatCache = new Map<string, Chat>();
|
| 6 |
+
|
| 7 |
+
function setCache(chat: Chat) {
|
| 8 |
+
const messages_only_content = chat.messages.map((m) => m.content);
|
| 9 |
+
const stringify = JSON.stringify(messages_only_content);
|
| 10 |
+
chatCache.set(stringify, chat);
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
function setRedoCache(chat: Chat) {
|
| 14 |
+
const chatRedo = structuredClone(chat);
|
| 15 |
+
chatRedo.messages.pop();
|
| 16 |
+
chatRedo.messages.pop();
|
| 17 |
+
chatRedo.newVqd = chatRedo.oldVqd;
|
| 18 |
+
setCache(chatRedo);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
function findCache(messages: Messages): Chat | undefined {
|
| 22 |
+
const messages_only_content = messages.map((m) => m.content);
|
| 23 |
+
const stringifyRedo = JSON.stringify(messages_only_content);
|
| 24 |
+
let chat = chatCache.get(stringifyRedo);
|
| 25 |
+
if (chat) {
|
| 26 |
+
// redo
|
| 27 |
+
return chat;
|
| 28 |
+
} else {
|
| 29 |
+
messages_only_content.pop();
|
| 30 |
+
const stringify = JSON.stringify(messages_only_content);
|
| 31 |
+
chat = chatCache.get(stringify);
|
| 32 |
+
removeCache(messages_only_content);
|
| 33 |
+
|
| 34 |
+
messages_only_content.pop();
|
| 35 |
+
messages_only_content.pop();
|
| 36 |
+
removeCache(messages_only_content);
|
| 37 |
+
|
| 38 |
+
return chat;
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function removeCache(messages_only_content: string[]) {
|
| 43 |
+
const stringify = JSON.stringify(messages_only_content);
|
| 44 |
+
chatCache.delete(stringify);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
export { chatCache, findCache, setCache, setRedoCache };
|
src/cron.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as cache from "./cache.ts";
|
| 2 |
+
|
| 3 |
+
function cron() {
|
| 4 |
+
const cron_var = Deno.env.get("CLEAN_CACHE_CRON");
|
| 5 |
+
let cron = 1;
|
| 6 |
+
if (cron_var !== undefined) {
|
| 7 |
+
cron = Number(cron_var);
|
| 8 |
+
}
|
| 9 |
+
Deno.cron("Clean cache", { hour: { every: cron } }, () => {
|
| 10 |
+
cache.chatCache.clear();
|
| 11 |
+
});
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export { cron };
|
src/limit.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Hono } from "jsr:@hono/hono";
|
| 2 |
+
import { BlankEnv, BlankSchema } from "jsr:@hono/hono/types";
|
| 3 |
+
|
| 4 |
+
import { rateLimiter } from "npm:hono-rate-limiter";
|
| 5 |
+
|
| 6 |
+
function limit(app: Hono<BlankEnv, BlankSchema, "/">) {
|
| 7 |
+
const limit_var = Deno.env.get("LIMIT");
|
| 8 |
+
let limit = 2;
|
| 9 |
+
if (limit_var !== undefined) {
|
| 10 |
+
limit = Number(limit_var);
|
| 11 |
+
}
|
| 12 |
+
const limiter = rateLimiter({
|
| 13 |
+
windowMs: 1000,
|
| 14 |
+
limit,
|
| 15 |
+
standardHeaders: "draft-6",
|
| 16 |
+
keyGenerator: (_) => "1",
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
app.use("/v1/*", limiter);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export { limit };
|