Files
OmniRoute/src/lib/telegram/chatProxy.ts
anhtahaylove 30c96d43a5 fix(telegram): bound the per-user API key cache (#13166)
`resolveUserApiKey()` keyed an uncapped Map on an id taken straight from the webhook body. The LRU's recency test is what keeps this from regressing into a clear-when-full cache.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the other 13 PRs of this batch — zero merge conflicts between them.

- `typecheck:core` clean
- complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline
- 71 focused assertions green across the 13 test files this batch adds or touches

⚠️ base-red inherited: #12732 — `Docs Gates (fast-path)`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098). None of them touch this diff.

Thanks @anhtahaylove — the root-cause write-up, the measured before/after numbers and the red-before-green proof on every one of these made the batch reviewable as a unit.
2026-09-11 13:27:30 -03:00

127 lines
4.4 KiB
TypeScript

/**
* Telegram → OmniRoute chat proxy.
*
* Turns a plain Telegram message into a chat.completions call through the
* existing handleChat pipeline and returns the assistant text. Non-streaming
* for Phase 1 (Telegram has no native SSE); streaming is emulated later via
* progressive editMessageText.
*
* Auth model: each Telegram user is mapped to a generated OmniRoute API key
* (createApiKey) so the existing policy/rate-limit/model-allowlist machinery
* applies unchanged. The key is cached in-memory per user id.
*/
import { handleChat } from "@/sse/handlers/chat";
import { createApiKey, getApiKeys } from "@/lib/db/apiKeys";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { randomUUID } from "node:crypto";
const DEFAULT_MODEL = process.env.TELEGRAM_DEFAULT_MODEL || "auto/chat";
/**
* Resolve (and lazily mint) an OmniRoute API key for a Telegram user.
* Returns the plaintext key value, cached per user id.
*/
// Bounded LRU. The webhook path passes a caller-supplied chat id, so the key
// space is not limited to the real user population and an uncapped Map would
// grow for the lifetime of the process. Insertion order is the recency order:
// a hit re-inserts, and the oldest entry is dropped once the cap is reached.
const KEY_CACHE_MAX_ENTRIES = 1000;
const keyCache = new Map<number, string>();
function rememberUserApiKey(telegramUserId: number, key: string): void {
// Re-insert so this id becomes the most recently used entry.
keyCache.delete(telegramUserId);
keyCache.set(telegramUserId, key);
while (keyCache.size > KEY_CACHE_MAX_ENTRIES) {
const oldest = keyCache.keys().next();
if (oldest.done) break;
keyCache.delete(oldest.value);
}
}
export async function resolveUserApiKey(telegramUserId: number): Promise<string> {
const cached = keyCache.get(telegramUserId);
if (cached) {
// Refresh recency so an active user is not evicted by a burst of new ids.
rememberUserApiKey(telegramUserId, cached);
return cached;
}
const machineId = (await getConsistentMachineId().catch(() => null)) || "0000000000000000";
// Reuse an existing key whose name matches, else mint one.
const existing = await getApiKeys();
const match = existing?.find(
(k) =>
(k as { name?: string }).name === `telegram:${telegramUserId}` &&
typeof (k as { key?: string }).key === "string" &&
((k as { key?: string }).key?.length ?? 0) > 0
);
const matchKey = (match as { key?: string } | undefined)?.key;
if (typeof matchKey === "string" && matchKey.length > 0) {
rememberUserApiKey(telegramUserId, matchKey);
return matchKey;
}
const created = await createApiKey(`telegram:${telegramUserId}`, machineId);
rememberUserApiKey(telegramUserId, created.key);
return created.key;
}
function buildChatRequest(apiKey: string, prompt: string, model: string): Request {
const body = JSON.stringify({
model,
messages: [{ role: "user", content: prompt }],
stream: false,
});
const headers = new Headers({
"content-type": "application/json",
authorization: `Bearer ${apiKey}`,
});
return new Request("http://127.0.0.1/v1/chat/completions", {
method: "POST",
headers,
body,
});
}
/** Extract plain assistant text from a handleChat Response (stream or not). */
async function extractResponseText(response: Response): Promise<string> {
if (!response) return "";
if (response.body) {
// Non-streaming JSON: {"choices":[{"message":{"content": "..."}}]}
try {
const text = await response.text();
const json = JSON.parse(text) as {
choices?: Array<{ message?: { content?: string }; text?: string }>;
error?: { message?: string };
};
if (json.error?.message) return `⚠️ ${json.error.message}`;
const choice = json.choices?.[0];
return choice?.message?.content ?? choice?.text ?? "";
} catch {
return "";
}
}
return "";
}
/**
* Proxy one user prompt through the OmniRoute chat pipeline.
* @returns assistant text (may be empty on failure)
*/
export async function proxyChat(
telegramUserId: number,
prompt: string,
model = DEFAULT_MODEL
): Promise<string> {
if (!prompt?.trim()) return "";
const apiKey = await resolveUserApiKey(telegramUserId);
const request = buildChatRequest(apiKey, prompt.trim(), model);
const response = await handleChat(request, null, null);
return extractResponseText(response);
}
export { DEFAULT_MODEL };
export { randomUUID };