|
5 | 5 |
|
6 | 6 | import logging |
7 | 7 | import math |
| 8 | +import os |
8 | 9 | import re |
9 | 10 | from abc import ABC, abstractmethod |
10 | 11 | from functools import lru_cache |
@@ -397,13 +398,17 @@ class AsyncTokenCounter: |
397 | 398 | performed synchronously and the callback is invoked immediately. |
398 | 399 | """ |
399 | 400 |
|
400 | | - _DEFAULT_POOL_SIZE = 2 |
| 401 | + # Dynamically scale the thread pool based on CPU cores. |
| 402 | + # On low-spec machines, limit threads to avoid starving the main gevent loop. |
| 403 | + # On high-spec machines, cap at 16 to prevent excessive context switching. |
| 404 | + _DEFAULT_POOL_SIZE = min(16, max(4, os.cpu_count() or 4)) |
401 | 405 |
|
402 | 406 | def __init__(self, pool_size: int = _DEFAULT_POOL_SIZE) -> None: |
403 | 407 | """Initialize the async token counter with the given pool size.""" |
404 | 408 | self._pool_size = pool_size |
405 | 409 | self._pool = None # lazily created |
406 | 410 | self._pending = None # lazily created |
| 411 | + self._pending_inputs: Dict[int, Tuple[Callable, str, str, str]] = {} |
407 | 412 |
|
408 | 413 | def _get_pool(self): |
409 | 414 | """Get or lazily create the OS thread pool.""" |
@@ -501,6 +506,14 @@ def count_async( |
501 | 506 | on_complete, |
502 | 507 | _log, |
503 | 508 | ) |
| 509 | + gid = id(g) |
| 510 | + self._pending_inputs[gid] = ( |
| 511 | + on_complete, |
| 512 | + user_prompt, |
| 513 | + reasoning_content, |
| 514 | + content, |
| 515 | + ) |
| 516 | + g.link(lambda _g: self._pending_inputs.pop(id(_g), None)) |
504 | 517 | pending.add(g) |
505 | 518 | else: |
506 | 519 | # gevent unavailable — synchronous fallback |
@@ -569,6 +582,57 @@ def join_pending(self, timeout: float = 5) -> int: |
569 | 582 | pending.join(timeout=timeout) |
570 | 583 | return len(pending) |
571 | 584 |
|
| 585 | + def drain_pending(self, idle_timeout: float = 5, max_timeout: float = 60) -> int: |
| 586 | + """Wait for pending greenlets to finish with sync fallback. |
| 587 | +
|
| 588 | + Args: |
| 589 | + idle_timeout: Ignored (kept for backward compatibility). |
| 590 | + max_timeout: Hard cap on total wait time. |
| 591 | +
|
| 592 | + Returns: |
| 593 | + Number of greenlets that did NOT complete (always 0 after fallback). |
| 594 | + """ |
| 595 | + pending = self._get_pending() |
| 596 | + if pending is None or len(pending) == 0: |
| 597 | + return 0 |
| 598 | + |
| 599 | + pending.join(timeout=max_timeout) |
| 600 | + remaining = len(pending) |
| 601 | + if remaining > 0: |
| 602 | + logger.warning( |
| 603 | + "drain_pending: timeout %.1fs reached, %d greenlets remaining. " |
| 604 | + "Using byte-estimation fallback.", |
| 605 | + max_timeout, |
| 606 | + remaining, |
| 607 | + ) |
| 608 | + self._fallback_remaining() |
| 609 | + return 0 |
| 610 | + |
| 611 | + def _fallback_remaining(self) -> None: |
| 612 | + """Synchronously estimate tokens for any greenlets that didn't finish.""" |
| 613 | + stale = dict(self._pending_inputs) |
| 614 | + self._pending_inputs.clear() |
| 615 | + for _gid, ( |
| 616 | + on_complete, |
| 617 | + user_prompt, |
| 618 | + reasoning_content, |
| 619 | + content, |
| 620 | + ) in stale.items(): |
| 621 | + try: |
| 622 | + input_tokens = ( |
| 623 | + estimate_tokens_via_bytes(user_prompt) if user_prompt else 0 |
| 624 | + ) |
| 625 | + completion_tokens = 0 |
| 626 | + if reasoning_content: |
| 627 | + completion_tokens += estimate_tokens_via_bytes(reasoning_content) |
| 628 | + if content: |
| 629 | + completion_tokens += estimate_tokens_via_bytes(content) |
| 630 | + total_tokens = input_tokens + completion_tokens |
| 631 | + if completion_tokens > 0 or total_tokens > 0: |
| 632 | + on_complete(input_tokens, completion_tokens, total_tokens) |
| 633 | + except Exception as e: |
| 634 | + logger.error("Fallback token estimation failed: %s", e) |
| 635 | + |
572 | 636 |
|
573 | 637 | def estimate_tokens_via_bytes(text: str) -> int: |
574 | 638 | """Heuristic: estimate tokens from UTF-8 byte length. |
|
0 commit comments