Add OpenAI compatible chat backend
This commit is contained in:
@@ -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");
|
||||
});
|
||||
@@ -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"}],
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user