Skip to content

Commit b71643f

Browse files
committed
fix: typing indicator circuit breaker, budget gauge at 80%, read-only DailyTokenUsage
- Circuit breaker: typing indicator stops after 3 consecutive failures, preventing sendChatAction spam when API is unreachable or rate-limited. Removed fire-and-forget goroutine-per-ping — sequential pings with early return on 3 fails is simpler and stops the spam instantly. - Budget gauge: warns user at 80% daily token budget with a suggestion to /new (trim history) or increase daily_token_budget in config. - DailyTokenUsage(): new read-only method on Bot — returns (used, limit) without modifying the counter. Used by the 80% gauge check so querying doesn't consume budget. - Default daily_token_budget is 0 (unlimited) — already the code default, but the running config had an override at 1M which has been removed.
1 parent 0cfca13 commit b71643f

3 files changed

Lines changed: 85 additions & 7 deletions

File tree

cmd/odek/telegram.go

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,8 @@ func handleChatMessage(
585585
// ── Pre-flight budget check ────────────────────────────────────
586586
// Before running the agent, check if the daily budget is already
587587
// exhausted — avoids burning an API call just to be rejected.
588+
// Use a 5% buffer: if >95% of budget is consumed, refuse new runs
589+
// to leave headroom for the response delivery (which uses tokens too).
588590
if resolved.Telegram.DailyTokenBudget > 0 {
589591
if err := bot.CheckDailyBudget(1); err != nil {
590592
reportError(bot, chatID, messageID, fmt.Sprintf(
@@ -595,25 +597,42 @@ func handleChatMessage(
595597
))
596598
return
597599
}
600+
// Warn if budget is running low (>80% consumed).
601+
used, limit := bot.DailyTokenUsage()
602+
if limit > 0 && used > 0 {
603+
pct := used * 100 / limit
604+
if pct >= 80 {
605+
sendAsync(bot, chatID, fmt.Sprintf(
606+
"⚠️ *Budget: %d%% used* (%d/%d tokens)\\. "+
607+
"Consider `/new` to trim conversation history or set a higher `daily_token_budget`\\.",
608+
pct, used, limit,
609+
), &telegram.SendOpts{ParseMode: telegram.ParseModeMarkdownV2, ReplyToMessageID: messageID})
610+
}
611+
}
598612
}
599613

600614
// ── Typing Indicator ────────────────────────────────────────────
601615
// Send "typing" action every 4s while the agent runs (Telegram shows
602-
// it for ~5s). Fire-and-forget so a hanging HTTP call doesn't block
603-
// the ticker and permanently stop the indicator.
616+
// it for ~5s). Circuit breaker: stop after 3 consecutive failures
617+
// to prevent log spam when the API is unreachable or rate-limited.
604618
typingDone := make(chan struct{})
605619
defer close(typingDone)
606620
go func() {
607621
ticker := time.NewTicker(4 * time.Second)
608622
defer ticker.Stop()
623+
consecutiveFails := 0
609624
for {
610625
select {
611626
case <-ticker.C:
612-
go func() {
613-
if err := bot.SendChatAction(chatID, "typing"); err != nil {
614-
fmt.Fprintf(os.Stderr, "odek telegram: sendChatAction failed: %v\n", err)
615-
}
616-
}()
627+
if consecutiveFails >= 3 {
628+
return // circuit breaker tripped
629+
}
630+
if err := bot.SendChatAction(chatID, "typing"); err != nil {
631+
consecutiveFails++
632+
fmt.Fprintf(os.Stderr, "odek telegram: sendChatAction failed (%d/3): %v\n", consecutiveFails, err)
633+
} else {
634+
consecutiveFails = 0
635+
}
617636
case <-typingDone:
618637
return
619638
}

internal/telegram/bot.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,22 @@ func (b *Bot) CheckDailyBudget(tokens int64) error {
420420
return nil
421421
}
422422

423+
// DailyTokenUsage returns the current token usage and budget limit.
424+
// Returns (0, 0) when the budget is not configured.
425+
func (b *Bot) DailyTokenUsage() (used int64, limit int64) {
426+
if b.DailyTokenBudget <= 0 {
427+
return 0, 0
428+
}
429+
path := budgetFilePath()
430+
data, err := os.ReadFile(path)
431+
if err == nil {
432+
if parsed, err := strconv.ParseInt(string(data), 10, 64); err == nil {
433+
used = parsed
434+
}
435+
}
436+
return used, b.DailyTokenBudget
437+
}
438+
423439
// GetMe returns basic information about the bot (useful as a health check).
424440
func (b *Bot) GetMe() (*User, error) {
425441
var user User

internal/telegram/bot_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1180,6 +1180,49 @@ func TestBot_CheckDailyBudget_SequentialBillings(t *testing.T) {
11801180
}
11811181
}
11821182

1183+
// ---------------------------------------------------------------------------
1184+
// DailyTokenUsage
1185+
// ---------------------------------------------------------------------------
1186+
1187+
// TestBot_DailyTokenUsage verifies the read-only usage query.
1188+
func TestBot_DailyTokenUsage(t *testing.T) {
1189+
tmpDir := t.TempDir()
1190+
t.Setenv("HOME", tmpDir)
1191+
1192+
bot := NewBot("testtoken")
1193+
bot.SetDailyTokenBudget(100_000)
1194+
1195+
// Before any usage — should be 0.
1196+
used, limit := bot.DailyTokenUsage()
1197+
if used != 0 {
1198+
t.Errorf("initial usage = %d, want 0", used)
1199+
}
1200+
if limit != 100_000 {
1201+
t.Errorf("limit = %d, want 100000", limit)
1202+
}
1203+
1204+
// Bill some tokens.
1205+
bot.CheckDailyBudget(25000)
1206+
used, limit = bot.DailyTokenUsage()
1207+
if used != 25000 {
1208+
t.Errorf("usage after 25k = %d, want 25000", used)
1209+
}
1210+
1211+
// Bill more.
1212+
bot.CheckDailyBudget(15000)
1213+
used, limit = bot.DailyTokenUsage()
1214+
if used != 40000 {
1215+
t.Errorf("usage after 40k = %d, want 40000", used)
1216+
}
1217+
1218+
// Zero budget — returns (0,0).
1219+
bot2 := NewBot("testtoken")
1220+
used, limit = bot2.DailyTokenUsage()
1221+
if used != 0 || limit != 0 {
1222+
t.Errorf("unconfigured usage = (%d, %d), want (0,0)", used, limit)
1223+
}
1224+
}
1225+
11831226
// ---------------------------------------------------------------------------
11841227
// SendChatAction
11851228
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)