@adriangalilea/utils 1.15.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -17
- package/dist/bot/index.d.ts +1 -1
- package/dist/bot/index.js +1 -1
- package/dist/bot/llm.d.ts +71 -226
- package/dist/bot/llm.d.ts.map +1 -1
- package/dist/bot/llm.js +183 -480
- package/dist/bot/llm.js.map +1 -1
- package/dist/llm/index.d.ts +215 -0
- package/dist/llm/index.d.ts.map +1 -0
- package/dist/llm/index.js +504 -0
- package/dist/llm/index.js.map +1 -0
- package/package.json +32 -3
package/README.md
CHANGED
|
@@ -281,7 +281,8 @@ Every `SourcedError` carries `source`, `operation`, `status`, `context`, and the
|
|
|
281
281
|
- **XDG**: XDG Base Directory paths — reads env vars set by [xdg-dirs](https://github.com/adriangalilea/xdg-dirs), falls back to spec defaults
|
|
282
282
|
- **Unseen**: Persistent dedup filter — "what's new since last time?" for cron/monitoring workflows
|
|
283
283
|
- **Project Discovery**: Find project/monorepo roots, detect JS/TS projects
|
|
284
|
-
- **
|
|
284
|
+
- **LLM caller (`llm`)**: multi-provider failover on top of the Vercel AI SDK — priority ordering, per-key circuit breaker (KV-backed), reset events for live previews, usage accounting with actual billed cost (OpenRouter), tool calling. Worker-safe.
|
|
285
|
+
- **Bot plugins (GramIO)**: `kit` (graceful shutdown + admin context), `access-control` (gate + approve/deny menu, backed by sessions), `llm` (draft-streamed replies `streamChatReply` + per-thread conversation history `ctx.llm`), `payments` (Telegram Stars: VIP tiers + credits + perks + waiver + refund flow, ToS-compliant, Spanish-autónomo-aware), `coalesce`, `language`, `menu`
|
|
285
286
|
|
|
286
287
|
### XDG Base Directories
|
|
287
288
|
|
|
@@ -447,7 +448,8 @@ Then `pnpm install`. Every `ctx.send` / `ctx.sendDocument` / `ctx.reply` / etc.
|
|
|
447
448
|
| `@adriangalilea/utils/bot/language` (picker surface) | The language-picker vocabulary and surfaces, so bots don't fork label lists: `flagFor` / `autonym` / `languageLabel` ("🇪🇸 Español"), `languagePickerItem({ label, codes, isActive, pick })` (the 2-up primary-highlighted MenuItem factory — storage and policy live in your closures), and `addLanguageRows(kb, { codes, pack, active?, activeStyle? })` (the raw-InlineKeyboard twin for onboarding / group-welcome keyboards; `activeStyle: "success"` when primary already marks something else on the keyboard). |
|
|
448
449
|
| `@adriangalilea/utils/bot/user` | `userLabel(u)` — the conditional "[name] [@username] [id]" line every bot re-rolls for admin DMs and logs: `Ada Lovelace (@ada · 42)` / `Ada (42)` / `@ada (42)` / `id 42`, missing pieces drop instead of padding. Reads both gramio spellings (`firstName` / `first_name`); plain text by design. |
|
|
449
450
|
| `@adriangalilea/utils/bot/coalesce` | Joins client-split inbound messages back into one. When a user pastes >4096 chars, Telegram clients fragment it into separate `message` updates with no marker. Middleware detects the burst and emits one combined event. |
|
|
450
|
-
| `@adriangalilea/utils/
|
|
451
|
+
| `@adriangalilea/utils/llm` | Multi-provider LLM caller on the Vercel AI SDK — the policy layer the SDK doesn't ship. `createLlm({ providers, health? })` → `.stream(req)` (typed events: `delta` / `reasoning` / `reset` / `tool-call` / `end`) and `.complete(req)`. **Failover:** priority-ordered across OpenAI-compatible, Anthropic-compatible, and OpenRouter endpoints; multiple keys per provider (health tracked per key fingerprint); per-key circuit breaker with exponential cool-down persisted in any KV-shaped `HealthStore`; an attempt that dies after emitting yields `reset` (discard, next candidate regenerates); empty completions count as failures. **Accounting:** tokens summed across billed attempts; `costUsd` is the ACTUAL charge (OpenRouter usage accounting via `providerMetadata`, or a `cost` field on the raw usage frame) — never a price-table estimate. **Knobs:** per-model temperature, maxTokens caps, `disableThinking` mapped to each dialect. **Tools:** AI SDK `tool()` + `toolChoice` pass through (re-exported). Worker-safe; peers: `ai`, `@ai-sdk/openai-compatible`, `@ai-sdk/anthropic`, `@openrouter/ai-sdk-provider`. |
|
|
452
|
+
| `@adriangalilea/utils/bot/llm` | The Telegram side of an LLM chatbot; the model side is `@adriangalilea/utils/llm`. **Output:** `streamChatReply(ctx, events, opts?)` consumes an `AsyncIterable<LlmStreamEvent>` and paints it with Telegram's native message-draft streaming (`sendMessageDraft` full-frame repaints, throttled ~1/s, keepalive under the ~30s draft TTL), then persists the finished markdown via `ctx.send`, entity-split across 4096 by `@gramio/split`. Reasoning models get a thinking phase — streams into the ephemeral draft and evaporates (`reasoning: 'preview'`, default), persists as an expandable blockquote (`'message'`), or never renders (`'hidden'`). Upstream `reset` (provider failover) repaints the draft from scratch. Drafts are private-chat-only (+ BotFather forum-topic mode); elsewhere the preview phase is skipped and only the final send happens. Returns `{ content, reasoning, toolCalls, usage, messages }`. **History:** `llmHistory({...})` returns `.plugin` (decorates `ctx.llm` with `.add() / .get() / .clear() / .all() / .clearAll()`, per-(user, thread) OpenAI `ChatMessage` shape, persisted in the shared session record so the menu's 🗑 Forget wipes it automatically) AND `.menuItem` (drop-in "🗑 Delete this thread" for `botMenu` — wipes the LLM history AND calls `deleteForumTopic` so the Telegram thread + all its messages disappear from the chat; falls back to history-only clear when no `threadId` is present). |
|
|
451
453
|
| `@adriangalilea/utils/bot/menu` | `botMenu({ command, description, items, privacy?, personalData?, adminContact })` — `/settings` command + InlineKeyboard router. Root view always renders a `🛡️ Privacy & data` submenu button that wraps the privacy policy link plus (if `personalData: { storage }`) 🗑 Forget + 📥 Export buttons. Items take `keepRow` (render on the same row as the next item — e.g. a two-per-row language picker) and `rootExtra` (render at the bottom of the root menu, below Privacy & data). `label` / `header` / `style` / `visible` resolvers may be **async** (read your db at render time — never cache render strings in the session); `parseMode: 'HTML'` renders the header formatted (you own escaping); `personalData.onForget(ctx, userId)` wipes YOUR tables inside the same try as the session delete, so Forget either forgets everything or reports failure. `toggleMenuItem({ id, read, write, label: { off, on }, toast? })` — convenience factory for boolean-toggle items with dynamic label + optional toast, storage-agnostic via `read`/`write` closures. |
|
|
452
454
|
| `@adriangalilea/utils/bot/payments` | `botPayments({ session, storage, paysupport, paysupportHint?, legal, waiver, vip?, credits?, perks? })` — Telegram Stars monetization in one drop-in plugin. **Three axes, all optional:** `vip` (positional tier ladder — single rung in v1 is just `vip: [{...}]`, ladder is `vip: [{...}, {...}]`; ids are `vip.1`, `vip.2`, …), `credits` (consumable balance + top-up packs `credits.1`, `credits.2`, …), `perks` (orthogonal one-shot unlocks `perks.<key>`). **Surface:** `ctx.payments.atLeast('vip')` / `atLeast('vip.2')` (typed rank check), `ctx.payments.tier()` / `.tier.level()` / `.tier.label()`, `ctx.payments.credits.{balance, consume, tryConsume}` (throws `InsufficientCredits`), `ctx.payments.has(perkId)`, `await ctx.payments.require('vip', { feature? })` (gate that sends a localized upgrade prompt deep-linked to `/settings → 💎 VIP`), `await ctx.payments.invoice(productKey)` (threads Art. 103(m) TRLGDCU consent inline before `sendInvoice`). **Owns:** waiver consent flow (versioned text → forces re-consent on bump, snapshotted on every charge for audit), `/paysupport` slash command (Telegram ToS §6.5; `paysupportHint` overrides the where-to-manage-charges line when your menu isn't `/settings`), idempotent `successful_payment` fulfillment via `pay:idempotency:{chargeId}` sentinel, lazy subscription expiry (no cron needed), tier upgrade auto-cancel of the lower rung's renewal, and admin-DM refund approval (mirror of `accessControl`'s [✅ Aprobar][❌ Denegar] pattern). **Returns:** `{ plugin, menuItem, payouts, onFulfilled }` — `menuItem` is the drop-in `💎 VIP` entry for `botMenu`; `payouts.{record, list, export, exportForUsers}` is the Fragment payout ledger (you receive TON, log the EUR conversion, export time-windowed CSV/JSON for your gestor); `onFulfilled(productKey \| '*', handler)` / `onRefunded(...)` register fire-and-forget hooks (purchase applied / admin-approved refund — a revenue ledger writes on one, reverses on the other). **Stars-only by design** — Telegram ToS §6.2 forbids third-party payment providers for digital goods. Crypto Pay deferred (MiCA risk); Stripe-outside-Telegram is a future v2 channel. Full compliance memo (Spanish-autónomo seller-of-record analysis, Verifactu vs Crea y Crece, MiCA, Art. 103(m) waiver text, GDPR retention) in `src/bot/payments/CLAUDE.md`. |
|
|
453
455
|
| `@adriangalilea/utils/bot/create` | `createBot<S>({ token?, storage?, initial?, admins?, language?, menu?, access?, payments?, handlers?, worker? })` — the composer (see "One bot file, ideation → production" above). Returns `{ build, session, poll, isMain, fetch }`: `poll()` long-polls, `export default app` is a complete Worker, `app.session(ctx)` / the `handlers` callback's `session(ctx)` is the TYPED accessor for your `S` fields. Owns storage+session wiring; resolves storage per environment (D1 binding → `bot/storage-d1`; `BOT_PERSIST` path → sqlite, `redis://` → redis, lazily-imported optional peers; else announced-ephemeral memory) — or pass `storage: (env) => Storage` when the choice is env-dependent (e.g. a D1 binding not named `DB`). Boot NARRATES the composition (`session: memory …`, `features: language(en,es) · menu(/settings)`) so every implicit decision is visible where you're looking. Runnable demo: `pnpm demo:bot`. |
|
|
@@ -463,7 +465,8 @@ import { redisStorage } from '@gramio/storage-redis'
|
|
|
463
465
|
import { adminContext, gracefulStart } from '@adriangalilea/utils/bot/kit'
|
|
464
466
|
import { botSession } from '@adriangalilea/utils/bot/session'
|
|
465
467
|
import { accessControl } from '@adriangalilea/utils/bot/access-control'
|
|
466
|
-
import {
|
|
468
|
+
import { createLlm } from '@adriangalilea/utils/llm'
|
|
469
|
+
import { streamChatReply, llmHistory, toModelMessages } from '@adriangalilea/utils/bot/llm'
|
|
467
470
|
|
|
468
471
|
// Raw redis is fine — bot-id namespacing happens inside botSession +
|
|
469
472
|
// every plugin via ctx.bot.info.id. Multiple bots sharing this Redis
|
|
@@ -472,29 +475,26 @@ const storage = redisStorage()
|
|
|
472
475
|
const userSession = botSession({ storage, key: 'session', initial: () => ({}) })
|
|
473
476
|
const chat = llmHistory({ session: userSession, maxTurns: 20, retentionDays: 7 })
|
|
474
477
|
|
|
478
|
+
// Any OpenAI-compatible endpoint: vllm-mlx, mlx-lm, llama.cpp, Together, Groq, OpenAI, …
|
|
479
|
+
// More entries = priority failover; a KV-shaped `health` store adds the circuit breaker.
|
|
480
|
+
const llm = createLlm({
|
|
481
|
+
providers: [{ id: 'local', type: 'openai', baseUrl: process.env.LLM_URL!, apiKey: 'none', defaultModel: process.env.LLM_MODEL! }],
|
|
482
|
+
})
|
|
483
|
+
|
|
475
484
|
const bot = new Bot(process.env.BOT_TOKEN!)
|
|
476
485
|
.extend(adminContext({ adminId: 190202471 })) // KEV.TELEGRAM_ADMIN_ID overrides
|
|
477
486
|
.extend(userSession)
|
|
478
487
|
.extend(accessControl({ session: userSession, storage, defaults: [] }))
|
|
479
|
-
.extend(llmStream())
|
|
480
488
|
.extend(chat.plugin)
|
|
481
489
|
.on('message', async (ctx) => {
|
|
482
490
|
if (!ctx.access.allowed) return
|
|
483
491
|
ctx.llm.add({ role: 'user', content: ctx.text ?? '' })
|
|
484
492
|
|
|
485
|
-
//
|
|
486
|
-
const
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
model: process.env.LLM_MODEL,
|
|
491
|
-
messages: [{ role: 'system', content: 'You are helpful.' }, ...ctx.llm.get()],
|
|
492
|
-
stream: true,
|
|
493
|
-
}),
|
|
494
|
-
})
|
|
495
|
-
|
|
496
|
-
// High-level: handles reasoning (collapsed blockquote) + content streaming.
|
|
497
|
-
const { content } = await ctx.startChatStream(response)
|
|
493
|
+
// Draft-streamed reply: thinking phase previews + final entity-split send.
|
|
494
|
+
const { content } = await streamChatReply(ctx, llm.stream({
|
|
495
|
+
instructions: 'You are helpful.',
|
|
496
|
+
messages: toModelMessages(ctx.llm.get()),
|
|
497
|
+
}))
|
|
498
498
|
ctx.llm.add({ role: 'assistant', content })
|
|
499
499
|
})
|
|
500
500
|
|
package/dist/bot/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* import { adminContext, gracefulStart } from '@adriangalilea/utils/bot/kit'
|
|
8
8
|
* import { accessControl } from '@adriangalilea/utils/bot/access-control'
|
|
9
|
-
* import {
|
|
9
|
+
* import { streamChatReply, llmHistory } from '@adriangalilea/utils/bot/llm'
|
|
10
10
|
*
|
|
11
11
|
* Or all-in-one (pulls every subpath):
|
|
12
12
|
* import { ... } from '@adriangalilea/utils/bot'
|
package/dist/bot/index.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* import { adminContext, gracefulStart } from '@adriangalilea/utils/bot/kit'
|
|
8
8
|
* import { accessControl } from '@adriangalilea/utils/bot/access-control'
|
|
9
|
-
* import {
|
|
9
|
+
* import { streamChatReply, llmHistory } from '@adriangalilea/utils/bot/llm'
|
|
10
10
|
*
|
|
11
11
|
* Or all-in-one (pulls every subpath):
|
|
12
12
|
* import { ... } from '@adriangalilea/utils/bot'
|
package/dist/bot/llm.d.ts
CHANGED
|
@@ -1,248 +1,86 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GramIO LLM toolkit —
|
|
3
|
-
*
|
|
2
|
+
* GramIO LLM toolkit — the Telegram side of an LLM chatbot. The model side
|
|
3
|
+
* (providers, failover, usage accounting, tools) lives in
|
|
4
|
+
* `@adriangalilea/utils/llm`; this module renders its event stream into a
|
|
5
|
+
* chat and remembers conversations:
|
|
4
6
|
*
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* `ctx.startChatStream()` — OUTPUT (high-level). Consumes a fetch
|
|
18
|
-
* `Response` (or any `AsyncIterable<LLMChunk>`)
|
|
19
|
-
* and renders BOTH phases of a thinking
|
|
20
|
-
* model: `reasoning` chunks go into one
|
|
21
|
-
* `<blockquote expandable>` message;
|
|
22
|
-
* `content` chunks go into a streamed
|
|
23
|
-
* markdown message below it. Returns
|
|
24
|
-
* `{ content, reasoning }` once the
|
|
25
|
-
* source generator ends.
|
|
7
|
+
* `streamChatReply(ctx, events)` — OUTPUT. Consumes an
|
|
8
|
+
* `AsyncIterable<LlmStreamEvent>` and paints it with Telegram's native
|
|
9
|
+
* message-draft streaming (`sendMessageDraft`: ephemeral ~30s previews,
|
|
10
|
+
* animated in place per draft_id), then persists the finished text via
|
|
11
|
+
* `ctx.send`, entity-split across the 4096 limit by `@gramio/split`.
|
|
12
|
+
* Reasoning models get a "thinking" phase: reasoning streams into the
|
|
13
|
+
* draft preview and evaporates by default, or persists as an expandable
|
|
14
|
+
* blockquote. A `reset` event (provider failover upstream) repaints the
|
|
15
|
+
* draft from scratch — full-frame previews make that one cheap frame.
|
|
16
|
+
* Drafts are a PRIVATE-chat capability (and the bot needs forum topic
|
|
17
|
+
* mode enabled in BotFather); elsewhere the preview phase is skipped and
|
|
18
|
+
* only the final send happens.
|
|
26
19
|
*
|
|
27
20
|
* `ctx.llm.add / .get / …` — HISTORY. Per-(user, thread) conversation
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* `botMenu` 🗑 Forget button wipes it
|
|
32
|
-
* together with everything else (one
|
|
33
|
-
* record, one delete, no per-plugin
|
|
34
|
-
* registry).
|
|
35
|
-
*
|
|
36
|
-
* The trio composes:
|
|
21
|
+
* buffer in OpenAI `ChatMessage` shape, persisted in the shared
|
|
22
|
+
* `@gramio/session` record under the `llm` field, so the `botMenu`
|
|
23
|
+
* 🗑 Forget button wipes it together with everything else.
|
|
37
24
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* └──── ctx.llm.get() ◀──── ctx.llm.add(assistant) ◀──┘
|
|
25
|
+
* Peer deps: `gramio`, `@gramio/session`, `@gramio/format`, `@gramio/split`,
|
|
26
|
+
* `marked`.
|
|
41
27
|
*
|
|
42
|
-
*
|
|
28
|
+
* @example chatbot turn: history → llm.stream → streamed reply
|
|
29
|
+
* import { createLlm } from '@adriangalilea/utils/llm'
|
|
30
|
+
* import { streamChatReply, llmHistory, toModelMessages } from '@adriangalilea/utils/bot/llm'
|
|
43
31
|
*
|
|
44
|
-
*
|
|
45
|
-
* import { Bot } from 'gramio'
|
|
46
|
-
* import { session } from '@gramio/session'
|
|
47
|
-
* import { llmStream, llmHistory } from '@adriangalilea/utils/bot/llm'
|
|
48
|
-
*
|
|
49
|
-
* const userSession = session({ storage, key: 'session', initial: () => ({}) })
|
|
32
|
+
* const llm = createLlm({ providers })
|
|
50
33
|
* const chat = llmHistory({ session: userSession, maxTurns: 20, retentionDays: 7 })
|
|
51
34
|
*
|
|
52
|
-
*
|
|
53
|
-
* .
|
|
54
|
-
* .
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* headers: { 'Content-Type': 'application/json' },
|
|
61
|
-
* body: JSON.stringify({
|
|
62
|
-
* model: process.env.LLM_MODEL,
|
|
63
|
-
* messages: [{ role: 'system', content: 'You are helpful.' }, ...ctx.llm.get()],
|
|
64
|
-
* stream: true,
|
|
65
|
-
* }),
|
|
66
|
-
* })
|
|
67
|
-
* const { content } = await ctx.startChatStream(response)
|
|
68
|
-
* ctx.llm.add({ role: 'assistant', content })
|
|
69
|
-
* })
|
|
70
|
-
*
|
|
71
|
-
* bot.start()
|
|
72
|
-
*
|
|
73
|
-
* @example low-level — manual loop when you need to map/filter chunks
|
|
74
|
-
* import { streamChat } from '@adriangalilea/utils/bot/llm'
|
|
75
|
-
*
|
|
76
|
-
* const stream = ctx.startStream()
|
|
77
|
-
* for await (const chunk of streamChat(response)) {
|
|
78
|
-
* if (chunk.type === 'content') await stream.append(chunk.text)
|
|
79
|
-
* // reasoning chunks dropped here
|
|
80
|
-
* }
|
|
81
|
-
* await stream.end()
|
|
35
|
+
* bot.extend(userSession).extend(chat.plugin).on('message', async (ctx) => {
|
|
36
|
+
* ctx.llm.add({ role: 'user', content: ctx.text ?? '' })
|
|
37
|
+
* const { content } = await streamChatReply(ctx, llm.stream({
|
|
38
|
+
* instructions: 'You are helpful.',
|
|
39
|
+
* messages: toModelMessages(ctx.llm.get()),
|
|
40
|
+
* }))
|
|
41
|
+
* ctx.llm.add({ role: 'assistant', content })
|
|
42
|
+
* })
|
|
82
43
|
*/
|
|
83
44
|
import type { session } from "@gramio/session";
|
|
84
|
-
import { type DeriveDefinitions, Plugin } from "gramio";
|
|
45
|
+
import { type Bot, type DeriveDefinitions, type MessageContext, Plugin } from "gramio";
|
|
46
|
+
import type { LlmStreamEvent, LlmToolCall, LlmUsage, ModelMessage } from "../llm/index.js";
|
|
85
47
|
import type { Polyglot } from "../say/index.js";
|
|
86
48
|
import type { MenuItem } from "./menu.js";
|
|
87
|
-
|
|
88
|
-
* A single chunk yielded by `streamChat`. Two kinds:
|
|
89
|
-
*
|
|
90
|
-
* - `content` — the visible reply text the user should see
|
|
91
|
-
* - `reasoning` — chain-of-thought / "thinking" text from reasoning
|
|
92
|
-
* models. Empty unless the model emits it.
|
|
93
|
-
*
|
|
94
|
-
* Most callers care about `content` only. Render `reasoning` separately
|
|
95
|
-
* (collapsed, italicized) if you want to surface thinking.
|
|
96
|
-
*/
|
|
97
|
-
export type LLMChunk = {
|
|
98
|
-
type: "content";
|
|
99
|
-
text: string;
|
|
100
|
-
} | {
|
|
101
|
-
type: "reasoning";
|
|
102
|
-
text: string;
|
|
103
|
-
};
|
|
104
|
-
/**
|
|
105
|
-
* Parse an OpenAI-compatible chat-completions SSE response into a
|
|
106
|
-
* typed `AsyncGenerator<LLMChunk>`. Reads `response.body` once.
|
|
107
|
-
*
|
|
108
|
-
* Recognised reasoning aliases (as of 2026): `reasoning_content`
|
|
109
|
-
* (vllm, qwen3, DeepSeek-R1, gpt-oss harmony) and `reasoning` (some
|
|
110
|
-
* mlx-lm forks, gemma builds). If a model surfaces a new key, add it
|
|
111
|
-
* here — single source of truth for the field.
|
|
112
|
-
*
|
|
113
|
-
* Constrained-SSE assumption: lines are `\n`-delimited and each event
|
|
114
|
-
* is `data: <json>` or `data: [DONE]`. This matches every OpenAI-compat
|
|
115
|
-
* server in the wild but is NOT the full SSE spec (no comments, no
|
|
116
|
-
* multi-line `data:`, no `retry`/`id` fields). Swap to
|
|
117
|
-
* `eventsource-parser` if you hit a producer that needs them.
|
|
118
|
-
*
|
|
119
|
-
* Malformed JSON lines are silently skipped; the generator ends when
|
|
120
|
-
* the stream closes.
|
|
121
|
-
*
|
|
122
|
-
* @param response the `fetch` `Response` from a `stream: true` chat
|
|
123
|
-
* completion call. Must not be already consumed.
|
|
124
|
-
* @throws if `response.body` is null (non-streaming response).
|
|
125
|
-
*
|
|
126
|
-
* @example framework-agnostic — parser doesn't know about Telegram
|
|
127
|
-
* const res = await fetch(url, { method: 'POST', body })
|
|
128
|
-
* for await (const chunk of streamChat(res)) {
|
|
129
|
-
* if (chunk.type === 'content') process.stdout.write(chunk.text)
|
|
130
|
-
* }
|
|
131
|
-
*/
|
|
132
|
-
export declare function streamChat(response: Response): AsyncGenerator<LLMChunk>;
|
|
133
|
-
export type StreamOptions = {
|
|
134
|
-
/** Debounce window between edits, in ms. Default 800. */
|
|
135
|
-
debounceMs?: number;
|
|
136
|
-
/** Initial placeholder shown until the first chunk arrives. Default "…". */
|
|
137
|
-
placeholder?: string;
|
|
138
|
-
/** Parse buffer as markdown. Default true. Set false for plain text streaming. */
|
|
139
|
-
markdown?: boolean;
|
|
140
|
-
/** Called on edit/send errors after internal recovery (rate limits, etc.). */
|
|
141
|
-
onError?: (err: unknown) => void;
|
|
142
|
-
};
|
|
143
|
-
export declare class MarkdownStreamer {
|
|
144
|
-
private buffer;
|
|
145
|
-
private currentMessageId?;
|
|
146
|
-
private firstSendPromise?;
|
|
147
|
-
private debounceTimer?;
|
|
148
|
-
private inFlight;
|
|
149
|
-
private dirty;
|
|
150
|
-
private ended;
|
|
49
|
+
export interface StreamChatReplyOptions {
|
|
151
50
|
/**
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
* try { for await (...) await stream.append(...) }
|
|
157
|
-
* finally {
|
|
158
|
-
* await stream.end()
|
|
159
|
-
* if (stream.wasPartial) logger.warn('LLM stream cut off')
|
|
160
|
-
* }
|
|
51
|
+
* What happens to a reasoning model's thinking text.
|
|
52
|
+
* `preview` (default) — streams into the ephemeral draft, evaporates when
|
|
53
|
+
* the answer starts. `message` — additionally persists as an expandable
|
|
54
|
+
* blockquote message before the answer. `hidden` — never rendered.
|
|
161
55
|
*/
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
threadId?: number;
|
|
172
|
-
bot: MarkdownStreamer["bot"];
|
|
173
|
-
}, opts: StreamOptions);
|
|
174
|
-
/** Append a chunk. Schedules a debounced edit. */
|
|
175
|
-
append(text: string): Promise<void>;
|
|
176
|
-
/** Flush any pending edit and close the stream. Idempotent. */
|
|
177
|
-
end(): Promise<void>;
|
|
178
|
-
private scheduleFlush;
|
|
179
|
-
private flushNow;
|
|
56
|
+
reasoning?: "preview" | "message" | "hidden";
|
|
57
|
+
/** Ms between draft repaints. Default 1000. */
|
|
58
|
+
throttleMs?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Extra params for the finalizing `ctx.send` calls (e.g. `reply_markup`).
|
|
61
|
+
* Applied to the LAST part when the reply splits across messages, so a
|
|
62
|
+
* keyboard lands at the end.
|
|
63
|
+
*/
|
|
64
|
+
messageParams?: Parameters<MessageContext<Bot>["send"]>[1];
|
|
180
65
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
*
|
|
184
|
-
* Defaults set here apply to every stream; per-call options in
|
|
185
|
-
* `ctx.startStream({...})` override them.
|
|
186
|
-
*/
|
|
187
|
-
export declare const llmStream: (defaults?: StreamOptions) => Plugin<{}, DeriveDefinitions & {
|
|
188
|
-
message: {
|
|
189
|
-
startStream: (opts?: StreamOptions) => MarkdownStreamer;
|
|
190
|
-
/**
|
|
191
|
-
* One-call helper for chat completions: consumes a `Response`
|
|
192
|
-
* (or any `AsyncIterable<LLMChunk>`), renders reasoning to an
|
|
193
|
-
* expandable blockquote message + content to a streamed markdown
|
|
194
|
-
* message, returns `{ content, reasoning }` when the stream ends.
|
|
195
|
-
*/
|
|
196
|
-
startChatStream: (source: Response | AsyncIterable<LLMChunk>, opts?: ChatStreamOptions) => Promise<ChatStreamResult>;
|
|
197
|
-
};
|
|
198
|
-
}, {}>;
|
|
199
|
-
/**
|
|
200
|
-
* Return shape of `consumeChatStream` / `ctx.startChatStream`. Both
|
|
201
|
-
* full transcripts are returned so the caller decides what to persist
|
|
202
|
-
* (typically `content` to `ctx.llm`; `reasoning` already lives in the
|
|
203
|
-
* Telegram message and is rarely worth storing).
|
|
204
|
-
*/
|
|
205
|
-
export type ChatStreamResult = {
|
|
206
|
-
/** Full assistant text concatenated from all `content` chunks. */
|
|
66
|
+
export interface ChatReplyResult {
|
|
67
|
+
/** Full assistant markdown, concatenated from `delta` events. */
|
|
207
68
|
content: string;
|
|
208
|
-
/** Full reasoning text
|
|
69
|
+
/** Full reasoning text. Empty for non-thinking models. */
|
|
209
70
|
reasoning: string;
|
|
210
|
-
|
|
211
|
-
|
|
71
|
+
/** Tool calls the model made (the caller executes them; nothing is rendered). */
|
|
72
|
+
toolCalls: LlmToolCall[];
|
|
73
|
+
/** End-of-stream usage accounting, when the provider reported it. */
|
|
74
|
+
usage: LlmUsage | null;
|
|
75
|
+
/** The persisted message(s), in order. Empty when the model produced no text (tool-call-only turns). */
|
|
76
|
+
messages: MessageContext<Bot>[];
|
|
77
|
+
}
|
|
212
78
|
/**
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
* - reasoning chunks → one message containing a single
|
|
217
|
-
* `<blockquote expandable>…</blockquote>` (HTML), debounced edits,
|
|
218
|
-
* stays expanded after the stream ends so the user can review it
|
|
219
|
-
* collapsed/uncollapsed at will
|
|
220
|
-
* - content chunks → a fresh `MarkdownStreamer` message below the
|
|
221
|
-
* reasoning one (auto-threaded via SendMixin)
|
|
222
|
-
*
|
|
223
|
-
* Returns when the source generator ends. Equivalent to (and replaces)
|
|
224
|
-
* the ~50 LOC of bookkeeping every thinking-model bot would otherwise
|
|
225
|
-
* write by hand around `streamChat` + `ctx.startStream`.
|
|
226
|
-
*
|
|
227
|
-
* Accepts either a raw `fetch` `Response` (wrapped with `streamChat`
|
|
228
|
-
* internally) or an `AsyncIterable<LLMChunk>` (when you want to map /
|
|
229
|
-
* filter chunks before the stream hits Telegram).
|
|
230
|
-
*
|
|
231
|
-
* @example via ctx.startChatStream from the llmStream plugin
|
|
232
|
-
* const response = await fetch(LLM_URL, { method: 'POST', body })
|
|
233
|
-
* const { content } = await ctx.startChatStream(response)
|
|
234
|
-
* ctx.llm.add({ role: 'assistant', content })
|
|
235
|
-
*
|
|
236
|
-
* @example standalone, framework-agnostic
|
|
237
|
-
* const { content, reasoning } = await consumeChatStream(ctx, response)
|
|
79
|
+
* Render an LLM event stream into the chat: live draft previews while
|
|
80
|
+
* generating, entity-split persisted message(s) when done. See the module doc
|
|
81
|
+
* for the full contract. Returns the transcript pieces plus the sent messages.
|
|
238
82
|
*/
|
|
239
|
-
export declare
|
|
240
|
-
chat: {
|
|
241
|
-
id: number;
|
|
242
|
-
};
|
|
243
|
-
threadId?: number;
|
|
244
|
-
bot: MarkdownStreamer["bot"];
|
|
245
|
-
}, source: Response | AsyncIterable<LLMChunk>, opts?: ChatStreamOptions) => Promise<ChatStreamResult>;
|
|
83
|
+
export declare function streamChatReply(ctx: MessageContext<Bot>, events: AsyncIterable<LlmStreamEvent>, opts?: StreamChatReplyOptions): Promise<ChatReplyResult>;
|
|
246
84
|
/**
|
|
247
85
|
* Multimodal content shape from OpenAI's chat-completions spec. Either
|
|
248
86
|
* a plain string or an ordered array of typed parts. Image URLs cover
|
|
@@ -269,6 +107,13 @@ export type ChatMessage = {
|
|
|
269
107
|
/** Unix seconds when added — used for retention pruning. */
|
|
270
108
|
date: number;
|
|
271
109
|
};
|
|
110
|
+
/**
|
|
111
|
+
* Convert stored history into the AI SDK's `ModelMessage` shape for
|
|
112
|
+
* `llm.stream({ messages })`. Text and image parts map 1:1; `tool` turns are
|
|
113
|
+
* dropped (tool plumbing belongs to the current request, not replayed
|
|
114
|
+
* history); an assistant turn's parts flatten to text.
|
|
115
|
+
*/
|
|
116
|
+
export declare function toModelMessages(history: ReadonlyArray<ChatMessage>): ModelMessage[];
|
|
272
117
|
/** Per-thread shards of `ChatMessage`s, persisted in the session. */
|
|
273
118
|
type ChatRecord = {
|
|
274
119
|
shards: {
|
|
@@ -325,7 +170,7 @@ export type LLMHistoryFeature = {
|
|
|
325
170
|
* thread" button that BOTH wipes `ctx.llm` for the current
|
|
326
171
|
* (user, thread) shard AND calls `deleteForumTopic` to remove the
|
|
327
172
|
* Telegram thread (and all its messages) from the chat. Sibling
|
|
328
|
-
* threads stay intact. Falls back to
|
|
173
|
+
* threads stay intact. Falls back to history-only clear when there is
|
|
329
174
|
* no `threadId` (general/non-threaded chats) or Telegram rejects
|
|
330
175
|
* the deletion (e.g. forum supergroup without admin rights).
|
|
331
176
|
*/
|
package/dist/bot/llm.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"llm.d.ts","sourceRoot":"","sources":["../../src/bot/llm.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"llm.d.ts","sourceRoot":"","sources":["../../src/bot/llm.ts"],"names":[],"mappings":"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"}
|