Add OpenAI compatible chat backend

This commit is contained in:
2026-05-22 20:52:35 +03:00
parent 321d185592
commit 46a99605e6
41 changed files with 2244 additions and 151 deletions
+14
View File
@@ -0,0 +1,14 @@
import test from "node:test";
import assert from "node:assert/strict";
const {Environment} = await import("../dist/common/environment.js");
test("openai backend defaults to official", () => {
assert.equal(Environment.OPENAI_BACKEND, "official");
});
test("openai backend setter updates runtime config", () => {
Environment.setOpenAIBackend("compatible");
assert.equal(Environment.OPENAI_BACKEND, "compatible");
Environment.setOpenAIBackend("official");
});
@@ -0,0 +1,43 @@
import test from "node:test";
import assert from "node:assert/strict";
import {OpenAI} from "openai";
const {extractOpenAiChatToolCalls} = await import("../dist/ai/provider-adapter-contract.js");
const baseURL = process.env.OPENAI_COMPATIBLE_TEST_BASE_URL;
const model = process.env.OPENAI_COMPATIBLE_TEST_MODEL;
const apiKey = process.env.OPENAI_COMPATIBLE_TEST_API_KEY ?? process.env.OPENAI_API_KEY ?? "test";
test("openai-compatible chat.completions tool loop works on a real server", {skip: !baseURL || !model}, async () => {
const client = new OpenAI({baseURL, apiKey});
const response = await client.chat.completions.create({
model,
temperature: 0,
messages: [
{role: "system", content: "You must call the ping tool exactly once. Do not answer in plain text."},
{role: "user", content: "ping"},
],
tools: [{
type: "function",
function: {
name: "ping",
description: "Return a ping token.",
parameters: {
type: "object",
properties: {},
additionalProperties: false,
},
},
}],
tool_choice: {
type: "function",
function: {name: "ping"},
},
});
const calls = extractOpenAiChatToolCalls(response);
assert.equal(calls.length, 1);
assert.equal(calls[0].name, "ping");
});
+61
View File
@@ -5,6 +5,11 @@ const {
extractOpenAiToolCalls,
extractOpenAiStreamingToolCalls,
extractOpenAiTextDelta,
extractOpenAiChatToolCalls,
extractOpenAiChatStreamingToolCalls,
extractOpenAiChatTextDelta,
mergeToolCallChunks,
normalizeStreamingTextDelta,
extractMistralToolCalls,
extractMistralTextDelta,
extractOllamaToolCalls,
@@ -42,6 +47,62 @@ test("openai contract extracts text delta and function calls", () => {
assert.equal(streamed[0].name, "search_files");
});
test("openai chat contract extracts text delta and tool calls", () => {
assert.equal(extractOpenAiChatTextDelta({choices: [{delta: {content: "hello chat"}}]}), "hello chat");
assert.equal(normalizeStreamingTextDelta("hel", "hello"), "lo");
assert.equal(normalizeStreamingTextDelta("hel", "lo"), "lo");
const calls = extractOpenAiChatToolCalls({
choices: [{
message: {
tool_calls: [{
id: "chat-1",
function: {
name: "read_user_info",
arguments: "{\"userId\":123}",
},
}],
},
}],
});
assert.equal(calls.length, 1);
assert.equal(calls[0].id, "chat-1");
assert.equal(calls[0].name, "read_user_info");
const streamed = extractOpenAiChatStreamingToolCalls({
choices: [{
delta: {
tool_calls: [{
index: 0,
id: "chat-2",
function: {
name: "write_note",
arguments: "{\"text\":",
},
}],
},
}],
});
assert.equal(streamed.length, 1);
assert.equal(streamed[0].id, "chat-2");
assert.equal(streamed[0].name, "write_note");
assert.equal(streamed[0].argumentsText, "{\"text\":");
const merged = mergeToolCallChunks([
{id: "chat-2", name: "", argumentsText: "{\"text\":"},
], [{
id: "chat-2",
name: "write_note",
argumentsText: "\"hello\"}",
}]);
assert.equal(merged.length, 1);
assert.equal(merged[0].name, "write_note");
assert.equal(merged[0].argumentsText, "{\"text\":\"hello\"}");
});
test("mistral contract extracts content and tool calls", () => {
assert.equal(extractMistralTextDelta({
content: [{text: "hello"}, {text: " world"}],
+13
View File
@@ -86,6 +86,19 @@ test("prompt includes search files routing example for usage search", () => {
assert.ok(prompt.includes(JSON.stringify({toolNames: ["search_files"]})));
});
test("prompt includes memory routing examples for remember requests", () => {
const prompt = promptFor("no_tool", "read_user_info", "add_user_info", "remove_user_info", "replace_user_info", "delete_user_info");
assert.ok(prompt.includes("что ты помнишь обо мне?"));
assert.ok(prompt.includes("запомни, что меня зовут Иван"));
assert.ok(prompt.includes("забудь, что я люблю кофе"));
assert.ok(prompt.includes("забудь всё обо мне и запиши только это"));
assert.ok(prompt.includes("удали всю память обо мне"));
assert.ok(prompt.includes("inspect remembered user info -> read_user_info"));
assert.ok(prompt.includes("remember a new user fact -> add_user_info"));
assert.ok(prompt.includes(JSON.stringify({toolNames: ["add_user_info"]})));
});
test("prompt includes edit file patch routing example for targeted edits", () => {
const prompt = promptFor("no_tool", "edit_file_patch");
+197
View File
@@ -0,0 +1,197 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const {Environment} = await import("../dist/common/environment.js");
const {
buildUserMemoryPrompt,
compressMemoryWithFallback,
deleteUserMemory,
getMemoryFilePath,
readUserMemory,
updateUserMemory,
} = await import("../dist/ai/tools/user-memory.js");
const {AiProvider} = await import("../dist/model/ai-provider.js");
function makeTempDataPath() {
return fs.mkdtempSync(path.join(os.tmpdir(), "tg-chat-bot-memory-"));
}
function withEnv(vars, fn) {
const snapshot = new Map();
for (const [key, value] of Object.entries(vars)) {
snapshot.set(key, process.env[key]);
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
return Promise.resolve(fn()).finally(() => {
for (const [key, value] of snapshot.entries()) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
}
test("memory storage supports append replace and remove", async () => {
const oldDataPath = Environment.DATA_PATH;
Environment.DATA_PATH = makeTempDataPath();
try {
const userId = 475823381;
let result = await updateUserMemory({
userId,
scope: "user",
action: "replace",
content: "# Profile\nLikes tea",
});
assert.equal(result.success, true);
result = await updateUserMemory({
userId,
scope: "user",
action: "add",
content: "Prefers concise answers",
});
assert.equal(result.success, true);
assert.match(result.content, /Likes tea/);
assert.match(result.content, /Prefers concise answers/);
result = await updateUserMemory({
userId,
scope: "user",
action: "remove",
content: "Prefers concise answers",
});
assert.equal(result.success, true);
assert.doesNotMatch(result.content, /Prefers concise answers/);
const readback = await readUserMemory(userId, "user");
assert.equal(readback.success, true);
assert.equal(readback.filePath, getMemoryFilePath(userId, "user"));
assert.match(readback.content, /Likes tea/);
} finally {
Environment.DATA_PATH = oldDataPath;
}
});
test("memory delete removes the file", async () => {
const oldDataPath = Environment.DATA_PATH;
Environment.DATA_PATH = makeTempDataPath();
try {
const userId = 999;
await updateUserMemory({userId, scope: "user", action: "replace", content: "hello"});
const deleted = await deleteUserMemory(userId, "user");
assert.equal(deleted.success, true);
const readback = await readUserMemory(userId, "user");
assert.equal(readback.success, true);
assert.equal(readback.content, "");
} finally {
Environment.DATA_PATH = oldDataPath;
}
});
test("memory prompt combines system and user files", async () => {
const oldDataPath = Environment.DATA_PATH;
Environment.DATA_PATH = makeTempDataPath();
try {
const userId = 1234;
await updateUserMemory({
userId,
scope: "system",
action: "replace",
content: "Ты зовешься Евлампий.",
});
await updateUserMemory({
userId,
scope: "user",
action: "replace",
content: "Пользователь любит короткие ответы.",
});
const prompt = await buildUserMemoryPrompt(userId);
assert(prompt);
assert.equal(prompt?.includes("## Assistant memory (system.md)"), true);
assert.equal(prompt?.includes("This is information about the assistant and its behavior."), true);
assert.equal(prompt?.includes("## User memory (user.md)"), true);
assert.equal(prompt?.includes("This is information about the user."), true);
assert(prompt.indexOf("## Assistant memory (system.md)") < prompt.indexOf("## User memory (user.md)"));
} finally {
Environment.DATA_PATH = oldDataPath;
}
});
test("memory compression falls back to current target when explicit target fails", async () => {
await withEnv({
OLLAMA_MEMORY_COMPRESS_MODEL: "memory-compress-model",
OLLAMA_CHAT_MODEL: "chat-model",
}, async () => {
const calls = [];
const result = await compressMemoryWithFallback(
{
provider: AiProvider.OLLAMA,
currentTarget: {
provider: AiProvider.OLLAMA,
purpose: "chat",
model: "chat-model",
},
scope: "system",
currentText: "x".repeat(1200),
limit: 1000,
},
async ({target}) => {
calls.push(target.model);
if (target.model === "memory-compress-model") {
throw new Error("boom");
}
return "short summary";
},
);
assert.deepEqual(calls, ["memory-compress-model", "chat-model"]);
assert.equal(result.content, "short summary");
assert.equal(result.compressed, true);
});
});
test("memory compression uses current target when no separate target exists", async () => {
await withEnv({
OLLAMA_MEMORY_COMPRESS_MODEL: undefined,
OLLAMA_CHAT_MODEL: "chat-model",
}, async () => {
const calls = [];
const result = await compressMemoryWithFallback(
{
provider: AiProvider.OLLAMA,
currentTarget: {
provider: AiProvider.OLLAMA,
purpose: "chat",
model: "chat-model",
},
scope: "user",
currentText: "x".repeat(1200),
limit: 1000,
},
async ({target}) => {
calls.push(target.model);
return "summary";
},
);
assert.deepEqual(calls, ["chat-model"]);
assert.equal(result.content, "summary");
assert.equal(result.compressed, true);
});
});