src / expert.ts
import { createHash } from "node:crypto";
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { isAbsolute, join } from "node:path";
import { z } from "zod";
export interface Settings {
keyFile: string; model: string; maxOutput: number; thinking: boolean;
dailyCalls: number; dailyTokens: number;
}
export interface Question { question: string; context: string; reason: string; uncertainty: string }
export const questionSchema = z.object({
question: z.string().min(3).max(10000), context: z.string().max(4000),
reason: z.enum(["explicit_user_request", "insufficient_knowledge"]),
uncertainty: z.string().min(3).max(500),
});
const settingsSchema = z.object({
keyFile: z.string().min(1), model: z.enum(["deepseek-v4-pro", "deepseek-flash"]),
maxOutput: z.number().int().min(256).max(16000), thinking: z.boolean(),
dailyCalls: z.number().int().min(1).max(100),
dailyTokens: z.number().int().min(5000).max(1000000),
});
const stateSchema = z.object({
day: z.string(), calls: z.number().int().nonnegative(),
reservedTokens: z.number().int().nonnegative(),
reportedInput: z.number().int().nonnegative(), reportedOutput: z.number().int().nonnegative(),
recent: z.array(z.object({ hash: z.string(), time: z.number() })),
});
type State = z.infer<typeof stateSchema>;
export const SYSTEM = "Ты эксперт, к которому обращается другая языковая модель. Ответь по-русски на поставленный вопрос: вывод, существенные пояснения и ограничения. Не расширяй задачу. Не придумывай факты, цитаты и источники. Явно отмечай неопределённость. Не предоставляй скрытые рассуждения. У тебя нет веб-поиска; не выдавай неподтверждённые текущие сведения за проверенные.";
function keyPath(path: string) {
const expanded = path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
if (!isAbsolute(expanded)) throw new Error("В настройках укажи абсолютный путь к файлу ключа или путь ~/...");
return expanded;
}
async function loadKey(path: string) {
const resolved = keyPath(path);
const info = await stat(resolved);
if (!info.isFile() || info.size > 1024) throw new Error("Файл ключа должен быть небольшим обычным текстовым файлом.");
const key = (await readFile(resolved, "utf8")).replace(/^\uFEFF/, "").trim();
if (!/^sk-[a-zA-Z0-9_-]{16,}$/.test(key)) throw new Error("Неверный формат ключа: в файле должна быть только одна строка sk-...");
return key;
}
async function withState<T>(directory: string, action: (s: State) => Promise<T>, now: number): Promise<T> {
await mkdir(directory, { recursive: true, mode: 0o700 });
const lock = join(directory, "usage.lock");
try { await mkdir(lock); } catch (error: any) {
if (error.code === "EEXIST") throw new Error("Журнал расхода занят другим вызовом. Автоповтор запрещён. После аварии проверь и удали папку usage.lock вручную.");
throw error;
}
try {
const file = join(directory, "usage.json");
let stored: State | undefined;
try { stored = stateSchema.parse(JSON.parse(await readFile(file, "utf8"))); }
catch (error: any) {
if (error.code !== "ENOENT") throw new Error("Повреждён журнал расхода. Вызовы заблокированы до ручной проверки.");
}
const day = new Date(now).toISOString().slice(0, 10);
const state: State = stored?.day === day ? stored : {
day, calls: 0, reservedTokens: 0, reportedInput: 0, reportedOutput: 0,
recent: stored?.recent.filter(r => r.time > now - 600000) ?? [],
};
const result = await action(state);
const temp = join(directory, "usage.tmp");
await writeFile(temp, JSON.stringify(state, null, 2), { mode: 0o600 });
await rename(temp, file);
return result;
} finally { await rm(lock, { recursive: true }); }
}
export async function consult(rawArgs: Question, rawSettings: Settings, signal?: AbortSignal,
dependencies: { fetch?: typeof fetch; directory?: string; now?: number } = {}) {
const args = questionSchema.parse(rawArgs);
const settings = settingsSchema.parse(rawSettings);
if (signal?.aborted) throw new Error("Запрос отменён.");
const key = await loadKey(settings.keyFile);
const text = args.question + (args.context ? `\n\nНеобходимый контекст:\n${args.context}` : "");
if (Buffer.byteLength(text, "utf8") > 16000) throw new Error("Слишком большой запрос: максимум 16000 UTF-8 байт. Сократи контекст.");
const now = dependencies.now ?? Date.now();
const directory = dependencies.directory ?? join(homedir(), ".lmstudio", "deepseek-expert-data");
const hash = createHash("sha256").update(text.trim().replace(/\s+/g, " ")).digest("hex");
// A byte-based upper reservation, not an exact tokenizer count or a monetary guarantee.
const reservation = Buffer.byteLength(text + SYSTEM, "utf8") + 1024 + settings.maxOutput;
await withState(directory, async (state) => {
if (state.calls >= settings.dailyCalls) throw new Error("Исчерпан суточный лимит попыток.");
if (state.reservedTokens + reservation > settings.dailyTokens) throw new Error("Исчерпан суточный резерв токенов.");
if (state.recent.some(r => r.hash === hash && r.time > now - 600000)) throw new Error("Этот запрос уже отправлялся в последние 10 минут.");
state.calls++; state.reservedTokens += reservation;
state.recent = state.recent.filter(r => r.time > now - 600000);
state.recent.push({ hash, time: now });
}, now);
const timeout = AbortSignal.timeout(90000);
const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
const response = await (dependencies.fetch ?? fetch)("https://api.deepseek.com/chat/completions", {
method: "POST", redirect: "error", signal: requestSignal,
headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
body: JSON.stringify({ model: settings.model, stream: false, max_tokens: settings.maxOutput,
thinking: { type: settings.thinking ? "enabled" : "disabled" },
...(settings.thinking ? { reasoning_effort: "low" } : {}),
messages: [{ role: "system", content: SYSTEM }, { role: "user", content: text }],
}),
});
if (!response.ok) {
// Never echo upstream bodies, which can contain echoed credentials or user text.
const hints: Record<number, string> = { 401: "Проверь ключ", 402: "Проверь баланс", 429: "Лимит запросов сервиса" };
throw new Error(`DeepSeek HTTP ${response.status}. ${hints[response.status] ?? "Ошибка сервиса"}. Попытка учтена в резерве.`);
}
const payload = await response.json() as any;
const first = payload?.choices?.[0];
const answer = first?.message?.content;
if (typeof answer !== "string" || !answer.trim()) throw new Error("DeepSeek не вернул окончательный ответ. Возможно, thinking исчерпал выходной лимит. Не повторять автоматически.");
const safeCount = (n: unknown) => typeof n === "number" && Number.isInteger(n) && n >= 0 ? n : 0;
const input = safeCount(payload?.usage?.prompt_tokens), output = safeCount(payload?.usage?.completion_tokens);
const limitWarning = first.finish_reason === "length" ? " Ответ обрезан выходным лимитом; не считай его завершённым." : "";
try {
await withState(directory, async state => { state.reportedInput += input; state.reportedOutput += output; }, now);
} catch { /* Reservation is already durable; a statistics update failure must not cause another paid call. */ }
const redact = (s: string) => s.split(key).join("[KEY REDACTED]").replace(/sk-[A-Za-z0-9_-]{16,}/g, "[KEY REDACTED]");
return redact(answer.trim()) + `\n\n[Источник: консультация ${settings.model}; токены API: ${input} вход / ${output} выход.${limitWarning}]`;
}
export function errorText(error: unknown) {
if (error instanceof z.ZodError) return "Некорректные параметры инструмента или настроек.";
if (error instanceof Error && ["AbortError", "TimeoutError"].includes(error.name)) return "Запрос отменён или истёк таймаут. Возможное списание уже учтено резервом.";
if (error instanceof Error && (error as NodeJS.ErrnoException).code === "ENOENT") return "Файл ключа не найден. Создай ~/Downloads/deepseek-key.txt или исправь путь в настройках.";
return (error instanceof Error ? error.message : "Ошибка инструмента").replace(/sk-[A-Za-z0-9_-]+/g, "[KEY REDACTED]");
}
src / expert.ts
import { createHash } from "node:crypto";
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { isAbsolute, join } from "node:path";
import { z } from "zod";
export interface Settings {
keyFile: string; model: string; maxOutput: number; thinking: boolean;
dailyCalls: number; dailyTokens: number;
}
export interface Question { question: string; context: string; reason: string; uncertainty: string }
export const questionSchema = z.object({
question: z.string().min(3).max(10000), context: z.string().max(4000),
reason: z.enum(["explicit_user_request", "insufficient_knowledge"]),
uncertainty: z.string().min(3).max(500),
});
const settingsSchema = z.object({
keyFile: z.string().min(1), model: z.enum(["deepseek-v4-pro", "deepseek-flash"]),
maxOutput: z.number().int().min(256).max(16000), thinking: z.boolean(),
dailyCalls: z.number().int().min(1).max(100),
dailyTokens: z.number().int().min(5000).max(1000000),
});
const stateSchema = z.object({
day: z.string(), calls: z.number().int().nonnegative(),
reservedTokens: z.number().int().nonnegative(),
reportedInput: z.number().int().nonnegative(), reportedOutput: z.number().int().nonnegative(),
recent: z.array(z.object({ hash: z.string(), time: z.number() })),
});
type State = z.infer<typeof stateSchema>;
export const SYSTEM = "Ты эксперт, к которому обращается другая языковая модель. Ответь по-русски на поставленный вопрос: вывод, существенные пояснения и ограничения. Не расширяй задачу. Не придумывай факты, цитаты и источники. Явно отмечай неопределённость. Не предоставляй скрытые рассуждения. У тебя нет веб-поиска; не выдавай неподтверждённые текущие сведения за проверенные.";
function keyPath(path: string) {
const expanded = path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
if (!isAbsolute(expanded)) throw new Error("В настройках укажи абсолютный путь к файлу ключа или путь ~/...");
return expanded;
}
async function loadKey(path: string) {
const resolved = keyPath(path);
const info = await stat(resolved);
if (!info.isFile() || info.size > 1024) throw new Error("Файл ключа должен быть небольшим обычным текстовым файлом.");
const key = (await readFile(resolved, "utf8")).replace(/^\uFEFF/, "").trim();
if (!/^sk-[a-zA-Z0-9_-]{16,}$/.test(key)) throw new Error("Неверный формат ключа: в файле должна быть только одна строка sk-...");
return key;
}
async function withState<T>(directory: string, action: (s: State) => Promise<T>, now: number): Promise<T> {
await mkdir(directory, { recursive: true, mode: 0o700 });
const lock = join(directory, "usage.lock");
try { await mkdir(lock); } catch (error: any) {
if (error.code === "EEXIST") throw new Error("Журнал расхода занят другим вызовом. Автоповтор запрещён. После аварии проверь и удали папку usage.lock вручную.");
throw error;
}
try {
const file = join(directory, "usage.json");
let stored: State | undefined;
try { stored = stateSchema.parse(JSON.parse(await readFile(file, "utf8"))); }
catch (error: any) {
if (error.code !== "ENOENT") throw new Error("Повреждён журнал расхода. Вызовы заблокированы до ручной проверки.");
}
const day = new Date(now).toISOString().slice(0, 10);
const state: State = stored?.day === day ? stored : {
day, calls: 0, reservedTokens: 0, reportedInput: 0, reportedOutput: 0,
recent: stored?.recent.filter(r => r.time > now - 600000) ?? [],
};
const result = await action(state);
const temp = join(directory, "usage.tmp");
await writeFile(temp, JSON.stringify(state, null, 2), { mode: 0o600 });
await rename(temp, file);
return result;
} finally { await rm(lock, { recursive: true }); }
}
export async function consult(rawArgs: Question, rawSettings: Settings, signal?: AbortSignal,
dependencies: { fetch?: typeof fetch; directory?: string; now?: number } = {}) {
const args = questionSchema.parse(rawArgs);
const settings = settingsSchema.parse(rawSettings);
if (signal?.aborted) throw new Error("Запрос отменён.");
const key = await loadKey(settings.keyFile);
const text = args.question + (args.context ? `\n\nНеобходимый контекст:\n${args.context}` : "");
if (Buffer.byteLength(text, "utf8") > 16000) throw new Error("Слишком большой запрос: максимум 16000 UTF-8 байт. Сократи контекст.");
const now = dependencies.now ?? Date.now();
const directory = dependencies.directory ?? join(homedir(), ".lmstudio", "deepseek-expert-data");
const hash = createHash("sha256").update(text.trim().replace(/\s+/g, " ")).digest("hex");
// A byte-based upper reservation, not an exact tokenizer count or a monetary guarantee.
const reservation = Buffer.byteLength(text + SYSTEM, "utf8") + 1024 + settings.maxOutput;
await withState(directory, async (state) => {
if (state.calls >= settings.dailyCalls) throw new Error("Исчерпан суточный лимит попыток.");
if (state.reservedTokens + reservation > settings.dailyTokens) throw new Error("Исчерпан суточный резерв токенов.");
if (state.recent.some(r => r.hash === hash && r.time > now - 600000)) throw new Error("Этот запрос уже отправлялся в последние 10 минут.");
state.calls++; state.reservedTokens += reservation;
state.recent = state.recent.filter(r => r.time > now - 600000);
state.recent.push({ hash, time: now });
}, now);
const timeout = AbortSignal.timeout(90000);
const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
const response = await (dependencies.fetch ?? fetch)("https://api.deepseek.com/chat/completions", {
method: "POST", redirect: "error", signal: requestSignal,
headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
body: JSON.stringify({ model: settings.model, stream: false, max_tokens: settings.maxOutput,
thinking: { type: settings.thinking ? "enabled" : "disabled" },
...(settings.thinking ? { reasoning_effort: "low" } : {}),
messages: [{ role: "system", content: SYSTEM }, { role: "user", content: text }],
}),
});
if (!response.ok) {
// Never echo upstream bodies, which can contain echoed credentials or user text.
const hints: Record<number, string> = { 401: "Проверь ключ", 402: "Проверь баланс", 429: "Лимит запросов сервиса" };
throw new Error(`DeepSeek HTTP ${response.status}. ${hints[response.status] ?? "Ошибка сервиса"}. Попытка учтена в резерве.`);
}
const payload = await response.json() as any;
const first = payload?.choices?.[0];
const answer = first?.message?.content;
if (typeof answer !== "string" || !answer.trim()) throw new Error("DeepSeek не вернул окончательный ответ. Возможно, thinking исчерпал выходной лимит. Не повторять автоматически.");
const safeCount = (n: unknown) => typeof n === "number" && Number.isInteger(n) && n >= 0 ? n : 0;
const input = safeCount(payload?.usage?.prompt_tokens), output = safeCount(payload?.usage?.completion_tokens);
const limitWarning = first.finish_reason === "length" ? " Ответ обрезан выходным лимитом; не считай его завершённым." : "";
try {
await withState(directory, async state => { state.reportedInput += input; state.reportedOutput += output; }, now);
} catch { /* Reservation is already durable; a statistics update failure must not cause another paid call. */ }
const redact = (s: string) => s.split(key).join("[KEY REDACTED]").replace(/sk-[A-Za-z0-9_-]{16,}/g, "[KEY REDACTED]");
return redact(answer.trim()) + `\n\n[Источник: консультация ${settings.model}; токены API: ${input} вход / ${output} выход.${limitWarning}]`;
}
export function errorText(error: unknown) {
if (error instanceof z.ZodError) return "Некорректные параметры инструмента или настроек.";
if (error instanceof Error && ["AbortError", "TimeoutError"].includes(error.name)) return "Запрос отменён или истёк таймаут. Возможное списание уже учтено резервом.";
if (error instanceof Error && (error as NodeJS.ErrnoException).code === "ENOENT") return "Файл ключа не найден. Создай ~/Downloads/deepseek-key.txt или исправь путь в настройках.";
return (error instanceof Error ? error.message : "Ошибка инструмента").replace(/sk-[A-Za-z0-9_-]+/g, "[KEY REDACTED]");
}