Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 46 additions & 62 deletions crates/libsy-llm-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,23 +111,6 @@ impl TranslatingLlmClient {
})
}

/// A configured Anthropic backend to forward a direct `count_tokens` call to,
/// paired with its upstream model id (the id the inbound route name is
/// restamped to). `None` when this client has no Anthropic backend;
/// `count_tokens` is Anthropic-only.
fn anthropic_backend(&self) -> Option<(&str, &Backend)> {
self.model_to_config.values().find_map(|config| {
if config.default_backend.is_anthropic() {
return Some((config.model_name.as_str(), &config.default_backend));
}
config
.other_backends
.as_ref()
.and_then(|backends| backends.iter().find(|backend| backend.is_anthropic()))
.map(|backend| (config.model_name.as_str(), backend))
})
}

/// The backend serving `model` over `format` — the default backend when its
/// format matches, otherwise a matching entry in `other_backends`; `None` when
/// the model is unknown or has no backend for `format`.
Expand All @@ -144,6 +127,50 @@ impl TranslatingLlmClient {
})
}

/// Whether `model` has an Anthropic backend that supports token counting.
pub fn supports_count_tokens(&self, model: &str) -> bool {
self.backend_for(model, WireFormat::AnthropicMessages)
.is_some()
}

/// Counts input tokens with `model`'s Anthropic backend.
///
/// Returns an error when the model has no Anthropic backend or the upstream
/// request fails or returns invalid JSON.
pub async fn count_tokens(&self, model: &str, request: Request) -> Result<Value> {
let backend = self
.backend_for(model, WireFormat::AnthropicMessages)
.ok_or_else(|| LlmClientError::Configuration {
message: format!("model {model} has no Anthropic backend for count_tokens"),
})?;
let Request {
llm_request,
metadata,
..
} = request;
let http_response = self
.send_encoded(
backend,
WireFormat::AnthropicMessages,
llm_request,
metadata.as_ref(),
model,
UpstreamEndpoint::CountTokens,
)
.await?;
let body = match http_response {
EncodedResponse::Buffered { body, .. } => body,
EncodedResponse::Streaming(_) => {
return Err(LlmClientError::InvalidRequest {
message: "count_tokens does not support streaming requests".to_string(),
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
serde_json::from_slice(&body).map_err(|error| LlmClientError::InvalidResponse {
source: Box::new(error),
})
}

/// Encode `llm_request` (its model restamped to `model`) for `wire_format`,
/// POST it to `url` with the request's forwarded headers plus the backend's
/// static headers and auth, and return the successful upstream response. A
Expand All @@ -153,8 +180,8 @@ impl TranslatingLlmClient {
/// overflow via the backend's provider rules. Shared by
/// [`call_rewrite_model`](Self::call_rewrite_model) (which POSTs to the
/// backend's completion URL and decodes a response) and
/// [`count_tokens`](RoutedLlmClient::count_tokens) (which POSTs to the
/// `count_tokens` URL and returns the raw JSON).
/// [`count_tokens`](Self::count_tokens) (which POSTs to the `count_tokens`
/// URL and returns the raw JSON).
async fn send_encoded(
&self,
backend: &Backend,
Expand Down Expand Up @@ -488,49 +515,6 @@ impl RoutedLlmClient for TranslatingLlmClient {
let model_name = Some(decision.selected_model());
self.call_rewrite_model(ctx, request, model_name).await
}

fn supports_count_tokens(&self) -> bool {
self.anthropic_backend().is_some()
}

async fn count_tokens(&self, request: Request) -> Result<Value> {
// Direct passthrough: forward straight to this client's Anthropic
// backend's count_tokens endpoint. Not routed — no decision.
let (model, backend) =
self.anthropic_backend()
.ok_or_else(|| LlmClientError::Configuration {
message: "count_tokens is anthropic-only; this client has no anthropic backend"
.to_string(),
})?;
// Same encode-and-forward path as the completion call, only to the
// `count_tokens` URL; the response is the raw `{"input_tokens": N}` JSON.
let Request {
llm_request,
metadata,
..
} = request;
let http_response = self
.send_encoded(
backend,
WireFormat::AnthropicMessages,
llm_request,
metadata.as_ref(),
model,
UpstreamEndpoint::CountTokens,
)
.await?;
let body = match http_response {
EncodedResponse::Buffered { body, .. } => body,
EncodedResponse::Streaming(_) => {
return Err(LlmClientError::InvalidRequest {
message: "count_tokens does not support streaming requests".to_string(),
});
}
};
serde_json::from_slice(&body).map_err(|error| LlmClientError::InvalidResponse {
source: Box::new(error),
})
}
}

#[derive(Clone, Copy)]
Expand Down
10 changes: 3 additions & 7 deletions crates/libsy/src/algorithms/fall_through.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ use crate::core::algorithm::{self, Algorithm, Driver, LlmTarget, LlmTargetSet, S
use crate::core::classifier::{Classification, Classifier, Score};
use crate::core::processor::{Event, Processor};
use crate::{LibsyError, Result};
use switchyard_protocol::{Context, Decision, Request, Response, RoutedLlmClient};
use switchyard_protocol::{Context, Decision, Request, Response};

struct SessionState<S> {
state: Arc<AsyncMutex<S>>,
Expand Down Expand Up @@ -420,10 +420,6 @@ where
&self.name
}

fn count_tokens_client(&self) -> Option<Arc<dyn RoutedLlmClient>> {
self.targets.count_tokens_client()
}

async fn create_run_task(
self: Arc<Self>,
ctx: Context,
Expand All @@ -441,8 +437,8 @@ mod tests {
use crate::{SystemPromptProcessor, TargetPrompts};

use switchyard_protocol::{
LlmClientError, LlmRequest, LlmResponse, Message, Metadata, Role, completion_text,
text_request, text_response,
LlmClientError, LlmRequest, LlmResponse, Message, Metadata, Role, RoutedLlmClient,
completion_text, text_request, text_response,
};

#[derive(Debug, thiserror::Error)]
Expand Down
6 changes: 1 addition & 5 deletions crates/libsy/src/algorithms/llm_class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ use crate::core::classifier::{Classification, Classifier, Score};
use crate::core::state::{State, StateValue};
use crate::{LibsyError, Result};
use switchyard_protocol::{
AggLlmResponse, Context, LlmClientError, LlmResponse, Request, Response, RoutedLlmClient,
AggLlmResponse, Context, LlmClientError, LlmResponse, Request, Response,
};

const PROMPT_TEMPLATE: &str = include_str!("../prompts/capability-classifier/prompt.md");
Expand Down Expand Up @@ -936,10 +936,6 @@ impl Algorithm for LlmTaskClassifier {
"llm_task_classifier"
}

fn count_tokens_client(&self) -> Option<Arc<dyn RoutedLlmClient>> {
self.route.count_tokens_client()
}

async fn create_run_task(
self: Arc<Self>,
ctx: Context,
Expand Down
10 changes: 1 addition & 9 deletions crates/libsy/src/algorithms/passthrough.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use switchyard_protocol::{Request, Response};

use crate::Result;
use crate::core::algorithm::{Algorithm, Driver, LlmTarget};
use switchyard_protocol::{Context, Decision, RoutedLlmClient};
use switchyard_protocol::{Context, Decision};

/// Routing algorithm that always calls one configured target.
pub struct Passthrough {
Expand Down Expand Up @@ -47,14 +47,6 @@ impl Algorithm for Passthrough {
"passthrough"
}

fn count_tokens_client(&self) -> Option<Arc<dyn RoutedLlmClient>> {
self.target
.llm_client
.as_ref()
.filter(|client| client.supports_count_tokens())
.cloned()
}

async fn create_run_task(
self: Arc<Self>,
ctx: Context,
Expand Down
6 changes: 1 addition & 5 deletions crates/libsy/src/algorithms/rand.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use crate::algorithms::fall_through::{FallThrough, FallThroughDecision};
use crate::core::algorithm::{Algorithm, Driver, LlmTargetSet};
use crate::core::classifier::{Classification, Classifier, Score};
use crate::{LibsyError, Result};
use switchyard_protocol::{Context, Request, Response, RoutedLlmClient};
use switchyard_protocol::{Context, Request, Response};

/// Compatibility name for the decision produced by [`Random`].
pub type RandomDecision = FallThroughDecision;
Expand Down Expand Up @@ -162,10 +162,6 @@ impl Algorithm for Random {
"random"
}

fn count_tokens_client(&self) -> Option<Arc<dyn RoutedLlmClient>> {
self.inner.count_tokens_client()
}

async fn create_run_task(
self: Arc<Self>,
ctx: Context,
Expand Down
6 changes: 1 addition & 5 deletions crates/libsy/src/algorithms/stage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ use crate::core::algorithm::{Algorithm, Driver, LlmTarget, LlmTargetSet};
use crate::core::classifier::{Classification, Classifier};
use crate::core::state::State;
use crate::{LibsyError, Result};
use switchyard_protocol::{Context, Request, Response, RoutedLlmClient};
use switchyard_protocol::{Context, Request, Response};

/// Telemetry name for a router this module assembles.
const STAGE_ROUTER: &str = "stage_router";
Expand Down Expand Up @@ -142,10 +142,6 @@ impl Algorithm for StageRouter {
STAGE_ROUTER
}

fn count_tokens_client(&self) -> Option<Arc<dyn RoutedLlmClient>> {
self.route.count_tokens_client()
}

async fn create_run_task(
self: Arc<Self>,
ctx: Context,
Expand Down
46 changes: 0 additions & 46 deletions crates/libsy/src/core/algorithm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -405,19 +405,6 @@ impl LlmTargetSet {
.cloned()
.ok_or(LibsyError::AllTargetsExcluded)
}

/// The first target's client that can serve `count_tokens` (an Anthropic
/// upstream), or `None` when no target has one. Used by an algorithm's
/// [`count_tokens_client`](crate::Algorithm::count_tokens_client).
pub fn count_tokens_client(&self) -> Option<Arc<dyn RoutedLlmClient>> {
self.targets.iter().find_map(|target| {
target
.llm_client
.as_ref()
.filter(|client| client.supports_count_tokens())
.cloned()
})
}
}

/// Bounds process-local overflow history. Dropping a live session's entry costs one
Expand Down Expand Up @@ -590,39 +577,6 @@ pub trait Algorithm: Send + Sync + 'static {
Ok(())
}

/// The client [`count_tokens`](Self::count_tokens) forwards to: the first of
/// this algorithm's targets whose client can count tokens (an Anthropic
/// upstream). The default is `None` — an algorithm with no Anthropic target
/// does not support token counting.
///
/// CAVEAT: this picks the *first* Anthropic target, not a routed one —
/// count_tokens is a direct passthrough, so it does not run the routing
/// cascade. For a route with several Anthropic tiers "first" is arbitrary;
/// choosing which tier count_tokens should reflect is deferred.
fn count_tokens_client(&self) -> Option<Arc<dyn RoutedLlmClient>> {
None
}

/// Count the tokens `request` would use — a **direct passthrough** to this
/// algorithm's Anthropic target (via
/// [`count_tokens_client`](Self::count_tokens_client)), **not** a routed
/// call. Token counting is a pre-flight estimate with no routing decision,
/// so it deliberately bypasses the classifier cascade (which runs only for
/// completions via [`run`](Self::run)). Returns the upstream's JSON
/// verbatim. Errors when the algorithm has no Anthropic target.
async fn count_tokens(&self, request: Request) -> Result<serde_json::Value> {
let client = self
.count_tokens_client()
.ok_or_else(|| LibsyError::AlgorithmError {
message: "no target supports count_tokens (needs an Anthropic upstream)"
.to_string(),
})?;
client
.count_tokens(request)
.await
.map_err(|source| LibsyError::client_call("count_tokens", source))
}

/// Process a request to completion, returning a stream of [`Step`]s.
///
/// The consumer must fulfill every [`Step::CallLlm`] before the algorithm can
Expand Down
20 changes: 0 additions & 20 deletions crates/protocol/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,24 +180,4 @@ pub trait RoutedLlmClient: Send + Sync {
request: Request,
decision: Arc<dyn Decision>,
) -> Result<Response, LlmClientError>;

/// Whether this client can serve [`count_tokens`](Self::count_tokens) — i.e.
/// it has an Anthropic upstream. The default is `false`.
fn supports_count_tokens(&self) -> bool {
false
}

/// Count the tokens `request` would use — a **direct passthrough**, not a
/// routed call. Forwards `request` to this client's Anthropic
/// `/v1/messages/count_tokens` endpoint (model restamped to the upstream
/// target id) and returns the JSON verbatim. Token counting is a pre-flight
/// estimate with no routing decision, so unlike [`call`](Self::call) it
/// takes no [`Decision`]. The default errors; only an Anthropic-backed
/// client overrides it.
async fn count_tokens(&self, request: Request) -> Result<serde_json::Value, LlmClientError> {
let _ = request;
Err(LlmClientError::Configuration {
message: "count_tokens is not supported by this client".to_string(),
})
}
}
5 changes: 4 additions & 1 deletion crates/switchyard-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ documented in [Stage-Router Routing](../../docs/routing_algorithms/stage_router_
| `POST` | `/v1/chat/completions` | OpenAI Chat Completions |
| `POST` | `/v1/messages` | Anthropic Messages |
| `POST` | `/v1/responses` | OpenAI Responses |
| `POST` | `/v1/messages/count_tokens` | Anthropic token count |
| `POST` | `/v1/messages/count_tokens` | Token count from a route's Anthropic target |
| `GET` | `/v1/models` | Routes served by this deployment |
| `GET` | `/v1/stats` | Per-model request, token, and cost totals |
| `POST` | `/v1/stats/reset` | Clear accumulated stats |
Expand All @@ -130,6 +130,9 @@ Requests name a route by its `id`, so `POST /v1/chat/completions` with `"model":
routes through the `[routes.general]` entry above. Any of the three request formats can address any
route, and the server translates between them.

Token counting selects an Anthropic-format completion target, preferring target names or model IDs
containing `opus`, `sonnet`, then `haiku`. Other ties preserve the route's target order.

## Metrics

`GET /metrics` exposes Prometheus text from the server's process-wide OpenTelemetry provider.
Expand Down
Loading
Loading