diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx
index e2fec8160..91e912ea9 100644
--- a/apps/fabro-web/app/routes/run-detail.tsx
+++ b/apps/fabro-web/app/routes/run-detail.tsx
@@ -680,7 +680,7 @@ export default function RunDetail({ params }: { params: { id: string } }) {
diff --git a/lib/crates/fabro-llm/README.md b/lib/crates/fabro-llm/README.md
index 2e251b97c..aa992daaa 100644
--- a/lib/crates/fabro-llm/README.md
+++ b/lib/crates/fabro-llm/README.md
@@ -208,6 +208,39 @@ let anthropic_models = list_models(Some("anthropic"));
let best_reasoner = get_latest_model("anthropic", Some("reasoning"));
```
+### Input token counting
+
+Use `count_input_tokens` when you need the current model-visible context size
+without creating a completion:
+
+```rust
+use fabro_llm::{InputTokenCountPreference, Client};
+
+let count = client
+ .count_input_tokens(&request, InputTokenCountPreference::PreferProvider)
+ .await?;
+```
+
+`InputTokenCountPreference` controls precision and data exposure:
+
+- `PreferProvider` sends the provider-serialized request to the upstream
+ token-count endpoint when supported, then falls back to a local estimate only
+ for unsupported adapters, network/timeout failures, rate limits, and provider
+ server errors.
+- `RequireProvider` sends the provider-serialized request and returns either a
+ provider count or an error. It never returns a local estimate.
+- `EstimateOnly` validates and resolves the provider locally, does not call the
+ adapter count endpoint, and returns a deterministic local estimate.
+
+Provider-native counting sends model-visible request content to the provider's
+token-count endpoint. That can include messages, system/developer instructions,
+tools, schemas, structured content, and media metadata/content after provider
+serialization. Use `EstimateOnly` when that extra upstream exposure is not
+acceptable.
+
+`InputTokenCount` is for input/context sizing. It is not billing usage and does
+not include output, reasoning-output, cache-read, or cache-write token buckets.
+
## Key types
| Type | Description |
@@ -222,7 +255,8 @@ let best_reasoner = get_latest_model("anthropic", Some("reasoning"));
| `GenerateResult` | Result containing response, tool results, total usage, and step history |
| `ToolDefinition` | Tool name, description, and JSON Schema parameters |
| `ToolChoice` | Auto, None, Required, or Named tool selection |
-| `Usage` | Token counts including input, output, reasoning, and cache tokens |
+| `InputTokenCount` | Input/context token count from a provider count API or local estimate |
+| `TokenCounts` | Billing-oriented token counts including input, output, reasoning, and cache tokens |
| `RetryPolicy` | Configurable retry with exponential backoff, jitter, and max delay |
| `Model` | Metadata about a model (context window, capabilities, costs) |
diff --git a/lib/crates/fabro-llm/src/client.rs b/lib/crates/fabro-llm/src/client.rs
index eed70a1f6..7ad3153d4 100644
--- a/lib/crates/fabro-llm/src/client.rs
+++ b/lib/crates/fabro-llm/src/client.rs
@@ -6,10 +6,13 @@ use fabro_model::{Catalog, ProviderId};
use tracing::debug;
use crate::adapter_registry::{AdapterConfig, factory_for};
-use crate::error::Error;
+use crate::error::{Error, ProviderErrorKind};
use crate::middleware::{Middleware, NextFn, NextStreamFn};
use crate::provider::{ProviderAdapter, StreamEventStream};
-use crate::types::{Request, Response, Speed};
+use crate::token_count::{
+ InputTokenCount, InputTokenCountMethod, InputTokenCountPreference, estimate_input_tokens,
+};
+use crate::types::{Request, Response, Speed, Warning};
/// The core client that routes requests to provider adapters (Section 2.2, 3).
#[derive(Clone)]
@@ -389,6 +392,59 @@ impl Client {
chain(request.clone()).await
}
+ /// Count the model-visible input/context tokens for a request without
+ /// creating a completion.
+ ///
+ /// # Errors
+ ///
+ /// Returns request validation/provider resolution errors, and returns
+ /// provider count errors when the selected preference requires provider
+ /// semantics or when the error is not fallback-eligible.
+ pub async fn count_input_tokens(
+ &self,
+ request: &Request,
+ preference: InputTokenCountPreference,
+ ) -> Result {
+ self.validate_request_controls(request)?;
+ let provider = self.resolve_provider(request)?;
+ provider.validate_request(request)?;
+
+ if preference == InputTokenCountPreference::EstimateOnly {
+ return Ok(estimate_input_tokens(request, provider.name()));
+ }
+
+ match provider.count_input_tokens(request).await {
+ Ok(Some(count)) => Ok(count),
+ Ok(None) if preference == InputTokenCountPreference::PreferProvider => {
+ Ok(fallback_estimate(
+ request,
+ provider.name(),
+ "provider_token_count_unsupported",
+ "provider does not support input token counting; returned local estimate",
+ ))
+ }
+ Ok(None) => Err(Error::Configuration {
+ message: format!(
+ "provider '{}' does not support input token counting",
+ provider.name()
+ ),
+ source: None,
+ }),
+ Err(error)
+ if preference == InputTokenCountPreference::PreferProvider
+ && token_count_fallback_eligible(&error) =>
+ {
+ Ok(fallback_estimate(
+ request,
+ provider.name(),
+ "provider_token_count_failed",
+ "provider input token counting failed; returned local estimate",
+ ))
+ }
+ Err(error) => Err(error),
+ }
+ }
+
/// Close all provider adapters.
///
/// # Errors
@@ -428,6 +484,39 @@ impl Client {
}
}
+fn token_count_fallback_eligible(error: &Error) -> bool {
+ matches!(
+ error,
+ Error::Network { .. }
+ | Error::RequestTimeout { .. }
+ | Error::Provider {
+ kind: ProviderErrorKind::RateLimit | ProviderErrorKind::Server,
+ ..
+ }
+ )
+}
+
+fn fallback_estimate(
+ request: &Request,
+ provider: &str,
+ code: &'static str,
+ message: &'static str,
+) -> InputTokenCount {
+ let mut count = estimate_input_tokens(request, provider);
+ if count.method == InputTokenCountMethod::LocalEstimate
+ && !count
+ .warnings
+ .iter()
+ .any(|warning| warning.code.as_deref() == Some(code))
+ {
+ count.warnings.push(Warning {
+ message: message.to_string(),
+ code: Some(code.to_string()),
+ });
+ }
+ count
+}
+
fn format_control_values(values: &[T]) -> String {
if values.is_empty() {
"none".to_string()
@@ -450,6 +539,8 @@ fn format_additional_speeds(values: &[Speed]) -> String {
#[cfg(test)]
mod tests {
+ use std::sync::atomic::{AtomicUsize, Ordering};
+
use async_trait::async_trait;
use fabro_auth::{ApiKeyHeader, CredentialSource, ResolvedCredentials};
use fabro_model::ProviderId;
@@ -457,6 +548,7 @@ mod tests {
use futures::stream;
use super::*;
+ use crate::error::ProviderErrorDetail;
use crate::types::*;
/// A mock provider for testing.
@@ -542,6 +634,99 @@ mod tests {
}
}
+ struct CountingProvider {
+ provider_name: String,
+ count_result: std::sync::Mutex, Error>>,
+ count_calls: Arc,
+ reject_named: bool,
+ }
+
+ impl CountingProvider {
+ fn new(result: Result