mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-22 00:31:44 +00:00
* feat(rust): count tiktoken cl100k_base admission tokens in Rust The Rust admission token counter only had the Anthropic tokenizer, so every other model (OpenAI gpt-4 family, Azure, Gemini, Bedrock non-Claude, Mistral) tokenized with tiktoken on the Python inference worker. Add an exact cl100k_base counter to litellm-token-counter: the vendored rank file (base64 token / rank lines, the bytes Python's tiktoken uses) is parsed into a byte-level BPE model and the cl100k split pattern is a handwritten scanner over the shared Unicode classes, so no regex engine runs per request. Both tokenizers share the message, tool and reply-priming accounting. The PyO3 TokenCounter gains a from_cl100k_ranks constructor; Python reads the rank file and passes it in, the way claude_json_str already works. The bridge selects the counter through the same predicates litellm.token_counter uses (huggingface_tokenizer_kind, openai_tokenizer_encoding), declines o200k_base, downloaded HuggingFace and custom tokenizers to Python, and budget reservation counts once per distinct tokenizer a request names. The legacy gpt-3.5-turbo-0301 message accounting (4 per message, -1 per name) stays in Python: the selector declines it through the predicate token_counter itself uses. * feat(rust): count tiktoken o200k_base admission tokens in Rust (#40794) Add a handwritten o200k_base split scanner and TokenCounter::from_o200k_ranks next to the cl100k_base counter, sharing MergeRanks and the request accounting. The Python bridge selects it when openai_tokenizer_encoding names o200k_base, so gpt-4o, gpt-4.1, gpt-5, o1/o3/o4 and chatgpt-4o requests stop tokenizing on the Python worker under LITELLM_RUST=true Co-authored-by: yassin <yassin@berri.ai> --------- Co-authored-by: yassin <yassin@berri.ai> Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
105 lines
3.5 KiB
Rust
105 lines
3.5 KiB
Rust
use std::num::NonZero;
|
|
use std::sync::Arc;
|
|
use std::thread::available_parallelism;
|
|
|
|
use litellm_python_interop::release_gil;
|
|
use litellm_token_counter::{
|
|
CountableRequest, Error, InputTokenCount, TokenCounter as CoreTokenCounter,
|
|
};
|
|
use pyo3::exceptions::{PyRuntimeError, PyValueError};
|
|
use pyo3::prelude::*;
|
|
use pyo3::types::PyAny;
|
|
use tokio::sync::Semaphore;
|
|
|
|
use crate::constants::TOKEN_COUNT_FALLBACK_PARALLELISM;
|
|
use crate::errors::RustBridgeDeclined;
|
|
use crate::execution::run_async;
|
|
|
|
/// Counts the input tokens of a raw request body off the Python event loop with
|
|
/// the GIL released. Python owns which requests get here and what to do with
|
|
/// the count. At most one encode per core runs at a time; the rest wait in the
|
|
/// async task, where a cancelled Python awaiter drops them before any blocking
|
|
/// work is scheduled.
|
|
#[pyclass(frozen)]
|
|
struct TokenCounter {
|
|
inner: Arc<CoreTokenCounter>,
|
|
encode_slots: Arc<Semaphore>,
|
|
}
|
|
|
|
#[pymethods]
|
|
impl TokenCounter {
|
|
#[new]
|
|
fn new(py: Python<'_>, tokenizer_json: &str) -> PyResult<Self> {
|
|
Self::load(py, || CoreTokenCounter::from_json(tokenizer_json))
|
|
}
|
|
|
|
#[staticmethod]
|
|
fn from_cl100k_ranks(py: Python<'_>, rank_file: &str) -> PyResult<Self> {
|
|
Self::load(py, || CoreTokenCounter::from_cl100k_ranks(rank_file))
|
|
}
|
|
|
|
#[staticmethod]
|
|
fn from_o200k_ranks(py: Python<'_>, rank_file: &str) -> PyResult<Self> {
|
|
Self::load(py, || CoreTokenCounter::from_o200k_ranks(rank_file))
|
|
}
|
|
|
|
fn acount_request<'py>(&self, py: Python<'py>, body: &[u8]) -> PyResult<Bound<'py, PyAny>> {
|
|
let counter = Arc::clone(&self.inner);
|
|
let encode_slots = Arc::clone(&self.encode_slots);
|
|
let body = body.to_vec();
|
|
run_async(
|
|
py,
|
|
async move {
|
|
let _slot = encode_slots
|
|
.acquire_owned()
|
|
.await
|
|
.map_err(|error| Error::Task(error.to_string()))?;
|
|
tokio::task::spawn_blocking(move || count_body(&counter, &body))
|
|
.await
|
|
.map_err(|error| Error::Task(error.to_string()))?
|
|
},
|
|
token_count_error_to_pyerr,
|
|
)
|
|
}
|
|
}
|
|
|
|
impl TokenCounter {
|
|
fn load(
|
|
py: Python<'_>,
|
|
load: impl FnOnce() -> Result<CoreTokenCounter, Error> + Send,
|
|
) -> PyResult<Self> {
|
|
let inner = release_gil(py, load).map_err(token_count_error_to_pyerr)?;
|
|
Ok(Self {
|
|
inner: Arc::new(inner),
|
|
encode_slots: Arc::new(Semaphore::new(encode_parallelism())),
|
|
})
|
|
}
|
|
}
|
|
|
|
fn encode_parallelism() -> usize {
|
|
available_parallelism().map_or(TOKEN_COUNT_FALLBACK_PARALLELISM, NonZero::get)
|
|
}
|
|
|
|
fn count_body(counter: &CoreTokenCounter, body: &[u8]) -> Result<InputTokenCount, Error> {
|
|
let request = CountableRequest::parse(body)?;
|
|
counter.count_request(&request)
|
|
}
|
|
|
|
fn token_count_error_to_pyerr(error: Error) -> PyErr {
|
|
let message = error.to_string();
|
|
match error {
|
|
Error::Load(_) | Error::Ranks(_) | Error::UnicodeClasses => PyValueError::new_err(message),
|
|
Error::RequestParse(_)
|
|
| Error::MissingInput
|
|
| Error::FloatText
|
|
| Error::ContentBlock
|
|
| Error::ArrayItems
|
|
| Error::JsonSerialization(_)
|
|
| Error::JsonUtf8(_) => RustBridgeDeclined::new_err(message),
|
|
Error::Encode(_) | Error::Task(_) => PyRuntimeError::new_err(message),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
module.add_class::<TokenCounter>()
|
|
}
|