mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
refactor(auth): replay native policy on current main
This commit is contained in:
parent
bc363397cc
commit
4297255514
229 changed files with 34021 additions and 5611 deletions
1
litellm-rust/Cargo.lock
generated
1
litellm-rust/Cargo.lock
generated
|
|
@ -4567,6 +4567,7 @@ dependencies = [
|
|||
name = "litellm-request-auth-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"fancy-regex 0.19.2",
|
||||
"litellm-secret-value",
|
||||
"rstest",
|
||||
"serde",
|
||||
|
|
|
|||
|
|
@ -63,9 +63,42 @@ mod _native {
|
|||
};
|
||||
#[pymodule_export]
|
||||
use crate::request_auth::{
|
||||
NativeAuthContinuation, request_auth_budgets, request_auth_litellm_header_token,
|
||||
request_auth_scheme_credential, request_auth_select_api_key,
|
||||
request_auth_select_websocket_api_key, request_auth_start,
|
||||
NativeAuthContinuation, request_auth_access, request_auth_admission,
|
||||
request_auth_agent_access, request_auth_agent_ceiling, request_auth_agent_ceiling_source,
|
||||
request_auth_agent_grants, request_auth_agent_group_load, request_auth_agent_invocation,
|
||||
request_auth_budget_alert, request_auth_budget_alert_plan, request_auth_budget_exemption,
|
||||
request_auth_budget_price, request_auth_budget_route, request_auth_budget_window_start,
|
||||
request_auth_budgets, request_auth_common_budget, request_auth_common_budget_scopes,
|
||||
request_auth_common_flow, request_auth_common_read, request_auth_context,
|
||||
request_auth_context_end_user, request_auth_context_member, request_auth_context_models,
|
||||
request_auth_context_percentage, request_auth_context_temporary,
|
||||
request_auth_context_throttle, request_auth_context_token, request_auth_custom_flow,
|
||||
request_auth_end_user_apply, request_auth_end_user_assignments,
|
||||
request_auth_end_user_default_id, request_auth_end_user_lookup,
|
||||
request_auth_end_user_normalize_id, request_auth_end_user_registry,
|
||||
request_auth_end_user_validation, request_auth_expired, request_auth_fallback_access,
|
||||
request_auth_fallback_candidate, request_auth_key_model_access,
|
||||
request_auth_litellm_header_token, request_auth_llm_route, request_auth_managed,
|
||||
request_auth_managed_model_grants, request_auth_managed_models, request_auth_member_limit,
|
||||
request_auth_member_models, request_auth_model_access, request_auth_model_attribution,
|
||||
request_auth_model_budget_check, request_auth_model_budget_query,
|
||||
request_auth_model_budget_resolve, request_auth_model_groups, request_auth_model_helpers,
|
||||
request_auth_model_projection, request_auth_org_admin, request_auth_org_admin_raw,
|
||||
request_auth_org_route, request_auth_owner_teams, request_auth_personal_budget,
|
||||
request_auth_personal_model_listing, request_auth_personal_model_request,
|
||||
request_auth_personal_models, request_auth_personal_owner, request_auth_reservation,
|
||||
request_auth_resolved_models, request_auth_route_access, request_auth_route_enable,
|
||||
request_auth_route_role, request_auth_route_role_api, request_auth_route_role_expand,
|
||||
request_auth_route_role_inside, request_auth_route_role_jwt,
|
||||
request_auth_route_role_listed, request_auth_scheme_credential,
|
||||
request_auth_select_api_key, request_auth_select_websocket_api_key,
|
||||
request_auth_spend_counter_policy, request_auth_start, request_auth_team_aliases,
|
||||
request_auth_team_override, request_auth_unpriced_model,
|
||||
};
|
||||
#[pymodule_export]
|
||||
use crate::request_auth::{
|
||||
request_auth_budget_lookup_emails, request_auth_budget_lookup_member,
|
||||
request_auth_budget_lookup_organization, request_auth_budget_lookup_soft,
|
||||
};
|
||||
#[pymodule_export]
|
||||
use crate::router::{RouterCall, RouterCoreHandle};
|
||||
|
|
@ -122,6 +155,88 @@ mod tests {
|
|||
Python::initialize();
|
||||
Python::attach(|py| {
|
||||
let mut expected = vec![
|
||||
"NativeAuthContinuation",
|
||||
"request_auth_access",
|
||||
"request_auth_admission",
|
||||
"request_auth_agent_access",
|
||||
"request_auth_agent_ceiling",
|
||||
"request_auth_agent_ceiling_source",
|
||||
"request_auth_agent_grants",
|
||||
"request_auth_agent_group_load",
|
||||
"request_auth_agent_invocation",
|
||||
"request_auth_budget_alert",
|
||||
"request_auth_budget_alert_plan",
|
||||
"request_auth_budget_exemption",
|
||||
"request_auth_budget_price",
|
||||
"request_auth_budget_route",
|
||||
"request_auth_budget_window_start",
|
||||
"request_auth_budgets",
|
||||
"request_auth_common_budget",
|
||||
"request_auth_common_budget_scopes",
|
||||
"request_auth_common_flow",
|
||||
"request_auth_common_read",
|
||||
"request_auth_context",
|
||||
"request_auth_context_end_user",
|
||||
"request_auth_context_member",
|
||||
"request_auth_context_models",
|
||||
"request_auth_context_percentage",
|
||||
"request_auth_context_temporary",
|
||||
"request_auth_context_throttle",
|
||||
"request_auth_context_token",
|
||||
"request_auth_custom_flow",
|
||||
"request_auth_end_user_apply",
|
||||
"request_auth_end_user_assignments",
|
||||
"request_auth_end_user_default_id",
|
||||
"request_auth_end_user_lookup",
|
||||
"request_auth_end_user_normalize_id",
|
||||
"request_auth_end_user_registry",
|
||||
"request_auth_end_user_validation",
|
||||
"request_auth_expired",
|
||||
"request_auth_fallback_access",
|
||||
"request_auth_fallback_candidate",
|
||||
"request_auth_key_model_access",
|
||||
"request_auth_litellm_header_token",
|
||||
"request_auth_llm_route",
|
||||
"request_auth_managed",
|
||||
"request_auth_managed_model_grants",
|
||||
"request_auth_managed_models",
|
||||
"request_auth_member_limit",
|
||||
"request_auth_member_models",
|
||||
"request_auth_model_access",
|
||||
"request_auth_model_attribution",
|
||||
"request_auth_model_budget_check",
|
||||
"request_auth_model_budget_query",
|
||||
"request_auth_model_budget_resolve",
|
||||
"request_auth_model_groups",
|
||||
"request_auth_model_helpers",
|
||||
"request_auth_model_projection",
|
||||
"request_auth_org_admin",
|
||||
"request_auth_org_admin_raw",
|
||||
"request_auth_org_route",
|
||||
"request_auth_owner_teams",
|
||||
"request_auth_personal_budget",
|
||||
"request_auth_personal_model_listing",
|
||||
"request_auth_personal_model_request",
|
||||
"request_auth_personal_models",
|
||||
"request_auth_personal_owner",
|
||||
"request_auth_reservation",
|
||||
"request_auth_resolved_models",
|
||||
"request_auth_route_access",
|
||||
"request_auth_route_enable",
|
||||
"request_auth_route_role",
|
||||
"request_auth_route_role_api",
|
||||
"request_auth_route_role_expand",
|
||||
"request_auth_route_role_inside",
|
||||
"request_auth_route_role_jwt",
|
||||
"request_auth_route_role_listed",
|
||||
"request_auth_scheme_credential",
|
||||
"request_auth_select_api_key",
|
||||
"request_auth_select_websocket_api_key",
|
||||
"request_auth_spend_counter_policy",
|
||||
"request_auth_start",
|
||||
"request_auth_team_aliases",
|
||||
"request_auth_team_override",
|
||||
"request_auth_unpriced_model",
|
||||
"RustBridgeDeclined",
|
||||
"RustUpstreamError",
|
||||
"ForkedAfterNativeRuntimeStarted",
|
||||
|
|
|
|||
|
|
@ -9,11 +9,273 @@ use pyo3::{
|
|||
|
||||
type ProjectedStep = (Py<PyAny>, Option<NativeAuthContinuation>);
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_spend_counter_policy(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::spend_counter_policy::Facts = from_py(facts)?;
|
||||
to_py(
|
||||
py,
|
||||
&py.detach(move || core::spend_counter_policy::decide(facts)),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_common_read(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::common_flow::ReadFacts = from_py(facts)?;
|
||||
to_py(py, &py.detach(move || core::common_flow::read(facts)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_reservation(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::reservation::Facts = from_py(facts)?;
|
||||
to_py(py, &py.detach(move || core::reservation::decide(facts)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_common_flow(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::common_flow::Facts = from_py(facts)?;
|
||||
to_py(py, &py.detach(move || core::common_flow::plan(facts)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_common_budget_scopes(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::common_flow::BudgetFacts = from_py(facts)?;
|
||||
to_py(py, &py.detach(move || core::common_flow::budgets(facts)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_custom_flow(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::common_flow::CustomFacts = from_py(facts)?;
|
||||
to_py(py, &py.detach(move || core::common_flow::custom(facts)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_expired(
|
||||
py: Python<'_>,
|
||||
expires: &Bound<'_, PyAny>,
|
||||
now: &Bound<'_, PyAny>,
|
||||
) -> PyResult<bool> {
|
||||
let expires: core::protocol::DateTime = from_py(expires)?;
|
||||
let now: core::protocol::DateTime = from_py(now)?;
|
||||
py.detach(move || expires.before(&now)).map_err(map_error)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_context(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::auth_context::Facts = from_py(facts)?;
|
||||
to_py(py, &py.detach(move || core::auth_context::plan(facts)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_agent_grants(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::agent_grants::Facts = from_py(facts)?;
|
||||
to_py(py, &py.detach(move || core::agent_grants::decide(facts)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_managed(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::managed::ManagedFacts = from_py(facts)?;
|
||||
to_py(py, &py.detach(move || core::managed::decide(facts)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_agent_access(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::agent_access::Facts = from_py(facts)?;
|
||||
to_py(py, &py.detach(move || core::agent_access::decide(&facts)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_agent_invocation(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::agent_access::Invocation = from_py(facts)?;
|
||||
to_py(
|
||||
py,
|
||||
&py.detach(move || core::agent_access::invocation(&facts)),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_admission(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::admission::AdmissionFacts = from_py(facts)?;
|
||||
to_py(py, &py.detach(move || core::admission::plan(facts)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_access(py: Python<'_>, facts: &Bound<'_, PyAny>) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::access::AccessFacts = from_py(facts)?;
|
||||
to_py(py, &py.detach(move || core::access::decide(facts)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_budget_lookup_soft(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<bool> {
|
||||
let facts: core::budget_lookup::SoftFacts = from_py(facts)?;
|
||||
Ok(py.detach(move || core::budget_lookup::read_soft_emails(&facts)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_budget_lookup_organization(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::budget_lookup::OrganizationFacts = from_py(facts)?;
|
||||
to_py(
|
||||
py,
|
||||
&py.detach(move || core::budget_lookup::organization(&facts)),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_budget_lookup_member(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::budget_lookup::MemberFacts = from_py(facts)?;
|
||||
to_py(py, &py.detach(move || core::budget_lookup::member(&facts)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_budget_lookup_emails(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::budget_lookup::EmailFacts = from_py(facts)?;
|
||||
to_py(py, &py.detach(move || core::budget_lookup::emails(&facts)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_budget_window_start(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Option<f64>> {
|
||||
let facts: core::common_budgets::WindowFacts = from_py(facts)?;
|
||||
Ok(py.detach(move || core::common_budgets::window_start(&facts)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_model_budget_resolve(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::model_budget_data::ResolveFacts = from_py(facts)?;
|
||||
to_py(
|
||||
py,
|
||||
&py.detach(move || core::model_budget_data::resolve(&facts)),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_model_budget_query(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::model_budget_data::QueryFacts = from_py(facts)?;
|
||||
to_py(
|
||||
py,
|
||||
&py.detach(move || core::model_budget_data::query(&facts)),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_model_budget_check(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::model_budget_data::Snapshot = from_py(facts)?;
|
||||
to_py(
|
||||
py,
|
||||
&py.detach(move || core::model_budget_data::check(&facts)),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_budget_price(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::budget_pricing::ModelPriceFacts = from_py(facts)?;
|
||||
to_py(py, &py.detach(move || core::budget_pricing::price(&facts)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_budget_route(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::budget_pricing::RouteBudgetFacts = from_py(facts)?;
|
||||
to_py(py, &py.detach(move || core::budget_pricing::route(&facts)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_common_budget(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::common_budgets::BudgetFacts = from_py(facts)?;
|
||||
to_py(py, &py.detach(move || core::common_budgets::check(&facts)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_budget_alert(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<bool> {
|
||||
let facts: core::common_budgets::AlertFacts = from_py(facts)?;
|
||||
Ok(py.detach(move || core::common_budgets::alert(&facts)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_member_limit(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Option<f64>> {
|
||||
let facts: core::common_budgets::MemberLimitFacts = from_py(facts)?;
|
||||
py.detach(move || core::common_budgets::member_limit(&facts))
|
||||
.map_err(map_error)
|
||||
}
|
||||
|
||||
fn map_error(error: core::Error) -> PyErr {
|
||||
let message = litellm_error::Report::new(&error).to_string();
|
||||
match error {
|
||||
core::Error::IncompatibleDateTimes => PyTypeError::new_err(message),
|
||||
core::Error::InvalidAnswer => PyRuntimeError::new_err(message),
|
||||
core::Error::Pattern(_) => PyRuntimeError::new_err(message),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -122,3 +384,598 @@ pub(crate) fn request_auth_scheme_credential(value: &str) -> Option<String> {
|
|||
pub(crate) fn request_auth_litellm_header_token(value: &str) -> String {
|
||||
credential::litellm_header_token(value)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_route_access(
|
||||
py: Python<'_>,
|
||||
request: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let request: core::route_access::Request = from_py(request)?;
|
||||
let decision = py
|
||||
.detach(move || core::route_access::decide(request))
|
||||
.map_err(map_error)?;
|
||||
to_py(py, &decision)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_org_route(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::org_routes::Facts = from_py(facts)?;
|
||||
to_py(py, &py.detach(move || core::org_routes::decide(facts)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_org_admin(
|
||||
py: Python<'_>,
|
||||
memberships: &Bound<'_, PyAny>,
|
||||
organizations: Vec<String>,
|
||||
) -> PyResult<bool> {
|
||||
let memberships: Vec<core::org_routes::Membership> = from_py(memberships)?;
|
||||
Ok(py.detach(move || core::org_routes::user_is_admin(&memberships, &organizations)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_model_access(
|
||||
py: Python<'_>,
|
||||
request: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let request: core::model_access::Request = from_py(request)?;
|
||||
let decision = py
|
||||
.detach(move || core::model_access::decide(request))
|
||||
.map_err(map_error)?;
|
||||
to_py(py, &decision)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_model_groups(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::model_groups::Facts = from_py(facts)?;
|
||||
to_py(py, &py.detach(move || core::model_groups::decide(facts)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_fallback_access(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::fallback_access::Facts = from_py(facts)?;
|
||||
let decision = py
|
||||
.detach(move || core::fallback_access::decide(facts))
|
||||
.map_err(map_error)?;
|
||||
to_py(py, &decision)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_budget_exemption(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::budget_pricing::ExemptionFacts = from_py(facts)?;
|
||||
to_py(
|
||||
py,
|
||||
&py.detach(move || core::budget_pricing::exemption(&facts)),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_key_model_access(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::key_model_access::Facts = from_py(facts)?;
|
||||
let decision = py
|
||||
.detach(move || core::key_model_access::decide(facts))
|
||||
.map_err(map_error)?;
|
||||
to_py(py, &decision)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_unpriced_model(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::unpriced_models::Facts = from_py(facts)?;
|
||||
to_py(
|
||||
py,
|
||||
&py.detach(move || core::unpriced_models::decide(&facts)),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_budget_alert_plan(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::budget_alerts::Facts = from_py(facts)?;
|
||||
to_py(py, &py.detach(move || core::budget_alerts::plan(&facts)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_model_attribution(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::model_attribution::Facts = from_py(facts)?;
|
||||
to_py(
|
||||
py,
|
||||
&py.detach(move || core::model_attribution::decide(&facts)),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_fallback_candidate(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::fallback_candidate::Facts = from_py(facts)?;
|
||||
let decision = py
|
||||
.detach(move || core::fallback_candidate::decide(&facts))
|
||||
.map_err(map_error)?;
|
||||
to_py(py, &decision)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_personal_budget(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::personal_budget::Facts = from_py(facts)?;
|
||||
let decision = py
|
||||
.detach(move || core::personal_budget::decide(&facts))
|
||||
.map_err(map_error)?;
|
||||
to_py(py, &decision)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_personal_models(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::personal_models::Request = from_py(facts)?;
|
||||
let decision = py
|
||||
.detach(move || core::personal_models::decide(facts))
|
||||
.map_err(map_error)?;
|
||||
to_py(py, &decision)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_owner_teams(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::owner_teams::Facts = from_py(facts)?;
|
||||
let decision = py
|
||||
.detach(move || core::owner_teams::decide(&facts))
|
||||
.map_err(map_error)?;
|
||||
to_py(py, &decision)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_context_models(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::budget_context::ModelCheckFacts = from_py(facts)?;
|
||||
to_py(
|
||||
py,
|
||||
&py.detach(move || core::budget_context::model_checks(&facts)),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_context_end_user(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::budget_context::EndUserFacts = from_py(facts)?;
|
||||
to_py(
|
||||
py,
|
||||
&py.detach(move || core::budget_context::end_user_source(&facts)),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_context_token(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::budget_context::TokenDefaultsFacts = from_py(facts)?;
|
||||
to_py(
|
||||
py,
|
||||
&py.detach(move || core::budget_context::token_assignments(&facts)),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_context_member(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::budget_context::MemberFacts = from_py(facts)?;
|
||||
to_py(
|
||||
py,
|
||||
&py.detach(move || core::budget_context::member_source(&facts)),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_context_temporary(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::budget_context::TemporaryFacts = from_py(facts)?;
|
||||
to_py(
|
||||
py,
|
||||
&py.detach(move || core::budget_context::temporary(&facts)),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_context_throttle(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::budget_context::ThrottleFacts = from_py(facts)?;
|
||||
to_py(
|
||||
py,
|
||||
&py.detach(move || core::budget_context::throttle(&facts)),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_context_percentage(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Option<f64>> {
|
||||
let facts: core::budget_context::NumericFacts = from_py(facts)?;
|
||||
Ok(py.detach(move || core::budget_context::percentage(&facts)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_team_override(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::team_override::Facts = from_py(facts)?;
|
||||
let decision = py
|
||||
.detach(move || core::team_override::decide(&facts))
|
||||
.map_err(map_error)?;
|
||||
to_py(py, &decision)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_model_helpers(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::model_helpers::Facts = from_py(facts)?;
|
||||
let decision = py
|
||||
.detach(move || core::model_helpers::decide(&facts))
|
||||
.map_err(map_error)?;
|
||||
to_py(py, &decision)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_personal_owner(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::personal_owner::Facts = from_py(facts)?;
|
||||
let decision = py
|
||||
.detach(move || core::personal_owner::decide(&facts))
|
||||
.map_err(map_error)?;
|
||||
to_py(py, &decision)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_personal_model_request(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::personal_model_request::Facts = from_py(facts)?;
|
||||
let decision = py
|
||||
.detach(move || core::personal_model_request::decide(&facts))
|
||||
.map_err(map_error)?;
|
||||
to_py(py, &decision)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_member_models(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::member_models::Facts = from_py(facts)?;
|
||||
let decision = py
|
||||
.detach(move || core::member_models::decide(&facts))
|
||||
.map_err(map_error)?;
|
||||
to_py(py, &decision)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_resolved_models(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::resolved_models::Facts = from_py(facts)?;
|
||||
let decision = py
|
||||
.detach(move || core::resolved_models::decide(&facts))
|
||||
.map_err(map_error)?;
|
||||
to_py(py, &decision)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_end_user_normalize_id(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::end_user_context::IdFacts = from_py(facts)?;
|
||||
to_py(
|
||||
py,
|
||||
&py.detach(move || core::end_user_context::normalize_id(&facts)),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_end_user_default_id(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::end_user_context::DefaultFacts = from_py(facts)?;
|
||||
to_py(
|
||||
py,
|
||||
&py.detach(move || core::end_user_context::default_id(&facts)),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_end_user_apply(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::end_user_context::ApplyFacts = from_py(facts)?;
|
||||
to_py(
|
||||
py,
|
||||
&py.detach(move || core::end_user_context::apply_default(&facts)),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_end_user_registry(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::end_user_context::RegistryFacts = from_py(facts)?;
|
||||
to_py(
|
||||
py,
|
||||
&py.detach(move || core::end_user_context::registry(&facts)),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_end_user_assignments(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::end_user_context::AssignmentFacts = from_py(facts)?;
|
||||
to_py(
|
||||
py,
|
||||
&py.detach(move || core::end_user_context::assignments(&facts)),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_end_user_lookup(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::end_user_context::LookupFacts = from_py(facts)?;
|
||||
to_py(
|
||||
py,
|
||||
&py.detach(move || core::end_user_context::lookup(&facts)),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_end_user_validation(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::end_user_context::ValidationFacts = from_py(facts)?;
|
||||
to_py(
|
||||
py,
|
||||
&py.detach(move || core::end_user_context::validation(&facts)),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_personal_model_listing(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::personal_model_listing::Facts = from_py(facts)?;
|
||||
let decision = py
|
||||
.detach(move || core::personal_model_listing::decide(&facts))
|
||||
.map_err(map_error)?;
|
||||
to_py(py, &decision)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_agent_ceiling(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::agent_ceilings::Facts = from_py(facts)?;
|
||||
let decision = py
|
||||
.detach(move || core::agent_ceilings::decide(&facts))
|
||||
.map_err(map_error)?;
|
||||
to_py(py, &decision)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_agent_group_load(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::agent_ceilings::LoadFacts = from_py(facts)?;
|
||||
to_py(py, &py.detach(move || core::agent_ceilings::load(&facts)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_team_aliases(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::team_aliases::Facts = from_py(facts)?;
|
||||
let decision = py
|
||||
.detach(move || core::team_aliases::decide(&facts))
|
||||
.map_err(map_error)?;
|
||||
to_py(py, &decision)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_managed_models(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::managed_models::Facts = from_py(facts)?;
|
||||
let decision = py
|
||||
.detach(move || core::managed_models::decide(&facts))
|
||||
.map_err(map_error)?;
|
||||
to_py(py, &decision)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_agent_ceiling_source(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::managed_models::CeilingSource = from_py(facts)?;
|
||||
to_py(
|
||||
py,
|
||||
&py.detach(move || core::managed_models::ceiling_source(&facts)),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_managed_model_grants(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::managed_models::GrantFacts = from_py(facts)?;
|
||||
to_py(py, &py.detach(move || core::managed_models::grants(&facts)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_route_role(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::route_roles::RoleFacts = from_py(facts)?;
|
||||
let decision = py.detach(move || core::route_roles::role(&facts));
|
||||
to_py(py, &decision)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_route_role_api(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::route_roles::ApiFacts = from_py(facts)?;
|
||||
let decision = py.detach(move || core::route_roles::api(&facts));
|
||||
to_py(py, &decision)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_route_role_jwt(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::route_roles::JwtFacts = from_py(facts)?;
|
||||
let decision = py.detach(move || core::route_roles::jwt(&facts));
|
||||
to_py(py, &decision)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_route_role_inside(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::route_roles::InsideFacts = from_py(facts)?;
|
||||
let decision = py.detach(move || core::route_roles::inside(&facts));
|
||||
to_py(py, &decision)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_route_role_expand(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::route_roles::Entry = from_py(facts)?;
|
||||
let decision = py.detach(move || core::route_roles::expand(&facts));
|
||||
to_py(py, &decision)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_route_role_listed(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::route_roles::ListedFacts = from_py(facts)?;
|
||||
let decision = py
|
||||
.detach(move || core::route_roles::listed(&facts))
|
||||
.map_err(map_error)?;
|
||||
to_py(py, &decision)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_org_admin_raw(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::org_routes::AdminFacts = from_py(facts)?;
|
||||
to_py(py, &py.detach(move || core::org_routes::admin(&facts)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_model_projection(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
reads: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::model_access::Facts = from_py(facts)?;
|
||||
let reads: Vec<core::model_projection::Reading> = from_py(reads)?;
|
||||
let decision = py
|
||||
.detach(move || core::model_projection::decide(&facts, &reads))
|
||||
.map_err(map_error)?;
|
||||
to_py(py, &decision)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn request_auth_route_enable(
|
||||
py: Python<'_>,
|
||||
facts: &Bound<'_, PyAny>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let facts: core::route_enable::Facts = from_py(facts)?;
|
||||
let decision = py
|
||||
.detach(move || core::route_enable::decide(&facts))
|
||||
.map_err(map_error)?;
|
||||
to_py(py, &decision)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (context, configured=None))]
|
||||
pub(crate) fn request_auth_llm_route(
|
||||
py: Python<'_>,
|
||||
context: &Bound<'_, PyAny>,
|
||||
configured: Option<bool>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let context: core::route_access::RouteContext = from_py(context)?;
|
||||
let decision = py
|
||||
.detach(move || core::route_enable::llm_route(&context, configured))
|
||||
.map_err(map_error)?;
|
||||
to_py(py, &decision)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ edition.workspace = true
|
|||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fancy-regex.workspace = true
|
||||
litellm-secret-value.workspace = true
|
||||
serde.workspace = true
|
||||
sha2.workspace = true
|
||||
|
|
|
|||
480
litellm-rust/crates/request-auth-core/src/access.rs
Normal file
480
litellm-rust/crates/request-auth-core/src/access.rs
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum AccessFacts {
|
||||
ConcurrentFailures {
|
||||
failures: Vec<bool>,
|
||||
terminal: Vec<bool>,
|
||||
},
|
||||
UserParamRequest {
|
||||
enabled: bool,
|
||||
},
|
||||
ClientTagRequest {
|
||||
enabled: bool,
|
||||
llm_route: Option<bool>,
|
||||
},
|
||||
GuardrailRequest {
|
||||
requested_keys: Vec<String>,
|
||||
},
|
||||
TraceRequest {
|
||||
required: bool,
|
||||
},
|
||||
Search {
|
||||
name: String,
|
||||
allowed: Vec<String>,
|
||||
strict: bool,
|
||||
},
|
||||
TraceGate {
|
||||
required: bool,
|
||||
method: String,
|
||||
llm_route: bool,
|
||||
passthrough: bool,
|
||||
telemetry: bool,
|
||||
},
|
||||
TraceStep {
|
||||
source: TraceSource,
|
||||
value: Option<String>,
|
||||
present: bool,
|
||||
headers_only: bool,
|
||||
},
|
||||
Blocked {
|
||||
blocked: bool,
|
||||
},
|
||||
UserParam {
|
||||
enabled: bool,
|
||||
method: Option<String>,
|
||||
llm_route: bool,
|
||||
mcp_route: bool,
|
||||
has_user: bool,
|
||||
},
|
||||
ClientTags {
|
||||
enabled: bool,
|
||||
llm_route: bool,
|
||||
has_tags: bool,
|
||||
},
|
||||
Guardrails {
|
||||
requested_keys: Vec<String>,
|
||||
modify_guardrails: Option<bool>,
|
||||
},
|
||||
AgentTrace {
|
||||
required: bool,
|
||||
header: Option<String>,
|
||||
},
|
||||
TeamTrace {
|
||||
required: bool,
|
||||
method: String,
|
||||
llm_route: bool,
|
||||
passthrough: bool,
|
||||
telemetry: bool,
|
||||
headers_only: bool,
|
||||
header: Option<String>,
|
||||
body_trace: Option<String>,
|
||||
body_trace_present: bool,
|
||||
traceparent_id: Option<String>,
|
||||
selected_trace: Option<String>,
|
||||
selected_trace_present: bool,
|
||||
other_trace: Option<String>,
|
||||
},
|
||||
Tools {
|
||||
requested: Vec<String>,
|
||||
key_allowed: Option<Vec<String>>,
|
||||
team_allowed: Option<Vec<String>>,
|
||||
},
|
||||
GrantLayers {
|
||||
deny_by_default: bool,
|
||||
identity_present: bool,
|
||||
master_key: bool,
|
||||
dashboard: bool,
|
||||
virtual_key: bool,
|
||||
session_token: bool,
|
||||
team_present: bool,
|
||||
},
|
||||
VectorStores {
|
||||
requested: Vec<String>,
|
||||
allowed: Option<Vec<String>>,
|
||||
strict: bool,
|
||||
},
|
||||
TeamLookup {
|
||||
dashboard: bool,
|
||||
missing: bool,
|
||||
recorded_models: Vec<String>,
|
||||
allow_unavailable: Option<bool>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, strum::VariantArray)]
|
||||
pub enum Denial {
|
||||
Search,
|
||||
Blocked,
|
||||
UserParam,
|
||||
ClientTags,
|
||||
Guardrails,
|
||||
AgentTrace,
|
||||
TeamTrace,
|
||||
Tools,
|
||||
VectorStores,
|
||||
MissingVectorGrant,
|
||||
TeamLookup,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, strum::VariantArray)]
|
||||
pub enum TraceSource {
|
||||
Gate,
|
||||
Header,
|
||||
Body,
|
||||
Traceparent,
|
||||
Selected,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Serialize)]
|
||||
pub struct AccessDecision {
|
||||
pub read: Option<AccessRead>,
|
||||
pub denial: Option<Denial>,
|
||||
pub rejected: Vec<String>,
|
||||
pub layers: Vec<String>,
|
||||
pub rejected_index: Option<usize>,
|
||||
pub next_trace: Option<TraceSource>,
|
||||
pub failure_index: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize, strum::VariantArray)]
|
||||
pub enum AccessRead {
|
||||
UserParam,
|
||||
ClientTagRoute,
|
||||
ClientTags,
|
||||
GuardrailPermission,
|
||||
UnavailablePolicy,
|
||||
}
|
||||
|
||||
fn modifies_guardrails(keys: &[String]) -> bool {
|
||||
keys.iter().any(|key| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"guardrails"
|
||||
| "disable_global_guardrails"
|
||||
| "disable_global_guardrail"
|
||||
| "opted_out_global_guardrails"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn deny_if(deny: bool, denial: Denial) -> AccessDecision {
|
||||
AccessDecision {
|
||||
denial: deny.then_some(denial),
|
||||
..AccessDecision::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn nonempty(value: &Option<String>) -> bool {
|
||||
value.as_ref().is_some_and(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
pub fn decide(facts: AccessFacts) -> AccessDecision {
|
||||
match facts {
|
||||
AccessFacts::ConcurrentFailures { failures, terminal } => AccessDecision {
|
||||
failure_index: terminal
|
||||
.iter()
|
||||
.position(|failed| *failed)
|
||||
.or_else(|| failures.iter().position(|failed| *failed)),
|
||||
..AccessDecision::default()
|
||||
},
|
||||
AccessFacts::UserParamRequest { enabled } => AccessDecision {
|
||||
read: enabled.then_some(AccessRead::UserParam),
|
||||
..AccessDecision::default()
|
||||
},
|
||||
AccessFacts::ClientTagRequest { enabled, llm_route } => AccessDecision {
|
||||
read: match (enabled, llm_route) {
|
||||
(false, _) | (_, Some(false)) => None,
|
||||
(_, None) => Some(AccessRead::ClientTagRoute),
|
||||
(_, Some(true)) => Some(AccessRead::ClientTags),
|
||||
},
|
||||
..AccessDecision::default()
|
||||
},
|
||||
AccessFacts::GuardrailRequest { requested_keys } => AccessDecision {
|
||||
read: modifies_guardrails(&requested_keys).then_some(AccessRead::GuardrailPermission),
|
||||
..AccessDecision::default()
|
||||
},
|
||||
AccessFacts::TraceRequest { required } => AccessDecision {
|
||||
next_trace: required.then_some(TraceSource::Gate),
|
||||
..AccessDecision::default()
|
||||
},
|
||||
AccessFacts::Search {
|
||||
name,
|
||||
allowed,
|
||||
strict,
|
||||
} => deny_if(
|
||||
(strict || !allowed.is_empty()) && !allowed.contains(&name),
|
||||
Denial::Search,
|
||||
),
|
||||
AccessFacts::TraceGate {
|
||||
required,
|
||||
method,
|
||||
llm_route,
|
||||
passthrough,
|
||||
telemetry,
|
||||
} => AccessDecision {
|
||||
next_trace: (required
|
||||
&& !matches!(method.as_str(), "GET" | "HEAD" | "OPTIONS")
|
||||
&& (llm_route || passthrough)
|
||||
&& !telemetry)
|
||||
.then_some(TraceSource::Header),
|
||||
..AccessDecision::default()
|
||||
},
|
||||
AccessFacts::TraceStep {
|
||||
source,
|
||||
value,
|
||||
present,
|
||||
headers_only,
|
||||
} => {
|
||||
if nonempty(&value) {
|
||||
return AccessDecision::default();
|
||||
}
|
||||
let next_trace = match source {
|
||||
TraceSource::Gate => Some(TraceSource::Header),
|
||||
TraceSource::Header if headers_only => None,
|
||||
TraceSource::Header => Some(TraceSource::Body),
|
||||
TraceSource::Body if !present => Some(TraceSource::Traceparent),
|
||||
TraceSource::Body | TraceSource::Traceparent => Some(TraceSource::Selected),
|
||||
TraceSource::Selected if !present => Some(TraceSource::Other),
|
||||
TraceSource::Selected | TraceSource::Other => None,
|
||||
};
|
||||
AccessDecision {
|
||||
denial: next_trace.is_none().then_some(Denial::TeamTrace),
|
||||
next_trace,
|
||||
..AccessDecision::default()
|
||||
}
|
||||
}
|
||||
AccessFacts::Blocked { blocked } => deny_if(blocked, Denial::Blocked),
|
||||
AccessFacts::UserParam {
|
||||
enabled,
|
||||
method,
|
||||
llm_route,
|
||||
mcp_route,
|
||||
has_user,
|
||||
} => deny_if(
|
||||
enabled
|
||||
&& method.is_some_and(|value| value.to_uppercase() == "POST")
|
||||
&& llm_route
|
||||
&& !mcp_route
|
||||
&& !has_user,
|
||||
Denial::UserParam,
|
||||
),
|
||||
AccessFacts::ClientTags {
|
||||
enabled,
|
||||
llm_route,
|
||||
has_tags,
|
||||
} => deny_if(enabled && llm_route && has_tags, Denial::ClientTags),
|
||||
AccessFacts::Guardrails {
|
||||
requested_keys,
|
||||
modify_guardrails,
|
||||
} => deny_if(
|
||||
modify_guardrails == Some(false)
|
||||
&& requested_keys.iter().any(|key| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"guardrails"
|
||||
| "disable_global_guardrails"
|
||||
| "disable_global_guardrail"
|
||||
| "opted_out_global_guardrails"
|
||||
)
|
||||
}),
|
||||
Denial::Guardrails,
|
||||
),
|
||||
AccessFacts::AgentTrace { required, header } => {
|
||||
deny_if(required && !nonempty(&header), Denial::AgentTrace)
|
||||
}
|
||||
AccessFacts::TeamTrace {
|
||||
required,
|
||||
method,
|
||||
llm_route,
|
||||
passthrough,
|
||||
telemetry,
|
||||
headers_only,
|
||||
header,
|
||||
body_trace,
|
||||
body_trace_present,
|
||||
traceparent_id,
|
||||
selected_trace,
|
||||
selected_trace_present,
|
||||
other_trace,
|
||||
} => {
|
||||
let gate = required
|
||||
&& !matches!(method.as_str(), "GET" | "HEAD" | "OPTIONS")
|
||||
&& (llm_route || passthrough)
|
||||
&& !telemetry;
|
||||
let body = nonempty(&body_trace)
|
||||
|| (!body_trace_present && nonempty(&traceparent_id))
|
||||
|| if selected_trace_present {
|
||||
nonempty(&selected_trace)
|
||||
} else {
|
||||
nonempty(&other_trace)
|
||||
};
|
||||
deny_if(
|
||||
gate && !nonempty(&header) && (headers_only || !body),
|
||||
Denial::TeamTrace,
|
||||
)
|
||||
}
|
||||
AccessFacts::Tools {
|
||||
requested,
|
||||
key_allowed,
|
||||
team_allowed,
|
||||
} => {
|
||||
let allowed = key_allowed
|
||||
.filter(|allowed| !allowed.is_empty())
|
||||
.or(team_allowed);
|
||||
let rejected: Vec<_> = match allowed {
|
||||
Some(allowed) if !allowed.is_empty() => requested
|
||||
.into_iter()
|
||||
.filter(|name| !allowed.contains(name))
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
AccessDecision {
|
||||
denial: (!rejected.is_empty()).then_some(Denial::Tools),
|
||||
rejected,
|
||||
..AccessDecision::default()
|
||||
}
|
||||
}
|
||||
AccessFacts::GrantLayers {
|
||||
deny_by_default,
|
||||
identity_present,
|
||||
master_key,
|
||||
dashboard,
|
||||
virtual_key,
|
||||
session_token,
|
||||
team_present,
|
||||
} => {
|
||||
let layers = if !deny_by_default || !identity_present || master_key || dashboard {
|
||||
Vec::new()
|
||||
} else if !(virtual_key && !session_token) && !team_present {
|
||||
vec!["user".into()]
|
||||
} else {
|
||||
[
|
||||
("key", virtual_key && !session_token),
|
||||
("team", team_present),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(name, required)| required.then_some(name.into()))
|
||||
.collect()
|
||||
};
|
||||
AccessDecision {
|
||||
layers,
|
||||
..AccessDecision::default()
|
||||
}
|
||||
}
|
||||
AccessFacts::VectorStores {
|
||||
requested,
|
||||
allowed,
|
||||
strict,
|
||||
} => match allowed {
|
||||
None => deny_if(strict, Denial::MissingVectorGrant),
|
||||
Some(allowed) if allowed.is_empty() => deny_if(strict, Denial::MissingVectorGrant),
|
||||
Some(allowed) => {
|
||||
let rejected_index = requested.iter().position(|name| !allowed.contains(name));
|
||||
AccessDecision {
|
||||
denial: rejected_index.map(|_| Denial::VectorStores),
|
||||
rejected_index,
|
||||
..AccessDecision::default()
|
||||
}
|
||||
}
|
||||
},
|
||||
AccessFacts::TeamLookup {
|
||||
dashboard,
|
||||
missing,
|
||||
recorded_models,
|
||||
allow_unavailable,
|
||||
} => {
|
||||
if !dashboard && !missing && recorded_models.is_empty() && allow_unavailable.is_none() {
|
||||
return AccessDecision {
|
||||
read: Some(AccessRead::UnavailablePolicy),
|
||||
..AccessDecision::default()
|
||||
};
|
||||
}
|
||||
deny_if(
|
||||
!dashboard
|
||||
&& (missing || (recorded_models.is_empty() && allow_unavailable != Some(true))),
|
||||
Denial::TeamLookup,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::disabled(false, None, None)]
|
||||
#[case::read_route(true, None, Some(AccessRead::ClientTagRoute))]
|
||||
#[case::non_llm(true, Some(false), None)]
|
||||
#[case::read_tags(true, Some(true), Some(AccessRead::ClientTags))]
|
||||
fn client_tag_reads(
|
||||
#[case] enabled: bool,
|
||||
#[case] llm_route: Option<bool>,
|
||||
#[case] read: Option<AccessRead>,
|
||||
) {
|
||||
assert_eq!(
|
||||
decide(AccessFacts::ClientTagRequest { enabled, llm_route }).read,
|
||||
read
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::allow(vec![false, false], vec![false, false], None)]
|
||||
#[case::scope_priority(vec![true, true], vec![false, false], Some(0))]
|
||||
#[case::second_scope(vec![false, true], vec![false, false], Some(1))]
|
||||
#[case::terminal_fault(vec![true, true], vec![false, true], Some(1))]
|
||||
fn gathered_failures_preserve_scope_order_unless_native_failed(
|
||||
#[case] failures: Vec<bool>,
|
||||
#[case] terminal: Vec<bool>,
|
||||
#[case] expected: Option<usize>,
|
||||
) {
|
||||
assert_eq!(
|
||||
decide(AccessFacts::ConcurrentFailures { failures, terminal }).failure_index,
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::key_overrides_team(Some(vec!["key".into()]), Some(vec!["team".into()]), vec!["team".into()])]
|
||||
#[case::empty_key_inherits(Some(vec![]), Some(vec!["team".into()]), vec!["key".into()])]
|
||||
#[case::unrestricted(None, Some(vec![]), vec![])]
|
||||
fn tools(
|
||||
#[case] key: Option<Vec<String>>,
|
||||
#[case] team: Option<Vec<String>>,
|
||||
#[case] rejected: Vec<String>,
|
||||
) {
|
||||
let result = decide(AccessFacts::Tools {
|
||||
requested: vec!["key".into(), "team".into()],
|
||||
key_allowed: key,
|
||||
team_allowed: team,
|
||||
});
|
||||
assert_eq!(
|
||||
result.denial,
|
||||
(!rejected.is_empty()).then_some(Denial::Tools)
|
||||
);
|
||||
assert_eq!(result.rejected, rejected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::missing_strict(None, true, Some(Denial::MissingVectorGrant))]
|
||||
#[case::empty_legacy(Some(vec![]), false, None)]
|
||||
#[case::empty_strict(Some(vec![]), true, Some(Denial::MissingVectorGrant))]
|
||||
#[case::denied(Some(vec!["other".into()]), false, Some(Denial::VectorStores))]
|
||||
#[case::granted(Some(vec!["store".into()]), true, None)]
|
||||
fn vectors(
|
||||
#[case] allowed: Option<Vec<String>>,
|
||||
#[case] strict: bool,
|
||||
#[case] denial: Option<Denial>,
|
||||
) {
|
||||
let result = decide(AccessFacts::VectorStores {
|
||||
requested: vec!["store".into()],
|
||||
allowed,
|
||||
strict,
|
||||
});
|
||||
assert_eq!(result.denial, denial);
|
||||
}
|
||||
}
|
||||
183
litellm-rust/crates/request-auth-core/src/admission.rs
Normal file
183
litellm-rust/crates/request-auth-core/src/admission.rs
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum AdmissionFacts {
|
||||
CommonGate {
|
||||
force_virtual_key_checks: bool,
|
||||
public_route: bool,
|
||||
additional_public_route: bool,
|
||||
unauthenticated_passthrough: bool,
|
||||
master_key_present: bool,
|
||||
jwt_enabled: bool,
|
||||
oauth_enabled: bool,
|
||||
oauth_proxy_enabled: bool,
|
||||
custom_auth: bool,
|
||||
custom_auth_common_checks: bool,
|
||||
},
|
||||
Reservation {
|
||||
skip_budget_checks: bool,
|
||||
disabled: bool,
|
||||
apply_user_budget_to_team_keys: bool,
|
||||
fail_closed: bool,
|
||||
billing_agent: bool,
|
||||
},
|
||||
Ownership {
|
||||
virtual_key: bool,
|
||||
custom_auth: bool,
|
||||
jwt: bool,
|
||||
oauth: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize, strum::VariantArray)]
|
||||
pub enum CommonSkip {
|
||||
PublicRoute,
|
||||
UnauthenticatedPassthrough,
|
||||
NoAuthDevelopment,
|
||||
CustomAuth,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize, strum::VariantArray)]
|
||||
pub enum Owner {
|
||||
Python,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize, strum::VariantArray)]
|
||||
pub enum Candidate {
|
||||
VirtualKey,
|
||||
CustomAuth,
|
||||
Validator,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum AdmissionPlan {
|
||||
Common {
|
||||
skip: Option<CommonSkip>,
|
||||
},
|
||||
Reserve {
|
||||
reserve: bool,
|
||||
apply_user_budget_to_team_keys: bool,
|
||||
fail_closed: bool,
|
||||
},
|
||||
Observe {
|
||||
owner: Owner,
|
||||
candidate: Candidate,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn plan(facts: AdmissionFacts) -> AdmissionPlan {
|
||||
match facts {
|
||||
AdmissionFacts::CommonGate {
|
||||
force_virtual_key_checks,
|
||||
public_route,
|
||||
additional_public_route,
|
||||
unauthenticated_passthrough,
|
||||
master_key_present,
|
||||
jwt_enabled,
|
||||
oauth_enabled,
|
||||
oauth_proxy_enabled,
|
||||
custom_auth,
|
||||
custom_auth_common_checks,
|
||||
} => {
|
||||
let skip = if force_virtual_key_checks {
|
||||
None
|
||||
} else if public_route || additional_public_route {
|
||||
Some(CommonSkip::PublicRoute)
|
||||
} else if unauthenticated_passthrough {
|
||||
Some(CommonSkip::UnauthenticatedPassthrough)
|
||||
} else if !master_key_present && !jwt_enabled && !oauth_enabled && !oauth_proxy_enabled
|
||||
{
|
||||
Some(CommonSkip::NoAuthDevelopment)
|
||||
} else if custom_auth && !custom_auth_common_checks {
|
||||
Some(CommonSkip::CustomAuth)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
AdmissionPlan::Common { skip }
|
||||
}
|
||||
AdmissionFacts::Reservation {
|
||||
skip_budget_checks,
|
||||
disabled,
|
||||
apply_user_budget_to_team_keys,
|
||||
fail_closed,
|
||||
billing_agent,
|
||||
} => AdmissionPlan::Reserve {
|
||||
reserve: !skip_budget_checks && !disabled,
|
||||
apply_user_budget_to_team_keys,
|
||||
fail_closed: fail_closed || billing_agent,
|
||||
},
|
||||
AdmissionFacts::Ownership {
|
||||
virtual_key,
|
||||
custom_auth,
|
||||
jwt,
|
||||
oauth,
|
||||
} => {
|
||||
let candidate = if custom_auth {
|
||||
Candidate::CustomAuth
|
||||
} else if jwt || oauth {
|
||||
Candidate::Validator
|
||||
} else if virtual_key {
|
||||
Candidate::VirtualKey
|
||||
} else {
|
||||
Candidate::Other
|
||||
};
|
||||
AdmissionPlan::Observe {
|
||||
owner: Owner::Python,
|
||||
candidate,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::normal(false, false, false, true, false)]
|
||||
#[case::free(true, false, false, false, false)]
|
||||
#[case::disabled(false, true, false, false, false)]
|
||||
#[case::managed(false, false, true, true, true)]
|
||||
fn reservation(
|
||||
#[case] skip: bool,
|
||||
#[case] disabled: bool,
|
||||
#[case] billing: bool,
|
||||
#[case] reserve: bool,
|
||||
#[case] closed: bool,
|
||||
) {
|
||||
assert_eq!(
|
||||
plan(AdmissionFacts::Reservation {
|
||||
skip_budget_checks: skip,
|
||||
disabled,
|
||||
billing_agent: billing,
|
||||
fail_closed: false,
|
||||
apply_user_budget_to_team_keys: true
|
||||
}),
|
||||
AdmissionPlan::Reserve {
|
||||
reserve,
|
||||
apply_user_budget_to_team_keys: true,
|
||||
fail_closed: closed
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn ownership_never_transfers_request() {
|
||||
assert_eq!(
|
||||
plan(AdmissionFacts::Ownership {
|
||||
virtual_key: true,
|
||||
custom_auth: false,
|
||||
jwt: false,
|
||||
oauth: false
|
||||
}),
|
||||
AdmissionPlan::Observe {
|
||||
owner: Owner::Python,
|
||||
candidate: Candidate::VirtualKey
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
269
litellm-rust/crates/request-auth-core/src/agent_access.rs
Normal file
269
litellm-rust/crates/request-auth-core/src/agent_access.rs
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::managed::Agent;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Facts {
|
||||
pub registry_managed: bool,
|
||||
pub database_present: bool,
|
||||
pub target_loaded: bool,
|
||||
pub target: Option<Agent>,
|
||||
pub subject_present: bool,
|
||||
pub key_hash_present: bool,
|
||||
pub subject_managed: bool,
|
||||
pub session: bool,
|
||||
pub custom: bool,
|
||||
pub authority_loaded: bool,
|
||||
pub explicit_grants: Option<Vec<String>>,
|
||||
pub legacy_loaded: bool,
|
||||
pub legacy_grants: Option<Vec<String>>,
|
||||
pub target_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, strum::VariantArray)]
|
||||
pub enum Decision {
|
||||
ReadTarget,
|
||||
ReadAuthority,
|
||||
ReadExplicitGrants,
|
||||
ReadLegacyGrants,
|
||||
ReadTargetIds,
|
||||
Allow,
|
||||
Deny,
|
||||
}
|
||||
|
||||
pub fn decide(f: &Facts) -> Decision {
|
||||
if f.registry_managed || f.database_present {
|
||||
if !f.target_loaded {
|
||||
return Decision::ReadTarget;
|
||||
}
|
||||
if f.target.is_none() && f.registry_managed {
|
||||
return Decision::Deny;
|
||||
}
|
||||
if let Some(target) = f.target.as_ref().filter(|target| target.identity_managed) {
|
||||
if !target.enabled
|
||||
|| !target
|
||||
.identity
|
||||
.as_ref()
|
||||
.is_some_and(|identity| identity.active)
|
||||
|| !f.subject_present
|
||||
{
|
||||
return Decision::Deny;
|
||||
}
|
||||
if f.key_hash_present
|
||||
&& !f.subject_managed
|
||||
&& !f.session
|
||||
&& !f.custom
|
||||
&& !f.authority_loaded
|
||||
{
|
||||
return Decision::ReadAuthority;
|
||||
}
|
||||
return match f.explicit_grants.as_ref() {
|
||||
None => Decision::ReadExplicitGrants,
|
||||
Some(grants) if grants.contains(&target.agent_id) => Decision::Allow,
|
||||
Some(_) => Decision::Deny,
|
||||
};
|
||||
}
|
||||
}
|
||||
if !f.legacy_loaded {
|
||||
return Decision::ReadLegacyGrants;
|
||||
}
|
||||
let Some(grants) = f.legacy_grants.as_ref() else {
|
||||
return Decision::Allow;
|
||||
};
|
||||
match f.target_ids.as_ref() {
|
||||
None => Decision::ReadTargetIds,
|
||||
Some(ids) if ids.iter().any(|id| grants.contains(id)) => Decision::Allow,
|
||||
Some(_) => Decision::Deny,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Invocation {
|
||||
pub registered: Option<Agent>,
|
||||
pub has_store: bool,
|
||||
pub target_loaded: bool,
|
||||
pub target: Option<Agent>,
|
||||
pub actor_managed: bool,
|
||||
pub actor_id_present: bool,
|
||||
pub permissions: Option<Facts>,
|
||||
pub assigned: bool,
|
||||
pub fee_loaded: bool,
|
||||
pub fee: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, strum::VariantArray)]
|
||||
pub enum InvocationAction {
|
||||
Complete,
|
||||
ReadTarget,
|
||||
ReadPermissions,
|
||||
Assign,
|
||||
ReadFee,
|
||||
Deny,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, strum::VariantArray)]
|
||||
pub enum InvocationDenial {
|
||||
DatabaseRequired,
|
||||
Removed,
|
||||
CallerNotPermitted,
|
||||
InvalidFee,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct InvocationPlan {
|
||||
pub action: InvocationAction,
|
||||
pub denial: Option<InvocationDenial>,
|
||||
pub use_target: bool,
|
||||
pub assign_billing: bool,
|
||||
}
|
||||
|
||||
pub fn invocation(f: &Invocation) -> InvocationPlan {
|
||||
let action = |action, denial| InvocationPlan {
|
||||
action,
|
||||
denial,
|
||||
use_target: f.target.is_some(),
|
||||
assign_billing: !f.actor_id_present
|
||||
&& f.target
|
||||
.as_ref()
|
||||
.or(f.registered.as_ref())
|
||||
.is_some_and(|agent| agent.identity_managed),
|
||||
};
|
||||
let Some(registered) = f.registered.as_ref() else {
|
||||
return action(InvocationAction::Complete, None);
|
||||
};
|
||||
let registered_managed = registered.identity_managed || registered.identity.is_some();
|
||||
if !f.has_store && registered_managed {
|
||||
return action(
|
||||
InvocationAction::Deny,
|
||||
Some(InvocationDenial::DatabaseRequired),
|
||||
);
|
||||
}
|
||||
if !f.target_loaded {
|
||||
return action(InvocationAction::ReadTarget, None);
|
||||
}
|
||||
if f.target.is_none() && registered_managed {
|
||||
return action(InvocationAction::Deny, Some(InvocationDenial::Removed));
|
||||
}
|
||||
let effective = f.target.as_ref().unwrap_or(registered);
|
||||
if !effective.identity_managed && !f.actor_managed {
|
||||
return action(InvocationAction::Complete, None);
|
||||
}
|
||||
match f.permissions.as_ref().map(decide) {
|
||||
None => return action(InvocationAction::ReadPermissions, None),
|
||||
Some(Decision::Allow) => (),
|
||||
Some(_) => {
|
||||
return action(
|
||||
InvocationAction::Deny,
|
||||
Some(InvocationDenial::CallerNotPermitted),
|
||||
);
|
||||
}
|
||||
}
|
||||
if !f.assigned {
|
||||
return action(InvocationAction::Assign, None);
|
||||
}
|
||||
if !f.fee_loaded {
|
||||
return action(InvocationAction::ReadFee, None);
|
||||
}
|
||||
if !f.fee.is_some_and(|fee| fee.is_finite() && fee >= 0.0) {
|
||||
return action(InvocationAction::Deny, Some(InvocationDenial::InvalidFee));
|
||||
}
|
||||
action(InvocationAction::Complete, None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::free(Some(0.0), None)]
|
||||
#[case::priced(Some(1.0), None)]
|
||||
#[case::missing(None, Some(InvocationDenial::InvalidFee))]
|
||||
#[case::negative(Some(-1.0), Some(InvocationDenial::InvalidFee))]
|
||||
#[case::nan(Some(f64::NAN), Some(InvocationDenial::InvalidFee))]
|
||||
#[case::infinity(Some(f64::INFINITY), Some(InvocationDenial::InvalidFee))]
|
||||
fn invocation_fee_is_checked_after_assignment(
|
||||
#[case] fee: Option<f64>,
|
||||
#[case] denial: Option<InvocationDenial>,
|
||||
) {
|
||||
let facts = Invocation {
|
||||
registered: Some(Agent {
|
||||
agent_id: "agent".into(),
|
||||
enabled: true,
|
||||
identity_managed: true,
|
||||
identity: None,
|
||||
execution_mode: "both".into(),
|
||||
}),
|
||||
has_store: true,
|
||||
target_loaded: true,
|
||||
target: Some(Agent {
|
||||
agent_id: "agent".into(),
|
||||
enabled: true,
|
||||
identity_managed: true,
|
||||
identity: None,
|
||||
execution_mode: "both".into(),
|
||||
}),
|
||||
actor_managed: false,
|
||||
actor_id_present: false,
|
||||
permissions: Some(Facts {
|
||||
registry_managed: false,
|
||||
database_present: false,
|
||||
target_loaded: false,
|
||||
target: None,
|
||||
subject_present: true,
|
||||
key_hash_present: false,
|
||||
subject_managed: false,
|
||||
session: false,
|
||||
custom: false,
|
||||
authority_loaded: false,
|
||||
explicit_grants: None,
|
||||
legacy_loaded: true,
|
||||
legacy_grants: None,
|
||||
target_ids: None,
|
||||
}),
|
||||
assigned: true,
|
||||
fee_loaded: true,
|
||||
fee,
|
||||
};
|
||||
assert_eq!(invocation(&facts).denial, denial);
|
||||
assert!(invocation(&facts).assign_billing);
|
||||
assert_eq!(
|
||||
invocation(&Invocation {
|
||||
assigned: false,
|
||||
..facts
|
||||
})
|
||||
.action,
|
||||
InvocationAction::Assign
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::no_database(false, false, false, Decision::ReadLegacyGrants)]
|
||||
#[case::database(false, true, false, Decision::ReadTarget)]
|
||||
#[case::managed_missing(true, true, true, Decision::Deny)]
|
||||
#[case::ordinary_missing(false, true, true, Decision::ReadLegacyGrants)]
|
||||
fn target_read_precedes_grants(
|
||||
#[case] registry_managed: bool,
|
||||
#[case] database_present: bool,
|
||||
#[case] target_loaded: bool,
|
||||
#[case] expected: Decision,
|
||||
) {
|
||||
let facts = Facts {
|
||||
registry_managed,
|
||||
database_present,
|
||||
target_loaded,
|
||||
target: None,
|
||||
subject_present: false,
|
||||
key_hash_present: false,
|
||||
subject_managed: false,
|
||||
session: false,
|
||||
custom: false,
|
||||
authority_loaded: false,
|
||||
explicit_grants: None,
|
||||
legacy_loaded: false,
|
||||
legacy_grants: None,
|
||||
target_ids: None,
|
||||
};
|
||||
assert_eq!(decide(&facts), expected);
|
||||
}
|
||||
}
|
||||
151
litellm-rust/crates/request-auth-core/src/agent_ceilings.rs
Normal file
151
litellm-rust/crates/request-auth-core/src/agent_ceilings.rs
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
use crate::Error;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Clone, Copy, Deserialize, PartialEq, Eq, strum::VariantArray)]
|
||||
pub enum Status {
|
||||
Found,
|
||||
Missing,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Group {
|
||||
pub status: Status,
|
||||
pub models: Vec<String>,
|
||||
pub mcp_server_ids: Vec<String>,
|
||||
pub agent_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Facts {
|
||||
pub ids: Option<Vec<String>>,
|
||||
pub groups: Option<Vec<Group>>,
|
||||
pub check_db_only: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Decision {
|
||||
ReadIds,
|
||||
ReadGroups {
|
||||
authoritative: bool,
|
||||
},
|
||||
Complete {
|
||||
present: bool,
|
||||
ids: Vec<String>,
|
||||
models: BTreeSet<String>,
|
||||
mcp_server_ids: BTreeSet<String>,
|
||||
agent_ids: BTreeSet<String>,
|
||||
indices: Vec<usize>,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn decide(facts: &Facts) -> Result<Decision, Error> {
|
||||
let Some(ids) = &facts.ids else {
|
||||
return Ok(Decision::ReadIds);
|
||||
};
|
||||
let empty = |present| Decision::Complete {
|
||||
present,
|
||||
ids: vec![],
|
||||
models: BTreeSet::new(),
|
||||
mcp_server_ids: BTreeSet::new(),
|
||||
agent_ids: BTreeSet::new(),
|
||||
indices: vec![],
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return Ok(empty(false));
|
||||
}
|
||||
let Some(groups) = &facts.groups else {
|
||||
return Ok(Decision::ReadGroups {
|
||||
authoritative: facts.check_db_only,
|
||||
});
|
||||
};
|
||||
if groups.len() != ids.len() {
|
||||
return Err(Error::InvalidAnswer);
|
||||
}
|
||||
let indices: Vec<_> = groups
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, group)| (group.status == Status::Found).then_some(index))
|
||||
.collect();
|
||||
Ok(Decision::Complete {
|
||||
present: true,
|
||||
ids: ids
|
||||
.iter()
|
||||
.zip(groups)
|
||||
.filter(|(_, group)| group.status != Status::Missing)
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect(),
|
||||
models: indices
|
||||
.iter()
|
||||
.flat_map(|index| groups[*index].models.iter().cloned())
|
||||
.collect(),
|
||||
mcp_server_ids: indices
|
||||
.iter()
|
||||
.flat_map(|index| groups[*index].mcp_server_ids.iter().cloned())
|
||||
.collect(),
|
||||
agent_ids: indices
|
||||
.iter()
|
||||
.flat_map(|index| groups[*index].agent_ids.iter().cloned())
|
||||
.collect(),
|
||||
indices,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Deserialize, strum::VariantArray)]
|
||||
pub enum ReadStatus {
|
||||
Success,
|
||||
NotFound,
|
||||
HttpError,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct LoadFacts {
|
||||
pub database_present: bool,
|
||||
pub check_db_only: bool,
|
||||
pub status: Option<ReadStatus>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum LoadDecision {
|
||||
Read,
|
||||
Return,
|
||||
DatabaseUnavailable,
|
||||
HttpUnavailable,
|
||||
Raise,
|
||||
}
|
||||
|
||||
pub fn load(facts: &LoadFacts) -> LoadDecision {
|
||||
if !facts.database_present {
|
||||
return LoadDecision::DatabaseUnavailable;
|
||||
}
|
||||
match facts.status {
|
||||
None => LoadDecision::Read,
|
||||
Some(ReadStatus::Success) => LoadDecision::Return,
|
||||
Some(ReadStatus::NotFound) => LoadDecision::Raise,
|
||||
Some(ReadStatus::HttpError) if facts.check_db_only => LoadDecision::Raise,
|
||||
Some(ReadStatus::HttpError) => LoadDecision::HttpUnavailable,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::soft(false, LoadDecision::HttpUnavailable)]
|
||||
#[case::authoritative(true, LoadDecision::Raise)]
|
||||
fn http_lookup_policy(#[case] check_db_only: bool, #[case] expected: LoadDecision) {
|
||||
assert_eq!(
|
||||
load(&LoadFacts {
|
||||
database_present: true,
|
||||
check_db_only,
|
||||
status: Some(ReadStatus::HttpError)
|
||||
}),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
408
litellm-rust/crates/request-auth-core/src/agent_grants.rs
Normal file
408
litellm-rust/crates/request-auth-core/src/agent_grants.rs
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Source {
|
||||
pub team_id: Option<String>,
|
||||
pub grants: Option<Vec<String>>,
|
||||
pub unavailable: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ConfiguredAgent {
|
||||
pub agent_id: String,
|
||||
pub groups: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Facts {
|
||||
Resolve {
|
||||
managed: bool,
|
||||
strict: bool,
|
||||
role: Option<String>,
|
||||
key_team_loaded: bool,
|
||||
key_team: Option<Vec<String>>,
|
||||
default_deny: Option<bool>,
|
||||
caller_loaded: bool,
|
||||
own: Option<Vec<String>>,
|
||||
ceiling_loaded: bool,
|
||||
ceiling: Option<Vec<String>>,
|
||||
},
|
||||
Failure {
|
||||
strict: bool,
|
||||
closed: bool,
|
||||
},
|
||||
OwnerTeam {
|
||||
strict: bool,
|
||||
permission_id_present: bool,
|
||||
permission_present: bool,
|
||||
},
|
||||
OwnerUnion {
|
||||
teams: Vec<Source>,
|
||||
withheld: Vec<Source>,
|
||||
unreadable: bool,
|
||||
},
|
||||
BillingTeam {
|
||||
sources: Vec<Source>,
|
||||
agent_id: String,
|
||||
},
|
||||
Human {
|
||||
sources: Vec<Source>,
|
||||
team_id: Option<String>,
|
||||
},
|
||||
Delegated {
|
||||
sources: Vec<Source>,
|
||||
agent_id: String,
|
||||
team_id: Option<String>,
|
||||
explicit: bool,
|
||||
allowed: Option<Vec<String>>,
|
||||
},
|
||||
TeamLookup {
|
||||
subject: bool,
|
||||
team_id: Option<String>,
|
||||
personal: Option<bool>,
|
||||
database: bool,
|
||||
loaded: bool,
|
||||
row_present: bool,
|
||||
strict: bool,
|
||||
},
|
||||
GroupMatches {
|
||||
agents: Vec<ConfiguredAgent>,
|
||||
groups: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, PartialEq, Eq, Serialize, strum::VariantArray)]
|
||||
pub enum Action {
|
||||
#[default]
|
||||
Complete,
|
||||
ReadManaged,
|
||||
ReadKeyTeam,
|
||||
ReadDefault,
|
||||
ReadCaller,
|
||||
ReadCeiling,
|
||||
ReadPersonalEligibility,
|
||||
ReadOwnerTeams,
|
||||
ReadTeam,
|
||||
ReadDeclared,
|
||||
Unavailable,
|
||||
SelectTeam,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct Decision {
|
||||
pub action: Action,
|
||||
pub grants: Option<Vec<String>>,
|
||||
pub team_id: Option<String>,
|
||||
}
|
||||
|
||||
fn action(action: Action) -> Decision {
|
||||
Decision {
|
||||
action,
|
||||
..Decision::default()
|
||||
}
|
||||
}
|
||||
fn grants(grants: Option<Vec<String>>) -> Decision {
|
||||
Decision {
|
||||
grants,
|
||||
..Decision::default()
|
||||
}
|
||||
}
|
||||
fn intersection(left: Option<Vec<String>>, right: Option<Vec<String>>) -> Option<Vec<String>> {
|
||||
match (left, right) {
|
||||
(None, right) => right,
|
||||
(left, None) => left,
|
||||
(Some(left), Some(right)) => {
|
||||
Some(left.into_iter().filter(|id| right.contains(id)).collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decide(facts: Facts) -> Decision {
|
||||
match facts {
|
||||
Facts::Resolve {
|
||||
managed,
|
||||
strict,
|
||||
role,
|
||||
key_team_loaded,
|
||||
key_team,
|
||||
default_deny,
|
||||
caller_loaded,
|
||||
own,
|
||||
ceiling_loaded,
|
||||
ceiling,
|
||||
} => {
|
||||
if managed {
|
||||
return action(Action::ReadManaged);
|
||||
}
|
||||
if !key_team_loaded {
|
||||
return action(Action::ReadKeyTeam);
|
||||
}
|
||||
let admin = role.as_deref() == Some("proxy_admin");
|
||||
if !strict && !admin && default_deny.is_none() {
|
||||
return action(Action::ReadDefault);
|
||||
}
|
||||
if (strict || (!admin && default_deny == Some(true))) && key_team.is_none() {
|
||||
return grants(Some(vec![]));
|
||||
}
|
||||
if !caller_loaded {
|
||||
return action(Action::ReadCaller);
|
||||
}
|
||||
if !ceiling_loaded {
|
||||
return action(Action::ReadCeiling);
|
||||
}
|
||||
grants(intersection(own, ceiling))
|
||||
}
|
||||
Facts::Failure { strict, closed } => {
|
||||
if strict {
|
||||
action(Action::Unavailable)
|
||||
} else {
|
||||
grants(closed.then(Vec::new))
|
||||
}
|
||||
}
|
||||
Facts::OwnerTeam {
|
||||
strict,
|
||||
permission_id_present,
|
||||
permission_present,
|
||||
} => {
|
||||
if permission_id_present && !permission_present {
|
||||
if strict {
|
||||
action(Action::Unavailable)
|
||||
} else {
|
||||
grants(Some(vec![]))
|
||||
}
|
||||
} else {
|
||||
action(Action::ReadDeclared)
|
||||
}
|
||||
}
|
||||
Facts::OwnerUnion {
|
||||
teams,
|
||||
withheld,
|
||||
unreadable,
|
||||
} => {
|
||||
if teams.iter().any(|source| source.unavailable) {
|
||||
return action(Action::Unavailable);
|
||||
}
|
||||
let restricted = teams.is_empty()
|
||||
|| unreadable
|
||||
|| teams.iter().any(|source| source.grants.is_some())
|
||||
|| withheld
|
||||
.iter()
|
||||
.any(|source| source.unavailable || source.grants.is_some());
|
||||
grants(restricted.then(|| {
|
||||
teams
|
||||
.into_iter()
|
||||
.flat_map(|source| source.grants.unwrap_or_default())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}))
|
||||
}
|
||||
Facts::BillingTeam { sources, agent_id } => {
|
||||
let selected = sources
|
||||
.iter()
|
||||
.filter(|source| {
|
||||
source
|
||||
.grants
|
||||
.as_ref()
|
||||
.is_some_and(|ids| ids.contains(&agent_id))
|
||||
})
|
||||
.filter_map(|source| source.team_id.clone())
|
||||
.min();
|
||||
let team_id = selected.or_else(|| {
|
||||
sources
|
||||
.iter()
|
||||
.all(|source| source.grants.is_none() && !source.unavailable)
|
||||
.then(|| {
|
||||
sources
|
||||
.iter()
|
||||
.filter_map(|source| source.team_id.clone())
|
||||
.min()
|
||||
})
|
||||
.flatten()
|
||||
});
|
||||
Decision {
|
||||
team_id,
|
||||
..Decision::default()
|
||||
}
|
||||
}
|
||||
Facts::Human { sources, team_id } => grants(Some(
|
||||
sources
|
||||
.into_iter()
|
||||
.filter(|source| source.team_id.is_none() || source.team_id == team_id)
|
||||
.flat_map(|source| source.grants.unwrap_or_default())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect(),
|
||||
)),
|
||||
Facts::Delegated {
|
||||
sources,
|
||||
agent_id,
|
||||
team_id,
|
||||
explicit,
|
||||
allowed,
|
||||
} => {
|
||||
if sources.iter().any(|source| {
|
||||
source.team_id.is_none()
|
||||
&& source
|
||||
.grants
|
||||
.as_ref()
|
||||
.is_some_and(|ids| ids.contains(&agent_id))
|
||||
}) {
|
||||
return Decision {
|
||||
team_id,
|
||||
..Decision::default()
|
||||
};
|
||||
}
|
||||
let teams: BTreeSet<_> = sources
|
||||
.iter()
|
||||
.filter(|source| {
|
||||
source
|
||||
.grants
|
||||
.as_ref()
|
||||
.is_some_and(|ids| ids.contains(&agent_id))
|
||||
})
|
||||
.filter_map(|source| source.team_id.as_ref())
|
||||
.filter(|team| {
|
||||
allowed
|
||||
.as_ref()
|
||||
.is_none_or(|allowed| allowed.contains(team))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
if team_id.as_ref().is_some_and(|team| teams.contains(team)) {
|
||||
return Decision {
|
||||
team_id,
|
||||
..Decision::default()
|
||||
};
|
||||
}
|
||||
if !explicit && let Some(first) = teams.first() {
|
||||
return Decision {
|
||||
team_id: Some(first.clone()),
|
||||
..Decision::default()
|
||||
};
|
||||
}
|
||||
action(Action::SelectTeam)
|
||||
}
|
||||
Facts::TeamLookup {
|
||||
subject,
|
||||
team_id,
|
||||
personal,
|
||||
database,
|
||||
loaded,
|
||||
row_present,
|
||||
strict,
|
||||
} => {
|
||||
if !subject {
|
||||
return grants(None);
|
||||
}
|
||||
if team_id.is_none() {
|
||||
return match personal {
|
||||
None => action(Action::ReadPersonalEligibility),
|
||||
Some(true) => action(Action::ReadOwnerTeams),
|
||||
Some(false) => grants(None),
|
||||
};
|
||||
}
|
||||
if !database || (loaded && !row_present) {
|
||||
return grants(strict.then(Vec::new));
|
||||
}
|
||||
if !loaded {
|
||||
action(Action::ReadTeam)
|
||||
} else {
|
||||
action(Action::ReadDeclared)
|
||||
}
|
||||
}
|
||||
Facts::GroupMatches { agents, groups } => grants(Some(
|
||||
agents
|
||||
.into_iter()
|
||||
.filter(|agent| agent.groups.iter().any(|id| groups.contains(id)))
|
||||
.map(|agent| agent.agent_id)
|
||||
.collect(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::legacy(false, false, Action::Complete, None)]
|
||||
#[case::closed(false, true, Action::Complete, Some(vec![]))]
|
||||
#[case::strict(true, false, Action::Unavailable, None)]
|
||||
#[case::strict_closed(true, true, Action::Unavailable, None)]
|
||||
fn lookup_failure(
|
||||
#[case] strict: bool,
|
||||
#[case] closed: bool,
|
||||
#[case] expected: Action,
|
||||
#[case] expected_grants: Option<Vec<String>>,
|
||||
) {
|
||||
assert_eq!(
|
||||
decide(Facts::Failure { strict, closed }),
|
||||
Decision {
|
||||
action: expected,
|
||||
grants: expected_grants,
|
||||
team_id: None
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::unrestricted(None, false, None)]
|
||||
#[case::withheld(Some(vec!["a".into()]), false, Some(vec![]))]
|
||||
#[case::unreadable(None, true, Some(vec![]))]
|
||||
fn withheld_never_widens(
|
||||
#[case] withheld: Option<Vec<String>>,
|
||||
#[case] unreadable: bool,
|
||||
#[case] expected: Option<Vec<String>>,
|
||||
) {
|
||||
assert_eq!(
|
||||
decide(Facts::OwnerUnion {
|
||||
teams: vec![Source {
|
||||
team_id: Some("team".into()),
|
||||
grants: None,
|
||||
unavailable: false
|
||||
}],
|
||||
withheld: vec![Source {
|
||||
team_id: Some("held".into()),
|
||||
grants: withheld,
|
||||
unavailable: false
|
||||
}],
|
||||
unreadable
|
||||
})
|
||||
.grants,
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::implicit(false, Some("a".into()), Action::Complete)]
|
||||
#[case::explicit(true, None, Action::SelectTeam)]
|
||||
fn delegated_requires_explicit_grant(
|
||||
#[case] explicit: bool,
|
||||
#[case] team_id: Option<String>,
|
||||
#[case] expected: Action,
|
||||
) {
|
||||
assert_eq!(
|
||||
decide(Facts::Delegated {
|
||||
sources: vec![Source {
|
||||
team_id: Some("a".into()),
|
||||
grants: Some(vec!["agent".into()]),
|
||||
unavailable: false
|
||||
}],
|
||||
agent_id: "agent".into(),
|
||||
team_id: Some("other".into()),
|
||||
explicit,
|
||||
allowed: None
|
||||
}),
|
||||
Decision {
|
||||
action: expected,
|
||||
team_id,
|
||||
grants: None
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
169
litellm-rust/crates/request-auth-core/src/auth_context.rs
Normal file
169
litellm-rust/crates/request-auth-core/src/auth_context.rs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::access::{self, AccessFacts, AccessRead};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Facts {
|
||||
Fetch {
|
||||
team_id: Option<String>,
|
||||
ui_team_id: String,
|
||||
user_id: Option<String>,
|
||||
project_id: Option<String>,
|
||||
end_user_id: Option<String>,
|
||||
},
|
||||
Team {
|
||||
team_id: Option<String>,
|
||||
ui_team_id: String,
|
||||
failed: bool,
|
||||
missing: bool,
|
||||
recorded_models: Vec<String>,
|
||||
allow_unavailable: Option<bool>,
|
||||
},
|
||||
Admin {
|
||||
role: Option<String>,
|
||||
user_id: Option<String>,
|
||||
admin_id: String,
|
||||
user_row: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, strum::VariantArray)]
|
||||
pub enum TeamSource {
|
||||
None,
|
||||
Token,
|
||||
Loaded,
|
||||
ReadUnavailablePolicy,
|
||||
Failure,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Plan {
|
||||
Fetch {
|
||||
team: bool,
|
||||
user: bool,
|
||||
project: bool,
|
||||
end_user: bool,
|
||||
},
|
||||
Team {
|
||||
source: TeamSource,
|
||||
},
|
||||
Admin {
|
||||
force_admin: bool,
|
||||
preserve_budget: bool,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn plan(facts: Facts) -> Plan {
|
||||
match facts {
|
||||
Facts::Fetch {
|
||||
team_id,
|
||||
ui_team_id,
|
||||
user_id,
|
||||
project_id,
|
||||
end_user_id,
|
||||
} => Plan::Fetch {
|
||||
team: team_id.as_ref().is_some_and(|id| id != &ui_team_id),
|
||||
user: user_id.is_some(),
|
||||
project: project_id.is_some(),
|
||||
end_user: end_user_id.is_some_and(|id| !id.is_empty()),
|
||||
},
|
||||
Facts::Team {
|
||||
team_id,
|
||||
ui_team_id,
|
||||
failed,
|
||||
missing,
|
||||
recorded_models,
|
||||
allow_unavailable,
|
||||
} => {
|
||||
let dashboard = team_id.as_ref() == Some(&ui_team_id);
|
||||
let source = if !failed {
|
||||
if dashboard {
|
||||
TeamSource::Token
|
||||
} else {
|
||||
TeamSource::Loaded
|
||||
}
|
||||
} else if team_id.is_none() {
|
||||
TeamSource::None
|
||||
} else {
|
||||
let decision = access::decide(AccessFacts::TeamLookup {
|
||||
dashboard,
|
||||
missing,
|
||||
recorded_models,
|
||||
allow_unavailable,
|
||||
});
|
||||
if decision.read == Some(AccessRead::UnavailablePolicy) {
|
||||
TeamSource::ReadUnavailablePolicy
|
||||
} else if decision.denial.is_some() {
|
||||
TeamSource::Failure
|
||||
} else {
|
||||
TeamSource::Token
|
||||
}
|
||||
};
|
||||
Plan::Team { source }
|
||||
}
|
||||
Facts::Admin {
|
||||
role,
|
||||
user_id,
|
||||
admin_id,
|
||||
user_row,
|
||||
} => Plan::Admin {
|
||||
force_admin: role.as_deref() == Some("proxy_admin"),
|
||||
preserve_budget: user_row && user_id.as_ref() != Some(&admin_id),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::absent(None, false)]
|
||||
#[case::dashboard(Some("ui"), false)]
|
||||
#[case::empty(Some(""), true)]
|
||||
#[case::team(Some("team"), true)]
|
||||
fn fetch_team(#[case] team: Option<&str>, #[case] expected: bool) {
|
||||
assert_eq!(
|
||||
plan(Facts::Fetch {
|
||||
team_id: team.map(str::to_owned),
|
||||
ui_team_id: "ui".into(),
|
||||
user_id: Some("".into()),
|
||||
project_id: Some("".into()),
|
||||
end_user_id: Some("".into())
|
||||
}),
|
||||
Plan::Fetch {
|
||||
team: expected,
|
||||
user: true,
|
||||
project: true,
|
||||
end_user: false
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::master(Some("admin"), true, false)]
|
||||
#[case::personal(Some("user"), true, true)]
|
||||
#[case::no_row(Some("user"), false, false)]
|
||||
#[case::missing_id(None, true, true)]
|
||||
fn admin_budget_projection(
|
||||
#[case] user: Option<&str>,
|
||||
#[case] user_row: bool,
|
||||
#[case] preserve_budget: bool,
|
||||
) {
|
||||
assert_eq!(
|
||||
plan(Facts::Admin {
|
||||
role: Some("proxy_admin".into()),
|
||||
user_id: user.map(str::to_owned),
|
||||
admin_id: "admin".into(),
|
||||
user_row
|
||||
}),
|
||||
Plan::Admin {
|
||||
force_admin: true,
|
||||
preserve_budget
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
233
litellm-rust/crates/request-auth-core/src/budget_alerts.rs
Normal file
233
litellm-rust/crates/request-auth-core/src/budget_alerts.rs
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Failure {
|
||||
pub kind: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ThresholdFacts {
|
||||
pub key: String,
|
||||
pub digit: bool,
|
||||
pub digits: Option<String>,
|
||||
pub ratio: Option<f64>,
|
||||
pub integer_failure: Option<Failure>,
|
||||
pub ratio_failure: Option<Failure>,
|
||||
pub text: Option<String>,
|
||||
pub items: Option<Vec<Option<String>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ConfigFacts {
|
||||
pub global_entries: Vec<ThresholdFacts>,
|
||||
pub local_entries: Vec<ThresholdFacts>,
|
||||
pub global_failure: Option<Failure>,
|
||||
pub local_failure: Option<Failure>,
|
||||
pub order: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Facts {
|
||||
pub member: bool,
|
||||
pub spend: Option<f64>,
|
||||
pub limit: Option<f64>,
|
||||
pub default_fraction: f64,
|
||||
pub config: Option<ConfigFacts>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq)]
|
||||
pub struct Threshold {
|
||||
pub key: String,
|
||||
pub recipients: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, PartialEq)]
|
||||
pub struct Decision {
|
||||
pub read_config: bool,
|
||||
pub read_order: bool,
|
||||
pub global_keys: Vec<String>,
|
||||
pub local_keys: Vec<String>,
|
||||
pub emit: bool,
|
||||
pub thresholds: Option<Vec<Threshold>>,
|
||||
pub failure: Option<Failure>,
|
||||
}
|
||||
|
||||
fn member_entry(entry: &ThresholdFacts) -> Result<bool, Failure> {
|
||||
if !entry.digit || entry.key.chars().count() > 3 {
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(error) = &entry.integer_failure {
|
||||
return Err(error.clone());
|
||||
}
|
||||
Ok(entry
|
||||
.digits
|
||||
.as_deref()
|
||||
.and_then(|digits| digits.parse::<u32>().ok())
|
||||
.is_some_and(|n| (1..=100).contains(&n)))
|
||||
}
|
||||
|
||||
fn eligible(entries: &[ThresholdFacts], member: bool) -> Result<Vec<&ThresholdFacts>, Failure> {
|
||||
entries.iter().try_fold(Vec::new(), |mut selected, entry| {
|
||||
if !member || member_entry(entry)? {
|
||||
selected.push(entry);
|
||||
}
|
||||
Ok(selected)
|
||||
})
|
||||
}
|
||||
|
||||
fn recipients(entry: &ThresholdFacts) -> Vec<String> {
|
||||
let strings: Vec<&str> = if let Some(text) = &entry.text {
|
||||
text.split(',').collect()
|
||||
} else {
|
||||
entry
|
||||
.items
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(Option::as_deref)
|
||||
.collect()
|
||||
};
|
||||
strings
|
||||
.into_iter()
|
||||
.map(|s| s.trim_matches(|c: char| c.is_whitespace() || matches!(c, '\u{1c}'..='\u{1f}')))
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn merge(key: &str, global: &[&ThresholdFacts], local: &[&ThresholdFacts]) -> Threshold {
|
||||
let recipients = global
|
||||
.iter()
|
||||
.chain(local)
|
||||
.filter(|entry| entry.key == key)
|
||||
.flat_map(|entry| recipients(entry))
|
||||
.fold(Vec::new(), |mut merged, email| {
|
||||
if !merged.contains(&email) {
|
||||
merged.push(email);
|
||||
}
|
||||
merged
|
||||
});
|
||||
Threshold {
|
||||
key: key.to_owned(),
|
||||
recipients,
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate(facts: &Facts) -> Result<Decision, Failure> {
|
||||
if !facts.member && !(facts.limit.is_some() && facts.spend.is_some_and(|spend| spend > 0.0)) {
|
||||
return Ok(Decision::default());
|
||||
}
|
||||
let Some(config) = &facts.config else {
|
||||
return Ok(Decision {
|
||||
read_config: true,
|
||||
..Decision::default()
|
||||
});
|
||||
};
|
||||
if let Some(failure) = &config.global_failure {
|
||||
return Err(failure.clone());
|
||||
}
|
||||
if !facts.member
|
||||
&& let Some(failure) = &config.local_failure
|
||||
{
|
||||
return Err(failure.clone());
|
||||
}
|
||||
let global = eligible(&config.global_entries, false)?;
|
||||
let local = if facts.member && config.local_failure.is_some() {
|
||||
vec![]
|
||||
} else {
|
||||
eligible(&config.local_entries, facts.member)?
|
||||
};
|
||||
if global.is_empty() && local.is_empty() {
|
||||
let emit = !facts.member
|
||||
&& matches!((facts.spend, facts.limit), (Some(spend), Some(limit)) if spend >= limit * facts.default_fraction && spend < limit);
|
||||
return Ok(Decision {
|
||||
emit,
|
||||
..Decision::default()
|
||||
});
|
||||
}
|
||||
let Some(order) = &config.order else {
|
||||
return Ok(Decision {
|
||||
read_order: true,
|
||||
global_keys: global.iter().map(|entry| entry.key.clone()).collect(),
|
||||
local_keys: local.iter().map(|entry| entry.key.clone()).collect(),
|
||||
..Decision::default()
|
||||
});
|
||||
};
|
||||
let thresholds: Vec<Threshold> = order
|
||||
.iter()
|
||||
.map(|key| merge(key, &global, &local))
|
||||
.collect();
|
||||
if facts.member && facts.spend.is_some_and(|spend| spend <= 0.0) {
|
||||
return Ok(Decision::default());
|
||||
}
|
||||
let mut minimum: Option<&ThresholdFacts> = None;
|
||||
for key in order {
|
||||
let Some(entry) = global.iter().chain(&local).find(|entry| entry.key == *key) else {
|
||||
continue;
|
||||
};
|
||||
if !entry.digit {
|
||||
continue;
|
||||
}
|
||||
if let Some(failure) = &entry.integer_failure {
|
||||
return Err(failure.clone());
|
||||
}
|
||||
let Some(digits) = entry.digits.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
if minimum
|
||||
.and_then(|entry| entry.digits.as_ref())
|
||||
.is_none_or(|current| (digits.len(), digits) < (current.len(), current))
|
||||
{
|
||||
minimum = Some(entry);
|
||||
}
|
||||
}
|
||||
let Some(minimum) = minimum else {
|
||||
return Ok(Decision::default());
|
||||
};
|
||||
if let Some(failure) = &minimum.ratio_failure {
|
||||
return Err(failure.clone());
|
||||
}
|
||||
let emit = matches!((facts.spend, facts.limit, minimum.ratio), (Some(spend), Some(limit), Some(ratio)) if spend.partial_cmp(&(limit * ratio)) != Some(std::cmp::Ordering::Less));
|
||||
Ok(Decision {
|
||||
emit,
|
||||
thresholds: Some(thresholds),
|
||||
..Decision::default()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn plan(facts: &Facts) -> Decision {
|
||||
evaluate(facts).unwrap_or_else(|failure| Decision {
|
||||
failure: Some(failure),
|
||||
..Decision::default()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::before(7.9, false)]
|
||||
#[case::at(8.0, true)]
|
||||
#[case::at_limit(10.0, false)]
|
||||
#[case::nan(f64::NAN, false)]
|
||||
#[case::zero(0.0, false)]
|
||||
fn default_key_threshold(#[case] spend: f64, #[case] emit: bool) {
|
||||
let facts = Facts {
|
||||
member: false,
|
||||
spend: Some(spend),
|
||||
limit: Some(10.0),
|
||||
default_fraction: 0.8,
|
||||
config: Some(ConfigFacts {
|
||||
global_entries: vec![],
|
||||
local_entries: vec![],
|
||||
global_failure: None,
|
||||
local_failure: None,
|
||||
order: None,
|
||||
}),
|
||||
};
|
||||
assert_eq!(plan(&facts).emit, emit);
|
||||
}
|
||||
}
|
||||
484
litellm-rust/crates/request-auth-core/src/budget_context.rs
Normal file
484
litellm-rust/crates/request-auth-core/src/budget_context.rs
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, strum::VariantArray)]
|
||||
pub enum ModelScope {
|
||||
Team,
|
||||
EndUser,
|
||||
CustomEndUser,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ModelCheckFacts {
|
||||
pub scope: ModelScope,
|
||||
pub entity_id: Option<String>,
|
||||
pub budget_count: usize,
|
||||
pub model_count: usize,
|
||||
}
|
||||
|
||||
pub fn model_checks(facts: &ModelCheckFacts) -> Vec<usize> {
|
||||
if facts.budget_count == 0
|
||||
|| facts.entity_id.is_none()
|
||||
|| (facts.scope == ModelScope::EndUser && facts.entity_id.as_deref() == Some(""))
|
||||
{
|
||||
return vec![];
|
||||
}
|
||||
(0..facts.model_count).collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct BudgetFields {
|
||||
pub max_budget: Option<f64>,
|
||||
pub tpm_limit: Option<i64>,
|
||||
pub rpm_limit: Option<i64>,
|
||||
pub tpd_limit: Option<i64>,
|
||||
pub model_budget_count: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, strum::VariantArray)]
|
||||
pub enum EndUserMode {
|
||||
Models,
|
||||
Token,
|
||||
Default,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct EndUserFacts {
|
||||
pub mode: EndUserMode,
|
||||
pub object_present: bool,
|
||||
pub end_user_id: Option<String>,
|
||||
pub prisma_present: bool,
|
||||
pub key_budget_id: Option<String>,
|
||||
pub key_read: bool,
|
||||
pub key_budget: Option<BudgetFields>,
|
||||
pub global_config_read: bool,
|
||||
pub global_budget_id: Option<String>,
|
||||
pub global_read: bool,
|
||||
pub global_budget: Option<BudgetFields>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq, strum::VariantArray)]
|
||||
pub enum EndUserSource {
|
||||
Own,
|
||||
ReadKey,
|
||||
Key,
|
||||
ReadGlobalConfig,
|
||||
ReadGlobal,
|
||||
Global,
|
||||
None,
|
||||
}
|
||||
|
||||
pub fn end_user_source(facts: &EndUserFacts) -> EndUserSource {
|
||||
use EndUserSource::*;
|
||||
if facts.mode != EndUserMode::Default && facts.object_present {
|
||||
return Own;
|
||||
}
|
||||
if facts.mode == EndUserMode::Models
|
||||
&& (!facts.prisma_present || facts.end_user_id.as_deref().is_none_or(str::is_empty))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if facts.key_budget_id.is_some() {
|
||||
if !facts.key_read {
|
||||
return ReadKey;
|
||||
}
|
||||
if facts.key_budget.is_some() {
|
||||
return Key;
|
||||
}
|
||||
}
|
||||
if !facts.global_config_read {
|
||||
return ReadGlobalConfig;
|
||||
}
|
||||
if facts.global_budget_id.is_none() {
|
||||
return None;
|
||||
}
|
||||
if !facts.global_read {
|
||||
return ReadGlobal;
|
||||
}
|
||||
if facts.global_budget.is_some() {
|
||||
Global
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct TokenDefaultsFacts {
|
||||
pub current: BudgetFields,
|
||||
pub keep_token_limits: bool,
|
||||
pub default_present: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq, strum::VariantArray)]
|
||||
pub enum TokenField {
|
||||
MaxBudget,
|
||||
Tpm,
|
||||
Rpm,
|
||||
Tpd,
|
||||
Models,
|
||||
}
|
||||
|
||||
pub fn token_assignments(facts: &TokenDefaultsFacts) -> Vec<TokenField> {
|
||||
if !facts.default_present {
|
||||
return vec![];
|
||||
}
|
||||
[
|
||||
(TokenField::MaxBudget, facts.current.max_budget.is_none()),
|
||||
(TokenField::Tpm, facts.current.tpm_limit.is_none()),
|
||||
(TokenField::Rpm, facts.current.rpm_limit.is_none()),
|
||||
(TokenField::Tpd, facts.current.tpd_limit.is_none()),
|
||||
(
|
||||
TokenField::Models,
|
||||
facts.current.model_budget_count.is_none(),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(field, missing)| (!facts.keep_token_limits || missing).then_some(field))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct MembershipSnapshot {
|
||||
pub budget_id: Option<String>,
|
||||
pub model_budget_count: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, strum::VariantArray)]
|
||||
pub enum LookupFailure {
|
||||
Membership,
|
||||
Default,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct MemberFacts {
|
||||
pub user_id: Option<String>,
|
||||
pub team_id: Option<String>,
|
||||
pub prisma_present: bool,
|
||||
pub default_id: Option<String>,
|
||||
pub skip_budget_checks: bool,
|
||||
pub membership_read: bool,
|
||||
pub membership: Option<MembershipSnapshot>,
|
||||
pub default_read: bool,
|
||||
pub default_budget: Option<BudgetFields>,
|
||||
pub lookup_failure: Option<LookupFailure>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq, strum::VariantArray)]
|
||||
pub enum MemberSource {
|
||||
Done,
|
||||
ReadMembership,
|
||||
ReadDefault,
|
||||
Member,
|
||||
Default,
|
||||
Clear,
|
||||
RaiseLookup,
|
||||
SkipLookup,
|
||||
}
|
||||
|
||||
pub fn member_source(facts: &MemberFacts) -> MemberSource {
|
||||
use MemberSource::*;
|
||||
if facts.user_id.is_none() || facts.team_id.is_none() || !facts.prisma_present {
|
||||
return Done;
|
||||
}
|
||||
if facts.lookup_failure.is_some() {
|
||||
return if facts.skip_budget_checks {
|
||||
SkipLookup
|
||||
} else {
|
||||
RaiseLookup
|
||||
};
|
||||
}
|
||||
if !facts.membership_read {
|
||||
return ReadMembership;
|
||||
}
|
||||
if facts.membership.as_ref().is_some_and(|member| {
|
||||
member.budget_id.is_some()
|
||||
&& member.budget_id != facts.default_id
|
||||
&& member.model_budget_count.is_some_and(|count| count > 0)
|
||||
}) {
|
||||
return Member;
|
||||
}
|
||||
if facts.default_id.is_none() {
|
||||
return Done;
|
||||
}
|
||||
if !facts.default_read {
|
||||
return ReadDefault;
|
||||
}
|
||||
if facts
|
||||
.default_budget
|
||||
.as_ref()
|
||||
.is_some_and(|budget| budget.model_budget_count.is_some())
|
||||
{
|
||||
Default
|
||||
} else {
|
||||
Clear
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct IncreaseFacts {
|
||||
pub number: Option<f64>,
|
||||
pub truthy: bool,
|
||||
pub type_name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct TemporaryFacts {
|
||||
pub max_budget: Option<f64>,
|
||||
pub metadata_read: bool,
|
||||
pub has_increase: bool,
|
||||
pub has_expiry: bool,
|
||||
pub expires_at: Option<i64>,
|
||||
pub now: Option<i64>,
|
||||
pub increase: Option<IncreaseFacts>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq, strum::VariantArray)]
|
||||
pub enum TemporaryRead {
|
||||
Metadata,
|
||||
Expiry,
|
||||
Now,
|
||||
Increase,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, PartialEq)]
|
||||
pub struct TemporaryDecision {
|
||||
pub read: Option<TemporaryRead>,
|
||||
pub copy: bool,
|
||||
pub max_budget: Option<f64>,
|
||||
pub invalid_increase_type: Option<String>,
|
||||
}
|
||||
|
||||
fn temp_read(read: TemporaryRead) -> TemporaryDecision {
|
||||
TemporaryDecision {
|
||||
read: Some(read),
|
||||
..TemporaryDecision::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn temporary(facts: &TemporaryFacts) -> TemporaryDecision {
|
||||
let Some(limit) = facts.max_budget else {
|
||||
return TemporaryDecision::default();
|
||||
};
|
||||
if !facts.metadata_read {
|
||||
return temp_read(TemporaryRead::Metadata);
|
||||
}
|
||||
if !facts.has_increase || !facts.has_expiry {
|
||||
return TemporaryDecision::default();
|
||||
}
|
||||
let Some(expiry) = facts.expires_at else {
|
||||
return temp_read(TemporaryRead::Expiry);
|
||||
};
|
||||
let Some(now) = facts.now else {
|
||||
return temp_read(TemporaryRead::Now);
|
||||
};
|
||||
if expiry <= now {
|
||||
return TemporaryDecision::default();
|
||||
}
|
||||
let Some(increase) = &facts.increase else {
|
||||
return temp_read(TemporaryRead::Increase);
|
||||
};
|
||||
if !increase.truthy {
|
||||
return TemporaryDecision::default();
|
||||
}
|
||||
match increase.number {
|
||||
Some(amount) => TemporaryDecision {
|
||||
copy: true,
|
||||
max_budget: Some(limit + amount),
|
||||
..TemporaryDecision::default()
|
||||
},
|
||||
None => TemporaryDecision {
|
||||
invalid_increase_type: Some(increase.type_name.clone()),
|
||||
..TemporaryDecision::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct NumericFacts {
|
||||
pub number: Option<f64>,
|
||||
pub boolean: bool,
|
||||
}
|
||||
|
||||
pub fn percentage(facts: &NumericFacts) -> Option<f64> {
|
||||
if facts.boolean {
|
||||
return None;
|
||||
}
|
||||
facts.number.filter(|pct| *pct > 0.0 && *pct <= 1.0)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ThrottleFacts {
|
||||
pub opt_in: bool,
|
||||
pub tpm_limit: Option<i64>,
|
||||
pub rpm_limit: Option<i64>,
|
||||
pub percentage: Option<NumericFacts>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, PartialEq)]
|
||||
pub struct ThrottleDecision {
|
||||
pub read_percentage: bool,
|
||||
pub percentage: Option<f64>,
|
||||
}
|
||||
|
||||
pub fn throttle(facts: &ThrottleFacts) -> ThrottleDecision {
|
||||
if !facts.opt_in || (facts.tpm_limit.is_none() && facts.rpm_limit.is_none()) {
|
||||
return ThrottleDecision::default();
|
||||
}
|
||||
match &facts.percentage {
|
||||
None => ThrottleDecision {
|
||||
read_percentage: true,
|
||||
..ThrottleDecision::default()
|
||||
},
|
||||
Some(raw) => ThrottleDecision {
|
||||
percentage: percentage(raw),
|
||||
..ThrottleDecision::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::team_empty(ModelScope::Team, Some(""), vec![0, 1])]
|
||||
#[case::team_none(ModelScope::Team, None, vec![])]
|
||||
#[case::enduser_empty(ModelScope::EndUser, Some(""), vec![])]
|
||||
#[case::enduser(ModelScope::EndUser, Some("user"), vec![0, 1])]
|
||||
#[case::custom_empty(ModelScope::CustomEndUser, Some(""), vec![0, 1])]
|
||||
#[case::custom_absent(ModelScope::CustomEndUser, None, vec![])]
|
||||
fn model_identity_gates(
|
||||
#[case] scope: ModelScope,
|
||||
#[case] identity: Option<&str>,
|
||||
#[case] expected: Vec<usize>,
|
||||
) {
|
||||
assert_eq!(
|
||||
model_checks(&ModelCheckFacts {
|
||||
scope,
|
||||
entity_id: identity.map(str::to_owned),
|
||||
budget_count: 1,
|
||||
model_count: 2
|
||||
}),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::zero(0.0, false, None)]
|
||||
#[case::negative(-0.5, false, None)]
|
||||
#[case::nan(f64::NAN, false, None)]
|
||||
#[case::infinite(f64::INFINITY, false, None)]
|
||||
#[case::one(1.0, false, Some(1.0))]
|
||||
#[case::boolean(1.0, true, None)]
|
||||
fn numeric_percentages(
|
||||
#[case] number: f64,
|
||||
#[case] boolean: bool,
|
||||
#[case] expected: Option<f64>,
|
||||
) {
|
||||
assert_eq!(
|
||||
percentage(&NumericFacts {
|
||||
number: Some(number),
|
||||
boolean
|
||||
}),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::expired(10, 10, 3.0, false, None)]
|
||||
#[case::before(9, 10, 3.0, false, None)]
|
||||
#[case::active(11, 10, 3.0, true, Some(13.0))]
|
||||
#[case::negative(11, 10, -3.0, true, Some(7.0))]
|
||||
#[case::zero(11, 10, 0.0, false, None)]
|
||||
fn temporary_copy_boundary(
|
||||
#[case] expiry: i64,
|
||||
#[case] now: i64,
|
||||
#[case] amount: f64,
|
||||
#[case] copy: bool,
|
||||
#[case] expected: Option<f64>,
|
||||
) {
|
||||
let facts = TemporaryFacts {
|
||||
max_budget: Some(10.0),
|
||||
metadata_read: true,
|
||||
has_increase: true,
|
||||
has_expiry: true,
|
||||
expires_at: Some(expiry),
|
||||
now: Some(now),
|
||||
increase: Some(IncreaseFacts {
|
||||
number: Some(amount),
|
||||
truthy: amount != 0.0,
|
||||
type_name: "float".to_owned(),
|
||||
}),
|
||||
};
|
||||
let result = temporary(&facts);
|
||||
assert_eq!(
|
||||
(result.copy, result.max_budget, result.read),
|
||||
(copy, expected, None)
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::member_override(Some("member"), Some(1), Some("default"), false, MemberSource::Member)]
|
||||
#[case::no_override(None, Some(1), None, false, MemberSource::Done)]
|
||||
#[case::empty_override(
|
||||
Some("member"),
|
||||
Some(0),
|
||||
Some("default"),
|
||||
false,
|
||||
MemberSource::ReadDefault
|
||||
)]
|
||||
#[case::current_default(
|
||||
Some("default"),
|
||||
Some(1),
|
||||
Some("default"),
|
||||
false,
|
||||
MemberSource::ReadDefault
|
||||
)]
|
||||
#[case::missing_default(None, None, Some("default"), true, MemberSource::Clear)]
|
||||
fn member_default_staging(
|
||||
#[case] member_id: Option<&str>,
|
||||
#[case] count: Option<usize>,
|
||||
#[case] default_id: Option<&str>,
|
||||
#[case] default_read: bool,
|
||||
#[case] expected: MemberSource,
|
||||
) {
|
||||
let facts = MemberFacts {
|
||||
user_id: Some(String::new()),
|
||||
team_id: Some(String::new()),
|
||||
prisma_present: true,
|
||||
default_id: default_id.map(str::to_owned),
|
||||
skip_budget_checks: false,
|
||||
membership_read: true,
|
||||
membership: Some(MembershipSnapshot {
|
||||
budget_id: member_id.map(str::to_owned),
|
||||
model_budget_count: count,
|
||||
}),
|
||||
default_read,
|
||||
default_budget: None,
|
||||
lookup_failure: None,
|
||||
};
|
||||
assert_eq!(member_source(&facts), expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::replace(false, true, vec![TokenField::MaxBudget, TokenField::Tpm, TokenField::Rpm, TokenField::Tpd, TokenField::Models])]
|
||||
#[case::keep_empty(true, true, vec![TokenField::Rpm, TokenField::Tpd])]
|
||||
#[case::no_default(false, false, vec![])]
|
||||
fn token_assignment_order(
|
||||
#[case] keep: bool,
|
||||
#[case] present: bool,
|
||||
#[case] expected: Vec<TokenField>,
|
||||
) {
|
||||
let facts = TokenDefaultsFacts {
|
||||
current: BudgetFields {
|
||||
max_budget: Some(0.0),
|
||||
tpm_limit: Some(0),
|
||||
model_budget_count: Some(0),
|
||||
..BudgetFields::default()
|
||||
},
|
||||
keep_token_limits: keep,
|
||||
default_present: present,
|
||||
};
|
||||
assert_eq!(token_assignments(&facts), expected);
|
||||
}
|
||||
}
|
||||
284
litellm-rust/crates/request-auth-core/src/budget_lookup.rs
Normal file
284
litellm-rust/crates/request-auth-core/src/budget_lookup.rs
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct OrganizationFacts {
|
||||
pub token_present: bool,
|
||||
pub client_present: bool,
|
||||
pub team_present: bool,
|
||||
pub token_id: Option<bool>,
|
||||
pub team_id: Option<bool>,
|
||||
pub row_present: Option<bool>,
|
||||
pub lookup_failed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq, strum::VariantArray)]
|
||||
pub enum OrganizationAction {
|
||||
Skip,
|
||||
ReadTokenId,
|
||||
ReadTeamId,
|
||||
ReadOrganization,
|
||||
UseOrganization,
|
||||
}
|
||||
|
||||
pub fn organization(facts: &OrganizationFacts) -> OrganizationAction {
|
||||
use OrganizationAction::*;
|
||||
if !facts.token_present || !facts.client_present {
|
||||
return Skip;
|
||||
}
|
||||
match facts.token_id {
|
||||
None => return ReadTokenId,
|
||||
Some(false) if !facts.team_present => return Skip,
|
||||
Some(false) => match facts.team_id {
|
||||
None => return ReadTeamId,
|
||||
Some(false) => return Skip,
|
||||
Some(true) => {}
|
||||
},
|
||||
Some(true) => {}
|
||||
}
|
||||
if facts.lookup_failed {
|
||||
return Skip;
|
||||
}
|
||||
match facts.row_present {
|
||||
None => ReadOrganization,
|
||||
Some(false) => Skip,
|
||||
Some(true) => UseOrganization,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct MemberFacts {
|
||||
pub team_present: bool,
|
||||
pub token_present: bool,
|
||||
pub team_id: Option<bool>,
|
||||
pub user_id: Option<bool>,
|
||||
pub membership_loaded: bool,
|
||||
pub limits_read: bool,
|
||||
pub maximum_present: bool,
|
||||
pub default_id_string: bool,
|
||||
pub default_read: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq, strum::VariantArray)]
|
||||
pub enum MemberAction {
|
||||
Skip,
|
||||
ReadTeamId,
|
||||
ReadUserId,
|
||||
ReadMembership,
|
||||
ReadLimits,
|
||||
ReadDefault,
|
||||
UseLimits,
|
||||
}
|
||||
|
||||
pub fn member(facts: &MemberFacts) -> MemberAction {
|
||||
use MemberAction::*;
|
||||
if !facts.team_present {
|
||||
return Skip;
|
||||
}
|
||||
match facts.team_id {
|
||||
None => return ReadTeamId,
|
||||
Some(false) => return Skip,
|
||||
Some(true) => {}
|
||||
}
|
||||
if !facts.token_present {
|
||||
return Skip;
|
||||
}
|
||||
match facts.user_id {
|
||||
None => return ReadUserId,
|
||||
Some(false) => return Skip,
|
||||
Some(true) => {}
|
||||
}
|
||||
if !facts.membership_loaded {
|
||||
return ReadMembership;
|
||||
}
|
||||
if !facts.limits_read {
|
||||
return ReadLimits;
|
||||
}
|
||||
if !facts.maximum_present && facts.default_id_string && !facts.default_read {
|
||||
return ReadDefault;
|
||||
}
|
||||
UseLimits
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub enum EmailFacts {
|
||||
Text(String),
|
||||
List(Vec<Option<String>>),
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct SoftFacts {
|
||||
pub limit: Option<f64>,
|
||||
pub spend: Option<f64>,
|
||||
pub token_present: bool,
|
||||
}
|
||||
|
||||
pub fn read_soft_emails(facts: &SoftFacts) -> bool {
|
||||
facts.token_present
|
||||
&& facts
|
||||
.limit
|
||||
.zip(facts.spend)
|
||||
.is_some_and(|(limit, spend)| spend >= limit)
|
||||
}
|
||||
|
||||
fn stripped(value: &str) -> &str {
|
||||
value.trim_matches(|character: char| {
|
||||
character.is_whitespace() || ('\u{1c}'..='\u{1f}').contains(&character)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn emails(facts: &EmailFacts) -> Vec<String> {
|
||||
match facts {
|
||||
EmailFacts::Text(value) => value
|
||||
.split(',')
|
||||
.map(stripped)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_owned)
|
||||
.collect(),
|
||||
EmailFacts::List(values) => values
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|value| !stripped(value).is_empty())
|
||||
.cloned()
|
||||
.collect(),
|
||||
EmailFacts::Other => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::below(Some(1.0), Some(0.0), true, false)]
|
||||
#[case::at_limit(Some(1.0), Some(1.0), true, true)]
|
||||
#[case::no_token(Some(1.0), Some(2.0), false, false)]
|
||||
#[case::no_limit(None, Some(2.0), true, false)]
|
||||
#[case::nan(Some(f64::NAN), Some(2.0), true, false)]
|
||||
fn lazy_soft_metadata(
|
||||
#[case] limit: Option<f64>,
|
||||
#[case] spend: Option<f64>,
|
||||
#[case] token_present: bool,
|
||||
#[case] expected: bool,
|
||||
) {
|
||||
assert_eq!(
|
||||
read_soft_emails(&SoftFacts {
|
||||
limit,
|
||||
spend,
|
||||
token_present
|
||||
}),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn failed_organization_lookup_is_skipped() {
|
||||
assert_eq!(
|
||||
organization(&OrganizationFacts {
|
||||
token_present: true,
|
||||
client_present: true,
|
||||
token_id: Some(true),
|
||||
lookup_failed: true,
|
||||
..Default::default()
|
||||
}),
|
||||
OrganizationAction::Skip
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::missing_token(false, true, None, None, OrganizationAction::Skip)]
|
||||
#[case::missing_client(true, false, None, None, OrganizationAction::Skip)]
|
||||
#[case::token_first(true, true, None, None, OrganizationAction::ReadTokenId)]
|
||||
#[case::token_selected(true, true, Some(true), None, OrganizationAction::ReadOrganization)]
|
||||
#[case::missing_row(true, true, Some(true), Some(false), OrganizationAction::Skip)]
|
||||
#[case::loaded_row(
|
||||
true,
|
||||
true,
|
||||
Some(true),
|
||||
Some(true),
|
||||
OrganizationAction::UseOrganization
|
||||
)]
|
||||
fn organization_stages(
|
||||
#[case] token_present: bool,
|
||||
#[case] client_present: bool,
|
||||
#[case] token_id: Option<bool>,
|
||||
#[case] row_present: Option<bool>,
|
||||
#[case] expected: OrganizationAction,
|
||||
) {
|
||||
assert_eq!(
|
||||
organization(&OrganizationFacts {
|
||||
token_present,
|
||||
client_present,
|
||||
token_id,
|
||||
row_present,
|
||||
..Default::default()
|
||||
}),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::no_team(false, None, OrganizationAction::Skip)]
|
||||
#[case::read_team(true, None, OrganizationAction::ReadTeamId)]
|
||||
#[case::no_team_id(true, Some(false), OrganizationAction::Skip)]
|
||||
#[case::team_selected(true, Some(true), OrganizationAction::ReadOrganization)]
|
||||
fn organization_fallback(
|
||||
#[case] team_present: bool,
|
||||
#[case] team_id: Option<bool>,
|
||||
#[case] expected: OrganizationAction,
|
||||
) {
|
||||
assert_eq!(
|
||||
organization(&OrganizationFacts {
|
||||
token_present: true,
|
||||
client_present: true,
|
||||
token_id: Some(false),
|
||||
team_present,
|
||||
team_id,
|
||||
row_present: None,
|
||||
lookup_failed: false
|
||||
}),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::load_membership(false, false, false, false, false, MemberAction::ReadMembership)]
|
||||
#[case::read_limits(true, false, false, false, false, MemberAction::ReadLimits)]
|
||||
#[case::own_maximum(true, true, true, true, false, MemberAction::UseLimits)]
|
||||
#[case::default(true, true, false, true, false, MemberAction::ReadDefault)]
|
||||
#[case::invalid_default(true, true, false, false, false, MemberAction::UseLimits)]
|
||||
#[case::loaded_default(true, true, false, true, true, MemberAction::UseLimits)]
|
||||
fn member_stages(
|
||||
#[case] membership_loaded: bool,
|
||||
#[case] limits_read: bool,
|
||||
#[case] maximum_present: bool,
|
||||
#[case] default_id_string: bool,
|
||||
#[case] default_read: bool,
|
||||
#[case] expected: MemberAction,
|
||||
) {
|
||||
assert_eq!(
|
||||
member(&MemberFacts {
|
||||
team_present: true,
|
||||
token_present: true,
|
||||
team_id: Some(true),
|
||||
user_id: Some(true),
|
||||
membership_loaded,
|
||||
limits_read,
|
||||
maximum_present,
|
||||
default_id_string,
|
||||
default_read
|
||||
}),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::comma_list(EmailFacts::Text(" a, ,b,a ".into()), vec!["a", "b", "a"])]
|
||||
#[case::list_keeps_space(EmailFacts::List(vec![Some(" a ".into()), None, Some("\u{1c}".into())]), vec![" a "])]
|
||||
#[case::python_whitespace(EmailFacts::Text("\u{1c}a\u{1f},\u{85}b\u{a0}".into()), vec!["a", "b"])]
|
||||
#[case::other(EmailFacts::Other, vec![])]
|
||||
fn email_normalization(#[case] facts: EmailFacts, #[case] expected: Vec<&str>) {
|
||||
assert_eq!(emails(&facts), expected);
|
||||
}
|
||||
}
|
||||
261
litellm-rust/crates/request-auth-core/src/budget_pricing.rs
Normal file
261
litellm-rust/crates/request-auth-core/src/budget_pricing.rs
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct DeploymentPrice {
|
||||
pub has_input_price: bool,
|
||||
pub has_output_price: bool,
|
||||
pub input_price: Option<f64>,
|
||||
pub output_price: Option<f64>,
|
||||
pub query_price: Option<f64>,
|
||||
pub ptu_count: Option<f64>,
|
||||
pub cost_per_ptu_per_hour: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ModelPriceFacts {
|
||||
pub cached: Option<bool>,
|
||||
pub lookup_failed: bool,
|
||||
pub group_found: bool,
|
||||
pub input_price: Option<f64>,
|
||||
pub output_price: Option<f64>,
|
||||
pub deployments: Option<Vec<DeploymentPrice>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct PriceDecision {
|
||||
pub zero_cost: bool,
|
||||
pub cache_value: Option<bool>,
|
||||
pub read_deployments: bool,
|
||||
}
|
||||
|
||||
pub fn price(facts: &ModelPriceFacts) -> PriceDecision {
|
||||
if let Some(cached) = facts.cached {
|
||||
return PriceDecision {
|
||||
zero_cost: cached,
|
||||
cache_value: None,
|
||||
read_deployments: false,
|
||||
};
|
||||
}
|
||||
if facts.lookup_failed {
|
||||
return PriceDecision {
|
||||
zero_cost: false,
|
||||
cache_value: None,
|
||||
read_deployments: false,
|
||||
};
|
||||
}
|
||||
let group_zero = facts.group_found
|
||||
&& matches!((facts.input_price, facts.output_price), (Some(input), Some(output)) if !(input > 0.0 || output > 0.0));
|
||||
if group_zero && facts.deployments.is_none() {
|
||||
return PriceDecision {
|
||||
zero_cost: false,
|
||||
cache_value: None,
|
||||
read_deployments: true,
|
||||
};
|
||||
}
|
||||
let deployments = facts.deployments.as_deref().unwrap_or_default();
|
||||
let zero_cost = group_zero
|
||||
&& deployments
|
||||
.iter()
|
||||
.any(|d| d.has_input_price || d.has_output_price)
|
||||
&& !deployments.iter().any(|d| {
|
||||
d.input_price.is_some_and(|p| p > 0.0)
|
||||
|| d.output_price.is_some_and(|p| p > 0.0)
|
||||
|| d.query_price.is_some_and(|p| p > 0.0)
|
||||
|| d.ptu_count.is_some() && d.cost_per_ptu_per_hour.is_some()
|
||||
});
|
||||
PriceDecision {
|
||||
zero_cost,
|
||||
cache_value: Some(zero_cost),
|
||||
read_deployments: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct RouteBudgetFacts {
|
||||
pub side_effect_route: bool,
|
||||
pub discovery_route: bool,
|
||||
pub llm_route: bool,
|
||||
pub registered_pass_through: Option<bool>,
|
||||
pub route: String,
|
||||
pub method: Option<String>,
|
||||
pub body_present: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, strum::VariantArray)]
|
||||
pub enum RouteBudgetDecision {
|
||||
ReadPassThrough,
|
||||
ReadPricing,
|
||||
Skip,
|
||||
}
|
||||
|
||||
pub fn route(facts: &RouteBudgetFacts) -> RouteBudgetDecision {
|
||||
use RouteBudgetDecision::*;
|
||||
if !facts.side_effect_route {
|
||||
if facts.discovery_route {
|
||||
return Skip;
|
||||
}
|
||||
if !facts.llm_route {
|
||||
match facts.registered_pass_through {
|
||||
None => return ReadPassThrough,
|
||||
Some(false) => return Skip,
|
||||
Some(true) => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
let mcp_discovery = facts.body_present
|
||||
&& (matches!(
|
||||
facts.route.as_str(),
|
||||
"/mcp-rest/tools/list" | "/v1/mcp/tools"
|
||||
) || !matches!(
|
||||
facts.route.as_str(),
|
||||
"/mcp/tools/call" | "/mcp-rest/tools/call"
|
||||
) && (facts.route == "/mcp" || facts.route.starts_with("/mcp/"))
|
||||
&& matches!(
|
||||
facts.method.as_deref(),
|
||||
Some("initialize" | "notifications/initialized" | "ping" | "tools/list")
|
||||
));
|
||||
if mcp_discovery { Skip } else { ReadPricing }
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ExemptionFacts {
|
||||
pub route: RouteBudgetFacts,
|
||||
pub model_input_present: bool,
|
||||
pub router_present: bool,
|
||||
pub model_count: usize,
|
||||
#[serde(default)]
|
||||
pub cache_read: bool,
|
||||
pub prices: Vec<ModelPriceFacts>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct ExemptionDecision {
|
||||
pub read_pass_through: bool,
|
||||
pub read_price_cache: bool,
|
||||
pub read_model: Option<usize>,
|
||||
pub exempt: bool,
|
||||
}
|
||||
|
||||
pub fn exemption(facts: &ExemptionFacts) -> ExemptionDecision {
|
||||
let done = |exempt| ExemptionDecision {
|
||||
read_pass_through: false,
|
||||
read_price_cache: false,
|
||||
read_model: None,
|
||||
exempt,
|
||||
};
|
||||
match route(&facts.route) {
|
||||
RouteBudgetDecision::ReadPassThrough => {
|
||||
return ExemptionDecision {
|
||||
read_pass_through: true,
|
||||
..done(false)
|
||||
};
|
||||
}
|
||||
RouteBudgetDecision::Skip => return done(true),
|
||||
RouteBudgetDecision::ReadPricing => (),
|
||||
}
|
||||
if !facts.model_input_present || !facts.router_present {
|
||||
return done(false);
|
||||
}
|
||||
if !facts.cache_read {
|
||||
return ExemptionDecision {
|
||||
read_price_cache: true,
|
||||
..done(false)
|
||||
};
|
||||
}
|
||||
if facts.prices.iter().any(|facts| !price(facts).zero_cost) {
|
||||
return done(false);
|
||||
}
|
||||
if facts.prices.len() < facts.model_count {
|
||||
return ExemptionDecision {
|
||||
read_model: Some(facts.prices.len()),
|
||||
..done(false)
|
||||
};
|
||||
}
|
||||
done(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::zero(Some(0.0), true)]
|
||||
#[case::negative(Some(-1.0), true)]
|
||||
#[case::nan(Some(f64::NAN), true)]
|
||||
#[case::missing(None, false)]
|
||||
#[case::positive(Some(1.0), false)]
|
||||
fn preserves_group_comparison(#[case] input: Option<f64>, #[case] zero_cost: bool) {
|
||||
let facts = ModelPriceFacts {
|
||||
cached: None,
|
||||
lookup_failed: false,
|
||||
group_found: true,
|
||||
input_price: input,
|
||||
output_price: Some(0.0),
|
||||
deployments: Some(vec![DeploymentPrice {
|
||||
has_input_price: true,
|
||||
has_output_price: false,
|
||||
input_price: None,
|
||||
output_price: None,
|
||||
query_price: None,
|
||||
ptu_count: None,
|
||||
cost_per_ptu_per_hour: None,
|
||||
}]),
|
||||
};
|
||||
assert_eq!(
|
||||
price(&facts),
|
||||
PriceDecision {
|
||||
zero_cost,
|
||||
cache_value: Some(zero_cost),
|
||||
read_deployments: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
#[rstest]
|
||||
#[case::read_first(vec![], Some(0), false)]
|
||||
#[case::read_second(vec![true], Some(1), false)]
|
||||
#[case::priced_first(vec![false], None, false)]
|
||||
#[case::all_free(vec![true, true], None, true)]
|
||||
#[case::priced_second(vec![true, false], None, false)]
|
||||
fn exemption_reads_models_in_order(
|
||||
#[case] cached: Vec<bool>,
|
||||
#[case] read: Option<usize>,
|
||||
#[case] exempt: bool,
|
||||
) {
|
||||
let facts = ExemptionFacts {
|
||||
route: RouteBudgetFacts {
|
||||
side_effect_route: false,
|
||||
discovery_route: false,
|
||||
llm_route: true,
|
||||
route: "/chat/completions".to_owned(),
|
||||
method: None,
|
||||
body_present: false,
|
||||
registered_pass_through: None,
|
||||
},
|
||||
model_input_present: true,
|
||||
router_present: true,
|
||||
model_count: 2,
|
||||
cache_read: true,
|
||||
prices: cached
|
||||
.into_iter()
|
||||
.map(|value| ModelPriceFacts {
|
||||
cached: Some(value),
|
||||
lookup_failed: false,
|
||||
group_found: false,
|
||||
input_price: None,
|
||||
output_price: None,
|
||||
deployments: None,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
assert_eq!(
|
||||
exemption(&facts),
|
||||
ExemptionDecision {
|
||||
read_pass_through: false,
|
||||
read_price_cache: false,
|
||||
read_model: read,
|
||||
exempt
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
230
litellm-rust/crates/request-auth-core/src/common_budgets.rs
Normal file
230
litellm-rust/crates/request-auth-core/src/common_budgets.rs
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
use std::cmp::Ordering::{Equal, Greater, Less};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, strum::VariantArray)]
|
||||
pub enum Scope {
|
||||
Team,
|
||||
Key,
|
||||
TeamWindow,
|
||||
KeyWindow,
|
||||
Project,
|
||||
Organization,
|
||||
Tag,
|
||||
AccessGroup,
|
||||
User,
|
||||
TeamMember,
|
||||
EndUser,
|
||||
Proxy,
|
||||
KeySoft,
|
||||
TeamSoft,
|
||||
ProjectSoft,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct BudgetFacts {
|
||||
pub scope: Scope,
|
||||
pub limit: Option<f64>,
|
||||
pub spend: Option<f64>,
|
||||
pub token_present: bool,
|
||||
pub team_key: bool,
|
||||
pub apply_user_budget_to_team_keys: bool,
|
||||
pub info_route: bool,
|
||||
pub llm_route: bool,
|
||||
pub model_list_route: bool,
|
||||
pub skip: bool,
|
||||
pub alert_recipients: Vec<String>,
|
||||
pub throttle_percentage: Option<f64>,
|
||||
pub throttle_opt_in: bool,
|
||||
pub tpm_limit: Option<i64>,
|
||||
pub rpm_limit: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
pub struct BudgetDecision {
|
||||
pub read_spend: bool,
|
||||
pub denied: bool,
|
||||
pub alert: Option<&'static str>,
|
||||
pub throttle_percentage: Option<f64>,
|
||||
}
|
||||
|
||||
pub fn check(facts: &BudgetFacts) -> BudgetDecision {
|
||||
use Scope::*;
|
||||
let Some(limit) = facts.limit else {
|
||||
return BudgetDecision::default();
|
||||
};
|
||||
if matches!(facts.scope, User) && facts.team_key && !facts.apply_user_budget_to_team_keys
|
||||
|| matches!(facts.scope, EndUser) && facts.info_route
|
||||
|| matches!(facts.scope, Project) && !limit.is_finite()
|
||||
|| matches!(facts.scope, AccessGroup) && limit <= 0.0
|
||||
|| matches!(facts.scope, Proxy)
|
||||
&& (limit.partial_cmp(&0.0) != Some(Greater)
|
||||
|| facts.skip
|
||||
|| !facts.llm_route
|
||||
|| facts.model_list_route)
|
||||
{
|
||||
return BudgetDecision::default();
|
||||
}
|
||||
let soft = matches!(facts.scope, KeySoft | TeamSoft | ProjectSoft);
|
||||
let Some(spend) = facts.spend else {
|
||||
return BudgetDecision {
|
||||
read_spend: !soft && facts.scope != Proxy,
|
||||
..BudgetDecision::default()
|
||||
};
|
||||
};
|
||||
if soft {
|
||||
let alert = spend >= limit
|
||||
&& match facts.scope {
|
||||
KeySoft => limit != 0.0,
|
||||
TeamSoft => facts.token_present && !facts.alert_recipients.is_empty(),
|
||||
ProjectSoft => facts.token_present,
|
||||
_ => false,
|
||||
};
|
||||
return BudgetDecision {
|
||||
alert: alert.then_some("soft_budget"),
|
||||
..BudgetDecision::default()
|
||||
};
|
||||
}
|
||||
let exceeded = match facts.scope {
|
||||
Team | Proxy => limit.is_finite() && spend > limit,
|
||||
EndUser => spend > limit,
|
||||
Tag => !matches!(spend.partial_cmp(&limit), Some(Less | Equal)),
|
||||
AccessGroup => spend.partial_cmp(&limit) != Some(Less),
|
||||
_ => limit.is_finite() && spend >= limit,
|
||||
};
|
||||
let alert = match facts.scope {
|
||||
Key => Some("token_budget"),
|
||||
User => Some("user_budget"),
|
||||
Team if exceeded && facts.token_present => Some("team_budget"),
|
||||
Project if exceeded && facts.token_present => Some("project_budget"),
|
||||
Organization if exceeded => Some("organization_budget"),
|
||||
TeamMember if limit.is_finite() => Some("team_member_threshold"),
|
||||
_ => None,
|
||||
};
|
||||
let throttle_percentage = if exceeded
|
||||
&& facts.scope == Key
|
||||
&& facts.throttle_opt_in
|
||||
&& (facts.tpm_limit.is_some() || facts.rpm_limit.is_some())
|
||||
{
|
||||
facts
|
||||
.throttle_percentage
|
||||
.filter(|pct| *pct > 0.0 && *pct <= 1.0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
BudgetDecision {
|
||||
read_spend: false,
|
||||
denied: exceeded && throttle_percentage.is_none(),
|
||||
alert,
|
||||
throttle_percentage,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AlertFacts {
|
||||
pub spend: Option<f64>,
|
||||
pub max_budget: Option<f64>,
|
||||
pub configured: bool,
|
||||
pub minimum_percent: Option<f64>,
|
||||
pub default_ratio: f64,
|
||||
pub team_member: bool,
|
||||
}
|
||||
|
||||
pub fn alert(facts: &AlertFacts) -> bool {
|
||||
let (Some(spend), Some(limit)) = (facts.spend, facts.max_budget) else {
|
||||
return false;
|
||||
};
|
||||
if facts.team_member {
|
||||
if !facts.configured || spend <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
} else if spend.partial_cmp(&0.0) != Some(Greater) {
|
||||
return false;
|
||||
}
|
||||
if facts.configured {
|
||||
return facts
|
||||
.minimum_percent
|
||||
.is_some_and(|pct| spend.partial_cmp(&(limit * (pct / 100.0))) != Some(Less));
|
||||
}
|
||||
spend >= limit * facts.default_ratio && spend < limit
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct MemberLimitFacts {
|
||||
pub member_limit: Option<f64>,
|
||||
pub default_limit: Option<f64>,
|
||||
pub temporary_increase: Option<f64>,
|
||||
pub expires_at: Option<f64>,
|
||||
pub now: f64,
|
||||
#[serde(default)]
|
||||
pub now_naive: bool,
|
||||
}
|
||||
|
||||
pub fn member_limit(facts: &MemberLimitFacts) -> Result<Option<f64>, crate::Error> {
|
||||
let base = facts.member_limit.or_else(|| {
|
||||
facts
|
||||
.default_limit
|
||||
.filter(|limit| !matches!(limit.partial_cmp(&0.0), Some(Less | Equal)))
|
||||
});
|
||||
let Some(base) = base else {
|
||||
return Ok(None);
|
||||
};
|
||||
let increase = match (facts.temporary_increase, facts.expires_at) {
|
||||
(Some(_), Some(_)) if facts.now_naive => return Err(crate::Error::IncompatibleDateTimes),
|
||||
(Some(increase), Some(expiry)) if expiry > facts.now => increase,
|
||||
_ => 0.0,
|
||||
};
|
||||
Ok(Some(base + increase))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct WindowFacts {
|
||||
pub duration_seconds: Option<f64>,
|
||||
pub reset_at: Option<f64>,
|
||||
pub now: f64,
|
||||
}
|
||||
|
||||
pub fn window_start(facts: &WindowFacts) -> Option<f64> {
|
||||
facts
|
||||
.duration_seconds
|
||||
.map(|seconds| facts.reset_at.unwrap_or(facts.now) - seconds)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::team_equality(Scope::Team, 1.0, 1.0, false)]
|
||||
#[case::key_equality(Scope::Key, 1.0, 1.0, true)]
|
||||
#[case::tag_nan(Scope::Tag, f64::NAN, 1.0, true)]
|
||||
#[case::group_nan_spend(Scope::AccessGroup, 1.0, f64::NAN, true)]
|
||||
#[case::key_nan(Scope::Key, f64::NAN, 1.0, false)]
|
||||
#[case::end_user_infinity(Scope::EndUser, 1.0, f64::INFINITY, true)]
|
||||
fn boundary(
|
||||
#[case] scope: Scope,
|
||||
#[case] limit: f64,
|
||||
#[case] spend: f64,
|
||||
#[case] denied: bool,
|
||||
) {
|
||||
let facts = BudgetFacts {
|
||||
scope,
|
||||
limit: Some(limit),
|
||||
spend: Some(spend),
|
||||
token_present: false,
|
||||
team_key: false,
|
||||
apply_user_budget_to_team_keys: false,
|
||||
info_route: false,
|
||||
llm_route: true,
|
||||
model_list_route: false,
|
||||
skip: false,
|
||||
alert_recipients: vec![],
|
||||
throttle_percentage: None,
|
||||
throttle_opt_in: false,
|
||||
tpm_limit: None,
|
||||
rpm_limit: None,
|
||||
};
|
||||
assert_eq!(check(&facts).denied, denied);
|
||||
}
|
||||
}
|
||||
358
litellm-rust/crates/request-auth-core/src/common_flow.rs
Normal file
358
litellm-rust/crates/request-auth-core/src/common_flow.rs
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize, strum::VariantArray)]
|
||||
pub enum ModelBudgetScope {
|
||||
Key,
|
||||
User,
|
||||
Member,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum ReadFacts {
|
||||
ModelBudgets {
|
||||
scope: ModelBudgetScope,
|
||||
entity_present: Option<bool>,
|
||||
budget_present: Option<bool>,
|
||||
mapping: Option<bool>,
|
||||
team_present: Option<bool>,
|
||||
},
|
||||
FallbackAllowed {
|
||||
router: bool,
|
||||
disabled: Option<bool>,
|
||||
},
|
||||
BudgetSkip {
|
||||
skip: bool,
|
||||
exempt: Option<bool>,
|
||||
},
|
||||
Pricing {
|
||||
enabled: bool,
|
||||
llm_route: Option<bool>,
|
||||
},
|
||||
Unpriced {
|
||||
count: usize,
|
||||
},
|
||||
MemberFallback {
|
||||
router: bool,
|
||||
multipart: Option<bool>,
|
||||
resource_bound: Option<bool>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, strum::VariantArray)]
|
||||
pub enum ReadPlan {
|
||||
CheckModelBudgets,
|
||||
ReadModelBudgetPresence,
|
||||
ReadModelBudgetShape,
|
||||
ReadModelBudgetToken,
|
||||
ReadModelBudgetUser,
|
||||
ReadModelBudgetTeam,
|
||||
AllowFallback,
|
||||
ReadFallbackDisabled,
|
||||
ReadExemption,
|
||||
SkipBudgets,
|
||||
CheckBudgets,
|
||||
ReadRoute,
|
||||
ReadUnpriced,
|
||||
RejectUnpriced,
|
||||
ReadContentType,
|
||||
ReadResourceBinding,
|
||||
ReadFallbackSettings,
|
||||
Continue,
|
||||
}
|
||||
|
||||
pub fn read(facts: ReadFacts) -> ReadPlan {
|
||||
match facts {
|
||||
ReadFacts::ModelBudgets {
|
||||
scope,
|
||||
entity_present,
|
||||
budget_present,
|
||||
mapping,
|
||||
team_present,
|
||||
} => model_budgets(scope, entity_present, budget_present, mapping, team_present),
|
||||
ReadFacts::FallbackAllowed {
|
||||
router: true,
|
||||
disabled: Some(false),
|
||||
} => ReadPlan::AllowFallback,
|
||||
ReadFacts::FallbackAllowed {
|
||||
router: true,
|
||||
disabled: None,
|
||||
} => ReadPlan::ReadFallbackDisabled,
|
||||
ReadFacts::FallbackAllowed { .. } => ReadPlan::Continue,
|
||||
ReadFacts::BudgetSkip { skip: true, .. }
|
||||
| ReadFacts::BudgetSkip {
|
||||
exempt: Some(true), ..
|
||||
} => ReadPlan::SkipBudgets,
|
||||
ReadFacts::BudgetSkip {
|
||||
exempt: Some(false),
|
||||
..
|
||||
} => ReadPlan::CheckBudgets,
|
||||
ReadFacts::BudgetSkip { exempt: None, .. } => ReadPlan::ReadExemption,
|
||||
ReadFacts::Pricing { enabled: false, .. }
|
||||
| ReadFacts::Pricing {
|
||||
llm_route: Some(false),
|
||||
..
|
||||
} => ReadPlan::Continue,
|
||||
ReadFacts::Pricing {
|
||||
llm_route: None, ..
|
||||
} => ReadPlan::ReadRoute,
|
||||
ReadFacts::Pricing {
|
||||
llm_route: Some(true),
|
||||
..
|
||||
} => ReadPlan::ReadUnpriced,
|
||||
ReadFacts::Unpriced { count: 0 } => ReadPlan::Continue,
|
||||
ReadFacts::Unpriced { .. } => ReadPlan::RejectUnpriced,
|
||||
ReadFacts::MemberFallback { router: false, .. }
|
||||
| ReadFacts::MemberFallback {
|
||||
multipart: Some(true),
|
||||
..
|
||||
}
|
||||
| ReadFacts::MemberFallback {
|
||||
resource_bound: Some(true),
|
||||
..
|
||||
} => ReadPlan::Continue,
|
||||
ReadFacts::MemberFallback {
|
||||
multipart: None, ..
|
||||
} => ReadPlan::ReadContentType,
|
||||
ReadFacts::MemberFallback {
|
||||
resource_bound: None,
|
||||
..
|
||||
} => ReadPlan::ReadResourceBinding,
|
||||
ReadFacts::MemberFallback { .. } => ReadPlan::ReadFallbackSettings,
|
||||
}
|
||||
}
|
||||
|
||||
fn model_budgets(
|
||||
scope: ModelBudgetScope,
|
||||
entity: Option<bool>,
|
||||
budget: Option<bool>,
|
||||
mapping: Option<bool>,
|
||||
team: Option<bool>,
|
||||
) -> ReadPlan {
|
||||
use ReadPlan as P;
|
||||
let stages = match scope {
|
||||
ModelBudgetScope::Key => vec![
|
||||
(budget, P::ReadModelBudgetPresence),
|
||||
(entity, P::ReadModelBudgetToken),
|
||||
],
|
||||
ModelBudgetScope::User => vec![
|
||||
(entity, P::ReadModelBudgetUser),
|
||||
(mapping, P::ReadModelBudgetShape),
|
||||
(budget, P::ReadModelBudgetPresence),
|
||||
],
|
||||
ModelBudgetScope::Member => vec![
|
||||
(entity, P::ReadModelBudgetUser),
|
||||
(team, P::ReadModelBudgetTeam),
|
||||
(mapping, P::ReadModelBudgetShape),
|
||||
(budget, P::ReadModelBudgetPresence),
|
||||
],
|
||||
};
|
||||
for (present, missing) in stages {
|
||||
match present {
|
||||
None => return missing,
|
||||
Some(false) => return P::Continue,
|
||||
Some(true) => {}
|
||||
}
|
||||
}
|
||||
P::CheckModelBudgets
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Facts {
|
||||
pub model_present: bool,
|
||||
pub skip_budgets: bool,
|
||||
pub team_present: bool,
|
||||
pub user_present: bool,
|
||||
pub token_present: bool,
|
||||
pub user_id: Option<String>,
|
||||
pub agent_id: Option<String>,
|
||||
pub customer_present: bool,
|
||||
pub customer_models: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct Plan {
|
||||
pub membership: bool,
|
||||
pub team_models: bool,
|
||||
pub member_models: bool,
|
||||
pub user_models: bool,
|
||||
pub customer_models: bool,
|
||||
pub agent_trace: bool,
|
||||
}
|
||||
|
||||
pub fn plan(facts: Facts) -> Plan {
|
||||
Plan {
|
||||
membership: facts.token_present
|
||||
&& (facts.model_present || !facts.skip_budgets)
|
||||
&& facts.team_present
|
||||
&& facts.user_id.is_some(),
|
||||
team_models: facts.model_present && facts.team_present,
|
||||
member_models: facts.model_present
|
||||
&& facts.team_present
|
||||
&& facts.token_present
|
||||
&& facts.user_id.is_some_and(|id| !id.is_empty()),
|
||||
user_models: facts.model_present && !facts.team_present && facts.user_present,
|
||||
customer_models: facts.customer_present && !facts.customer_models.is_empty(),
|
||||
agent_trace: facts.token_present && facts.agent_id.is_some_and(|id| !id.is_empty()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BudgetFacts {
|
||||
pub token_present: bool,
|
||||
pub group_count: usize,
|
||||
pub customer_present: bool,
|
||||
pub customer_budget_present: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, strum::VariantArray)]
|
||||
pub enum BudgetScope {
|
||||
Team,
|
||||
TeamWindows,
|
||||
KeyWindows,
|
||||
TeamSoft,
|
||||
Organization,
|
||||
Tags,
|
||||
Groups,
|
||||
User,
|
||||
Member,
|
||||
Customer,
|
||||
}
|
||||
|
||||
pub fn budgets(facts: BudgetFacts) -> Vec<BudgetScope> {
|
||||
use BudgetScope as S;
|
||||
[
|
||||
Some(S::Team),
|
||||
Some(S::TeamWindows),
|
||||
facts.token_present.then_some(S::KeyWindows),
|
||||
Some(S::TeamSoft),
|
||||
Some(S::Organization),
|
||||
Some(S::Tags),
|
||||
(facts.group_count > 0).then_some(S::Groups),
|
||||
Some(S::User),
|
||||
Some(S::Member),
|
||||
(facts.customer_present && facts.customer_budget_present).then_some(S::Customer),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CustomFacts {
|
||||
pub end_user_id: Option<String>,
|
||||
pub end_user_present: bool,
|
||||
pub common_checks: bool,
|
||||
pub model_present: bool,
|
||||
pub router_present: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct CustomPlan {
|
||||
pub read_end_user: bool,
|
||||
pub check_end_user: bool,
|
||||
pub check_model_access: bool,
|
||||
pub read_pricing: bool,
|
||||
}
|
||||
|
||||
pub fn custom(facts: CustomFacts) -> CustomPlan {
|
||||
CustomPlan {
|
||||
read_end_user: facts.end_user_id.is_some(),
|
||||
check_end_user: facts.end_user_present && !facts.common_checks,
|
||||
check_model_access: facts.common_checks,
|
||||
read_pricing: facts.model_present && facts.router_present,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::available(true, true, true, true, ReadPlan::CheckModelBudgets)]
|
||||
#[case::missing_identity(false, true, true, true, ReadPlan::Continue)]
|
||||
#[case::empty_budget(true, false, true, true, ReadPlan::Continue)]
|
||||
#[case::malformed_member_budget(true, true, false, true, ReadPlan::Continue)]
|
||||
#[case::no_team(true, true, true, false, ReadPlan::Continue)]
|
||||
fn model_budget_availability(
|
||||
#[case] entity_present: bool,
|
||||
#[case] budget_present: bool,
|
||||
#[case] mapping: bool,
|
||||
#[case] team_present: bool,
|
||||
#[case] expected: ReadPlan,
|
||||
) {
|
||||
assert_eq!(
|
||||
read(ReadFacts::ModelBudgets {
|
||||
scope: ModelBudgetScope::Member,
|
||||
entity_present: Some(entity_present),
|
||||
budget_present: Some(budget_present),
|
||||
mapping: Some(mapping),
|
||||
team_present: Some(team_present)
|
||||
}),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::explicit_skip(ReadFacts::BudgetSkip {skip:true, exempt:None}, ReadPlan::SkipBudgets)]
|
||||
#[case::exempt(ReadFacts::BudgetSkip {skip:false, exempt:Some(true)}, ReadPlan::SkipBudgets)]
|
||||
#[case::check(ReadFacts::BudgetSkip {skip:false, exempt:Some(false)}, ReadPlan::CheckBudgets)]
|
||||
#[case::read_exemption(ReadFacts::BudgetSkip {skip:false, exempt:None}, ReadPlan::ReadExemption)]
|
||||
#[case::pricing_disabled(ReadFacts::Pricing {enabled:false, llm_route:None}, ReadPlan::Continue)]
|
||||
#[case::pricing_route(ReadFacts::Pricing {enabled:true, llm_route:None}, ReadPlan::ReadRoute)]
|
||||
#[case::pricing_skip(ReadFacts::Pricing {enabled:true, llm_route:Some(false)}, ReadPlan::Continue)]
|
||||
#[case::pricing_check(ReadFacts::Pricing {enabled:true, llm_route:Some(true)}, ReadPlan::ReadUnpriced)]
|
||||
#[case::priced(ReadFacts::Unpriced {count:0}, ReadPlan::Continue)]
|
||||
#[case::unpriced(ReadFacts::Unpriced {count:1}, ReadPlan::RejectUnpriced)]
|
||||
#[case::no_router(ReadFacts::MemberFallback {router:false, multipart:None, resource_bound:None}, ReadPlan::Continue)]
|
||||
#[case::content_type(ReadFacts::MemberFallback {router:true, multipart:None, resource_bound:None}, ReadPlan::ReadContentType)]
|
||||
#[case::multipart(ReadFacts::MemberFallback {router:true, multipart:Some(true), resource_bound:None}, ReadPlan::Continue)]
|
||||
#[case::resource_read(ReadFacts::MemberFallback {router:true, multipart:Some(false), resource_bound:None}, ReadPlan::ReadResourceBinding)]
|
||||
#[case::bound_resource(ReadFacts::MemberFallback {router:true, multipart:Some(false), resource_bound:Some(true)}, ReadPlan::Continue)]
|
||||
#[case::settings(ReadFacts::MemberFallback {router:true, multipart:Some(false), resource_bound:Some(false)}, ReadPlan::ReadFallbackSettings)]
|
||||
fn selected_reads(#[case] facts: ReadFacts, #[case] expected: ReadPlan) {
|
||||
assert_eq!(read(facts), expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::absent(None, false, false)]
|
||||
#[case::empty(Some(""), true, false)]
|
||||
#[case::user(Some("user"), true, true)]
|
||||
fn member_identity(#[case] user: Option<&str>, #[case] membership: bool, #[case] models: bool) {
|
||||
let actual = plan(Facts {
|
||||
model_present: true,
|
||||
skip_budgets: false,
|
||||
team_present: true,
|
||||
user_present: false,
|
||||
token_present: true,
|
||||
user_id: user.map(str::to_owned),
|
||||
agent_id: None,
|
||||
customer_present: false,
|
||||
customer_models: vec![],
|
||||
});
|
||||
assert_eq!(actual.membership, membership);
|
||||
assert_eq!(actual.member_models, models);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::empty(false, 0, false, false, vec![BudgetScope::Team, BudgetScope::TeamWindows, BudgetScope::TeamSoft, BudgetScope::Organization, BudgetScope::Tags, BudgetScope::User, BudgetScope::Member])]
|
||||
#[case::all(true, 1, true, true, vec![BudgetScope::Team, BudgetScope::TeamWindows, BudgetScope::KeyWindows, BudgetScope::TeamSoft, BudgetScope::Organization, BudgetScope::Tags, BudgetScope::Groups, BudgetScope::User, BudgetScope::Member, BudgetScope::Customer])]
|
||||
fn budget_order(
|
||||
#[case] token_present: bool,
|
||||
#[case] group_count: usize,
|
||||
#[case] customer_present: bool,
|
||||
#[case] customer_budget_present: bool,
|
||||
#[case] expected: Vec<BudgetScope>,
|
||||
) {
|
||||
assert_eq!(
|
||||
budgets(BudgetFacts {
|
||||
token_present,
|
||||
group_count,
|
||||
customer_present,
|
||||
customer_budget_present
|
||||
}),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
428
litellm-rust/crates/request-auth-core/src/end_user_context.rs
Normal file
428
litellm-rust/crates/request-auth-core/src/end_user_context.rs
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use strum::VariantArray;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct IdFacts {
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
pub fn normalize_id(facts: &IdFacts) -> Option<String> {
|
||||
facts.value.clone().filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct DefaultFacts {
|
||||
pub prisma_present: bool,
|
||||
pub explicit_id: Option<String>,
|
||||
pub global_read: bool,
|
||||
pub global_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct DefaultDecision {
|
||||
pub read_global: bool,
|
||||
pub budget_id: Option<String>,
|
||||
}
|
||||
|
||||
pub fn default_id(facts: &DefaultFacts) -> DefaultDecision {
|
||||
if facts.explicit_id.is_none() && !facts.global_read {
|
||||
return DefaultDecision {
|
||||
read_global: true,
|
||||
budget_id: None,
|
||||
};
|
||||
}
|
||||
if !facts.prisma_present {
|
||||
return DefaultDecision {
|
||||
read_global: false,
|
||||
budget_id: None,
|
||||
};
|
||||
}
|
||||
DefaultDecision {
|
||||
read_global: false,
|
||||
budget_id: facts
|
||||
.explicit_id
|
||||
.clone()
|
||||
.or_else(|| facts.global_id.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct ApplyFacts {
|
||||
pub own_budget_id: Option<String>,
|
||||
pub own_budget_present: bool,
|
||||
pub key_id: Option<String>,
|
||||
pub global_read: bool,
|
||||
pub global_id: Option<String>,
|
||||
pub default_read: bool,
|
||||
pub default_present: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, VariantArray)]
|
||||
pub enum ApplyDecision {
|
||||
Keep,
|
||||
ReadGlobal,
|
||||
ReadDefault,
|
||||
Copy,
|
||||
}
|
||||
|
||||
pub fn apply_default(facts: &ApplyFacts) -> ApplyDecision {
|
||||
if facts.own_budget_id.is_some() && facts.own_budget_present {
|
||||
return ApplyDecision::Keep;
|
||||
}
|
||||
if facts.key_id.is_none() {
|
||||
if !facts.global_read {
|
||||
return ApplyDecision::ReadGlobal;
|
||||
}
|
||||
if facts.global_id.is_none() {
|
||||
return ApplyDecision::Keep;
|
||||
}
|
||||
}
|
||||
if !facts.default_read {
|
||||
return ApplyDecision::ReadDefault;
|
||||
}
|
||||
if facts.default_present {
|
||||
ApplyDecision::Copy
|
||||
} else {
|
||||
ApplyDecision::Keep
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct RegistryFacts {
|
||||
pub end_user_id: String,
|
||||
pub global_id: Option<String>,
|
||||
pub key_id: Option<String>,
|
||||
pub validate_id: bool,
|
||||
pub token_max_present: bool,
|
||||
pub registry_read: bool,
|
||||
pub registry: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, VariantArray)]
|
||||
pub enum RegistryDecision {
|
||||
Read,
|
||||
Restricted,
|
||||
Unrestricted,
|
||||
}
|
||||
|
||||
pub fn registry(facts: &RegistryFacts) -> RegistryDecision {
|
||||
if facts.global_id.is_some()
|
||||
|| facts.key_id.is_some()
|
||||
|| facts.validate_id
|
||||
|| facts.token_max_present
|
||||
{
|
||||
return RegistryDecision::Restricted;
|
||||
}
|
||||
if !facts.registry_read {
|
||||
return RegistryDecision::Read;
|
||||
}
|
||||
if facts
|
||||
.registry
|
||||
.as_ref()
|
||||
.is_some_and(|ids| !ids.contains(&facts.end_user_id))
|
||||
{
|
||||
RegistryDecision::Unrestricted
|
||||
} else {
|
||||
RegistryDecision::Restricted
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, VariantArray)]
|
||||
pub enum Field {
|
||||
EndUserId,
|
||||
Tpm,
|
||||
Rpm,
|
||||
Tpd,
|
||||
MaxBudget,
|
||||
Models,
|
||||
Region,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct Fields {
|
||||
pub tpm: bool,
|
||||
pub rpm: bool,
|
||||
pub tpd: bool,
|
||||
pub max_budget: bool,
|
||||
pub models: bool,
|
||||
pub region: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, VariantArray)]
|
||||
pub enum AssignmentMode {
|
||||
Params,
|
||||
Token,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct AssignmentFacts {
|
||||
pub mode: AssignmentMode,
|
||||
pub fields: Fields,
|
||||
}
|
||||
|
||||
pub fn assignments(facts: &AssignmentFacts) -> Vec<Field> {
|
||||
let fields = &facts.fields;
|
||||
match facts.mode {
|
||||
AssignmentMode::Params => [
|
||||
(Field::Tpm, fields.tpm),
|
||||
(Field::Rpm, fields.rpm),
|
||||
(Field::Tpd, fields.tpd),
|
||||
(Field::MaxBudget, fields.max_budget),
|
||||
(Field::Models, fields.models),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(field, present)| present.then_some(field))
|
||||
.collect(),
|
||||
AssignmentMode::Token => [
|
||||
(Field::EndUserId, true),
|
||||
(Field::Tpm, fields.tpm),
|
||||
(Field::Rpm, fields.rpm),
|
||||
(Field::Tpd, fields.tpd),
|
||||
(Field::Region, fields.region),
|
||||
(Field::Models, fields.models),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(field, present)| present.then_some(field))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct LookupFacts {
|
||||
pub own_budget_present: bool,
|
||||
pub key_id: Option<String>,
|
||||
pub end_user_read: bool,
|
||||
pub end_user_present: bool,
|
||||
pub global_read: bool,
|
||||
pub global_id: Option<String>,
|
||||
pub default_read: bool,
|
||||
pub default_present: bool,
|
||||
pub token_max_present: bool,
|
||||
pub applied: bool,
|
||||
pub read_failed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, VariantArray)]
|
||||
pub enum LookupAction {
|
||||
ReadEndUser,
|
||||
ReadGlobal,
|
||||
ReadDefault,
|
||||
ApplyOwn,
|
||||
ApplyDefault,
|
||||
Done,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct LookupDecision {
|
||||
pub action: LookupAction,
|
||||
pub set_max_budget: bool,
|
||||
pub project_budget: bool,
|
||||
}
|
||||
|
||||
pub fn lookup(facts: &LookupFacts) -> LookupDecision {
|
||||
let action = if facts.read_failed || facts.applied {
|
||||
LookupAction::Done
|
||||
} else if !facts.end_user_read {
|
||||
LookupAction::ReadEndUser
|
||||
} else if facts.end_user_present {
|
||||
LookupAction::ApplyOwn
|
||||
} else if facts.key_id.is_none() && !facts.global_read {
|
||||
LookupAction::ReadGlobal
|
||||
} else if facts.key_id.is_none() && facts.global_id.is_none() {
|
||||
LookupAction::Done
|
||||
} else if !facts.default_read {
|
||||
LookupAction::ReadDefault
|
||||
} else if facts.default_present {
|
||||
LookupAction::ApplyDefault
|
||||
} else {
|
||||
LookupAction::Done
|
||||
};
|
||||
LookupDecision {
|
||||
project_budget: action == LookupAction::ApplyDefault
|
||||
|| (action == LookupAction::ApplyOwn && facts.own_budget_present),
|
||||
set_max_budget: facts.applied
|
||||
&& !facts.read_failed
|
||||
&& !facts.end_user_present
|
||||
&& facts.default_present
|
||||
&& !facts.token_max_present,
|
||||
action,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize, VariantArray)]
|
||||
pub enum ValidationMode {
|
||||
#[default]
|
||||
Resolve,
|
||||
Exists,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct ValidationFacts {
|
||||
pub mode: ValidationMode,
|
||||
pub end_user_id: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub prisma_present: bool,
|
||||
pub global_id: Option<String>,
|
||||
pub key_id: Option<String>,
|
||||
pub cache_read: bool,
|
||||
pub cached: Option<String>,
|
||||
pub end_user_read: bool,
|
||||
pub end_user_present: bool,
|
||||
pub user_read: bool,
|
||||
pub user_present: bool,
|
||||
pub cache_written: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, VariantArray)]
|
||||
pub enum ValidationRead {
|
||||
Cache,
|
||||
EndUser,
|
||||
User,
|
||||
WriteCache,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct ValidationDecision {
|
||||
pub read: Option<ValidationRead>,
|
||||
pub end_user_id: Option<String>,
|
||||
pub valid: bool,
|
||||
pub cache_value: Option<String>,
|
||||
pub cache_ttl: u64,
|
||||
pub user_email: Option<String>,
|
||||
}
|
||||
|
||||
fn validation_result(facts: &ValidationFacts, valid: bool) -> ValidationDecision {
|
||||
let retain = valid
|
||||
|| facts.key_id.is_some()
|
||||
|| facts.global_id.as_ref().is_some_and(|id| !id.is_empty());
|
||||
ValidationDecision {
|
||||
end_user_id: facts.end_user_id.clone().filter(|_| retain),
|
||||
valid,
|
||||
..ValidationDecision::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validation(facts: &ValidationFacts) -> ValidationDecision {
|
||||
let Some(id) = &facts.end_user_id else {
|
||||
return ValidationDecision::default();
|
||||
};
|
||||
if facts.mode == ValidationMode::Resolve {
|
||||
if !facts.enabled || !facts.prisma_present {
|
||||
return ValidationDecision {
|
||||
end_user_id: Some(id.clone()),
|
||||
..ValidationDecision::default()
|
||||
};
|
||||
}
|
||||
if !facts.cache_read {
|
||||
return ValidationDecision {
|
||||
read: Some(ValidationRead::Cache),
|
||||
..ValidationDecision::default()
|
||||
};
|
||||
}
|
||||
match facts.cached.as_deref() {
|
||||
Some("valid") => return validation_result(facts, true),
|
||||
Some("invalid") => return validation_result(facts, false),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !facts.end_user_read {
|
||||
return ValidationDecision {
|
||||
read: Some(ValidationRead::EndUser),
|
||||
..ValidationDecision::default()
|
||||
};
|
||||
}
|
||||
if !facts.end_user_present && !facts.user_read {
|
||||
return ValidationDecision {
|
||||
read: Some(ValidationRead::User),
|
||||
user_email: id.contains('@').then(|| id.clone()),
|
||||
..ValidationDecision::default()
|
||||
};
|
||||
}
|
||||
let valid = facts.end_user_present || facts.user_present;
|
||||
if facts.mode == ValidationMode::Resolve && !facts.cache_written {
|
||||
return ValidationDecision {
|
||||
read: Some(ValidationRead::WriteCache),
|
||||
valid,
|
||||
cache_value: Some(if valid { "valid" } else { "invalid" }.to_owned()),
|
||||
cache_ttl: if valid { 300 } else { 60 },
|
||||
..ValidationDecision::default()
|
||||
};
|
||||
}
|
||||
validation_result(facts, valid)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::absent(None, None)]
|
||||
#[case::empty(Some(""), None)]
|
||||
#[case::space(Some(" "), Some(" "))]
|
||||
#[case::id(Some("budget"), Some("budget"))]
|
||||
fn normalizes_metadata_id(#[case] value: Option<&str>, #[case] expected: Option<&str>) {
|
||||
assert_eq!(
|
||||
normalize_id(&IdFacts {
|
||||
value: value.map(str::to_owned)
|
||||
})
|
||||
.as_deref(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::explicit(Some("key"), Some("global"), Some("key"))]
|
||||
#[case::empty_explicit(Some(""), Some("global"), Some(""))]
|
||||
#[case::global(None, Some("global"), Some("global"))]
|
||||
#[case::absent(None, None, None)]
|
||||
fn selects_default(
|
||||
#[case] explicit: Option<&str>,
|
||||
#[case] global: Option<&str>,
|
||||
#[case] expected: Option<&str>,
|
||||
) {
|
||||
let facts = DefaultFacts {
|
||||
prisma_present: true,
|
||||
explicit_id: explicit.map(str::to_owned),
|
||||
global_read: true,
|
||||
global_id: global.map(str::to_owned),
|
||||
};
|
||||
assert_eq!(default_id(&facts).budget_id.as_deref(), expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::none(None, None, None)]
|
||||
#[case::empty_global(Some(""), None, None)]
|
||||
#[case::global(Some("budget"), None, Some("missing"))]
|
||||
#[case::key(None, Some(""), Some("missing"))]
|
||||
fn retains_unknown_ids(
|
||||
#[case] global: Option<&str>,
|
||||
#[case] key: Option<&str>,
|
||||
#[case] expected: Option<&str>,
|
||||
) {
|
||||
let facts = ValidationFacts {
|
||||
end_user_id: Some("missing".to_owned()),
|
||||
enabled: true,
|
||||
prisma_present: true,
|
||||
global_id: global.map(str::to_owned),
|
||||
key_id: key.map(str::to_owned),
|
||||
cache_read: true,
|
||||
cached: Some("invalid".to_owned()),
|
||||
..ValidationFacts::default()
|
||||
};
|
||||
assert_eq!(validation(&facts).end_user_id.as_deref(), expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::missing(AssignmentMode::Params, vec![])]
|
||||
#[case::token_identity(AssignmentMode::Token, vec![Field::EndUserId])]
|
||||
fn absent_fields(#[case] mode: AssignmentMode, #[case] expected: Vec<Field>) {
|
||||
assert_eq!(
|
||||
assignments(&AssignmentFacts {
|
||||
mode,
|
||||
fields: Fields::default()
|
||||
}),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("invalid auth pattern")]
|
||||
Pattern(#[source] fancy_regex::Error),
|
||||
#[error("invalid auth effect answer")]
|
||||
InvalidAnswer,
|
||||
#[error("can't compare offset-naive and offset-aware datetimes")]
|
||||
|
|
|
|||
145
litellm-rust/crates/request-auth-core/src/fallback_access.rs
Normal file
145
litellm-rust/crates/request-auth-core/src/fallback_access.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
Error,
|
||||
model_access::{self, Decision as ModelDecision, Facts as ModelFacts, Request, Response},
|
||||
};
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct GroupedScope {
|
||||
pub facts: ModelFacts,
|
||||
pub group_ids: Vec<String>,
|
||||
pub group_models: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Facts {
|
||||
pub refuse_agent_keys: bool,
|
||||
pub agent_id: Option<String>,
|
||||
pub key: Option<GroupedScope>,
|
||||
pub team: Option<GroupedScope>,
|
||||
pub embedded_team: Option<ModelFacts>,
|
||||
pub assigned_models: Option<Vec<String>>,
|
||||
pub assigned_scope: Option<ModelFacts>,
|
||||
pub member: Option<ModelFacts>,
|
||||
pub project: Option<ModelFacts>,
|
||||
pub team_present: bool,
|
||||
pub embedded_team_grants: Vec<String>,
|
||||
pub member_grants: Vec<String>,
|
||||
pub project_grants: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Decision {
|
||||
Allow,
|
||||
Deny { denial: Option<ModelDecision> },
|
||||
ReadKey,
|
||||
ReadTeam,
|
||||
ReadEmbeddedTeam,
|
||||
ReadMember,
|
||||
ReadProject,
|
||||
ReadKeyGroups,
|
||||
ReadTeamGroups,
|
||||
ReadAssignedGroups,
|
||||
ReadAssignedScope,
|
||||
}
|
||||
|
||||
pub fn grouped(scope: GroupedScope, merge: bool) -> Result<ModelDecision, Error> {
|
||||
let Response::Decision(decision) = model_access::decide(Request::Grouped {
|
||||
facts: scope.facts,
|
||||
group_ids: scope.group_ids,
|
||||
group_models: scope.group_models,
|
||||
merge,
|
||||
})?
|
||||
else {
|
||||
return Err(Error::InvalidAnswer);
|
||||
};
|
||||
Ok(decision)
|
||||
}
|
||||
|
||||
pub fn decide(facts: Facts) -> Result<Decision, Error> {
|
||||
if facts.refuse_agent_keys && facts.agent_id.is_some() {
|
||||
return Ok(Decision::Deny { denial: None });
|
||||
}
|
||||
let Some(key) = facts.key else {
|
||||
return Ok(Decision::ReadKey);
|
||||
};
|
||||
let key = grouped(key, false)?;
|
||||
if matches!(
|
||||
key,
|
||||
ModelDecision::ReadGroups | ModelDecision::ReadGroupScope { .. }
|
||||
) {
|
||||
return Ok(Decision::ReadKeyGroups);
|
||||
}
|
||||
if !key.allowed() {
|
||||
return Ok(Decision::Deny { denial: Some(key) });
|
||||
}
|
||||
if facts.team_present && facts.team.is_none() {
|
||||
return Ok(Decision::ReadTeam);
|
||||
}
|
||||
if let Some(team) = facts.team {
|
||||
let decision = grouped(team, true)?;
|
||||
if matches!(
|
||||
decision,
|
||||
ModelDecision::ReadGroups | ModelDecision::ReadGroupScope { .. }
|
||||
) {
|
||||
return Ok(Decision::ReadTeamGroups);
|
||||
}
|
||||
if !decision.allowed() {
|
||||
if !matches!(decision, ModelDecision::Deny { .. }) {
|
||||
return Ok(Decision::Deny {
|
||||
denial: Some(decision),
|
||||
});
|
||||
}
|
||||
let Some(grants) = facts.assigned_models else {
|
||||
return Ok(Decision::ReadAssignedGroups);
|
||||
};
|
||||
if grants.is_empty() {
|
||||
return Ok(Decision::Deny {
|
||||
denial: Some(decision),
|
||||
});
|
||||
}
|
||||
let Some(assigned) = facts.assigned_scope else {
|
||||
return Ok(Decision::ReadAssignedScope);
|
||||
};
|
||||
if !model_access::check(&assigned)?.allowed() {
|
||||
return Ok(Decision::Deny {
|
||||
denial: Some(decision),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if facts.embedded_team.is_none() && !facts.embedded_team_grants.is_empty() {
|
||||
return Ok(Decision::ReadEmbeddedTeam);
|
||||
} else if let Some(team) = facts.embedded_team {
|
||||
let decision = model_access::check(&team)?;
|
||||
if !decision.allowed() {
|
||||
return Ok(Decision::Deny {
|
||||
denial: Some(decision),
|
||||
});
|
||||
}
|
||||
}
|
||||
if facts.member.is_none() && !facts.member_grants.is_empty() {
|
||||
return Ok(Decision::ReadMember);
|
||||
}
|
||||
if let Some(scope) = facts.member {
|
||||
let decision = model_access::check(&scope)?;
|
||||
if !decision.allowed() {
|
||||
return Ok(Decision::Deny {
|
||||
denial: Some(decision),
|
||||
});
|
||||
}
|
||||
}
|
||||
if facts.project.is_none() && !facts.project_grants.is_empty() {
|
||||
return Ok(Decision::ReadProject);
|
||||
}
|
||||
if let Some(scope) = facts.project {
|
||||
let decision = model_access::check(&scope)?;
|
||||
if !decision.allowed() {
|
||||
return Ok(Decision::Deny {
|
||||
denial: Some(decision),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Decision::Allow)
|
||||
}
|
||||
322
litellm-rust/crates/request-auth-core/src/fallback_candidate.rs
Normal file
322
litellm-rust/crates/request-auth-core/src/fallback_candidate.rs
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use strum::VariantArray;
|
||||
|
||||
use crate::{
|
||||
Error, budget_pricing, common_budgets, fallback_access, model_access, model_attribution,
|
||||
model_budget_data, route_access, unpriced_models,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, strum::VariantArray)]
|
||||
pub enum Entity {
|
||||
Key,
|
||||
User,
|
||||
EndUser,
|
||||
Team,
|
||||
TeamMember,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, Deserialize, PartialEq, Eq, strum::VariantArray)]
|
||||
pub enum Membership {
|
||||
#[default]
|
||||
Unread,
|
||||
Loaded,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct BudgetScope {
|
||||
pub entity: Entity,
|
||||
pub identity_present: bool,
|
||||
pub configured: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct BudgetRead {
|
||||
pub model_index: usize,
|
||||
pub entity: Entity,
|
||||
pub snapshot: model_budget_data::Snapshot,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
pub struct Facts {
|
||||
pub router_present: bool,
|
||||
pub user_present: bool,
|
||||
pub team_present: bool,
|
||||
pub membership: Membership,
|
||||
pub block_unpriced: bool,
|
||||
pub route: Option<route_access::RouteContext>,
|
||||
pub unpriced: Option<unpriced_models::Facts>,
|
||||
pub customer_present: bool,
|
||||
pub customer_grants: Option<Vec<String>>,
|
||||
pub customer: Option<model_access::Facts>,
|
||||
pub price: Option<budget_pricing::ModelPriceFacts>,
|
||||
pub budget_models: Option<Vec<String>>,
|
||||
pub budget_scopes: Option<Vec<BudgetScope>>,
|
||||
pub budgets: Vec<BudgetRead>,
|
||||
pub authorization: Option<fallback_access::Facts>,
|
||||
pub attribution_reset: bool,
|
||||
pub attribution: Option<model_attribution::Facts>,
|
||||
pub attribution_stamped: bool,
|
||||
pub group_budgets_read: bool,
|
||||
pub group_budgets: Vec<common_budgets::BudgetFacts>,
|
||||
pub io_failed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Decision {
|
||||
Complete { usable: bool, log_failure: bool },
|
||||
ReadMembership,
|
||||
ReadRoute,
|
||||
ReadUnpriced,
|
||||
ReadCustomerGrants,
|
||||
ReadCustomer,
|
||||
ReadPrice,
|
||||
ReadBudgetModels,
|
||||
ReadBudgetScopes,
|
||||
ReadBudget { model_index: usize, entity: Entity },
|
||||
ReadAuthorization,
|
||||
ResetAttribution,
|
||||
ReadAttribution,
|
||||
StampAttribution { groups: Vec<String> },
|
||||
ReadGroupBudgets { groups: Vec<String> },
|
||||
}
|
||||
|
||||
pub fn decide(facts: &Facts) -> Result<Decision, Error> {
|
||||
let complete = |usable, log_failure| {
|
||||
Ok(Decision::Complete {
|
||||
usable,
|
||||
log_failure,
|
||||
})
|
||||
};
|
||||
if !facts.router_present
|
||||
|| !facts.user_present
|
||||
|| !facts.team_present
|
||||
|| facts.membership == Membership::Failed
|
||||
{
|
||||
return complete(false, false);
|
||||
}
|
||||
if facts.membership == Membership::Unread {
|
||||
return Ok(Decision::ReadMembership);
|
||||
}
|
||||
if facts.io_failed {
|
||||
return complete(false, false);
|
||||
}
|
||||
if facts.block_unpriced {
|
||||
let Some(route) = &facts.route else {
|
||||
return Ok(Decision::ReadRoute);
|
||||
};
|
||||
if route_access::decide(route_access::Request::Classify {
|
||||
context: route.clone(),
|
||||
kind: route_access::Classification::Llm,
|
||||
})?
|
||||
.allowed
|
||||
{
|
||||
let Some(unpriced) = &facts.unpriced else {
|
||||
return Ok(Decision::ReadUnpriced);
|
||||
};
|
||||
match unpriced_models::decide(unpriced) {
|
||||
unpriced_models::Decision::Complete { unpriced: true } => {
|
||||
return complete(false, false);
|
||||
}
|
||||
unpriced_models::Decision::Complete { unpriced: false } => (),
|
||||
_ => return Err(Error::InvalidAnswer),
|
||||
}
|
||||
}
|
||||
}
|
||||
if facts.customer_present {
|
||||
let Some(grants) = &facts.customer_grants else {
|
||||
return Ok(Decision::ReadCustomerGrants);
|
||||
};
|
||||
if !grants.is_empty() {
|
||||
let Some(customer) = &facts.customer else {
|
||||
return Ok(Decision::ReadCustomer);
|
||||
};
|
||||
match model_access::decide(model_access::Request::Customer(customer.clone()))? {
|
||||
model_access::Response::Decision(decision) if decision.allowed() => (),
|
||||
model_access::Response::Decision(_) => return complete(false, true),
|
||||
_ => return Err(Error::InvalidAnswer),
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(price) = &facts.price else {
|
||||
return Ok(Decision::ReadPrice);
|
||||
};
|
||||
let priced = budget_pricing::price(price);
|
||||
if priced.read_deployments {
|
||||
return Err(Error::InvalidAnswer);
|
||||
}
|
||||
if !priced.zero_cost {
|
||||
let Some(models) = &facts.budget_models else {
|
||||
return Ok(Decision::ReadBudgetModels);
|
||||
};
|
||||
let Some(scopes) = &facts.budget_scopes else {
|
||||
return Ok(Decision::ReadBudgetScopes);
|
||||
};
|
||||
let mut seen = BTreeSet::new();
|
||||
for (model_index, model) in models.iter().enumerate() {
|
||||
if !seen.insert(model) {
|
||||
continue;
|
||||
}
|
||||
for entity in Entity::VARIANTS {
|
||||
if !scopes.iter().any(|scope| {
|
||||
scope.entity == *entity && scope.identity_present && scope.configured
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let Some(reading) = facts
|
||||
.budgets
|
||||
.iter()
|
||||
.find(|read| read.model_index == model_index && read.entity == *entity)
|
||||
else {
|
||||
return Ok(Decision::ReadBudget {
|
||||
model_index,
|
||||
entity: *entity,
|
||||
});
|
||||
};
|
||||
let budget = model_budget_data::check(&reading.snapshot);
|
||||
if budget.read.is_some() {
|
||||
return Err(Error::InvalidAnswer);
|
||||
}
|
||||
if budget.denied {
|
||||
return complete(false, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(authorization) = &facts.authorization else {
|
||||
return Ok(Decision::ReadAuthorization);
|
||||
};
|
||||
match fallback_access::decide(authorization.clone())? {
|
||||
fallback_access::Decision::Allow => (),
|
||||
fallback_access::Decision::Deny { .. } => return complete(false, false),
|
||||
_ => return Err(Error::InvalidAnswer),
|
||||
}
|
||||
if !facts.attribution_reset {
|
||||
return Ok(Decision::ResetAttribution);
|
||||
}
|
||||
let Some(attribution) = &facts.attribution else {
|
||||
return Ok(Decision::ReadAttribution);
|
||||
};
|
||||
let model_attribution::Decision::Complete { groups, .. } =
|
||||
model_attribution::decide(attribution)
|
||||
else {
|
||||
return Err(Error::InvalidAnswer);
|
||||
};
|
||||
if !facts.attribution_stamped {
|
||||
return Ok(Decision::StampAttribution { groups });
|
||||
}
|
||||
if !groups.is_empty() {
|
||||
if !facts.group_budgets_read {
|
||||
return Ok(Decision::ReadGroupBudgets { groups });
|
||||
}
|
||||
for facts in &facts.group_budgets {
|
||||
let budget = common_budgets::check(facts);
|
||||
if budget.read_spend {
|
||||
return Err(Error::InvalidAnswer);
|
||||
}
|
||||
if budget.denied {
|
||||
return complete(false, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
complete(true, false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::{fixture, rstest};
|
||||
|
||||
#[fixture]
|
||||
fn facts() -> Facts {
|
||||
Facts {
|
||||
router_present: true,
|
||||
user_present: true,
|
||||
team_present: true,
|
||||
..Facts::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::router(false, true, true, Decision::Complete { usable: false, log_failure: false })]
|
||||
#[case::user(true, false, true, Decision::Complete { usable: false, log_failure: false })]
|
||||
#[case::team(true, true, false, Decision::Complete { usable: false, log_failure: false })]
|
||||
#[case::ready(true, true, true, Decision::ReadMembership)]
|
||||
fn prerequisites(
|
||||
facts: Facts,
|
||||
#[case] router: bool,
|
||||
#[case] user: bool,
|
||||
#[case] team: bool,
|
||||
#[case] expected: Decision,
|
||||
) {
|
||||
assert_eq!(
|
||||
decide(&Facts {
|
||||
router_present: router,
|
||||
user_present: user,
|
||||
team_present: team,
|
||||
..facts
|
||||
})
|
||||
.unwrap(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::key(0, Entity::Key)]
|
||||
#[case::user(1, Entity::User)]
|
||||
#[case::enduser(2, Entity::EndUser)]
|
||||
#[case::team(3, Entity::Team)]
|
||||
#[case::member(4, Entity::TeamMember)]
|
||||
fn budgets(facts: Facts, #[case] count: usize, #[case] expected: Entity) {
|
||||
let facts = Facts {
|
||||
membership: Membership::Loaded,
|
||||
price: Some(budget_pricing::ModelPriceFacts {
|
||||
cached: Some(false),
|
||||
lookup_failed: false,
|
||||
group_found: false,
|
||||
input_price: None,
|
||||
output_price: None,
|
||||
deployments: None,
|
||||
}),
|
||||
budget_models: Some(vec!["model".into()]),
|
||||
budget_scopes: Some(
|
||||
Entity::VARIANTS
|
||||
.iter()
|
||||
.map(|entity| BudgetScope {
|
||||
entity: *entity,
|
||||
identity_present: true,
|
||||
configured: true,
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
budgets: Entity::VARIANTS
|
||||
.iter()
|
||||
.take(count)
|
||||
.map(|entity| BudgetRead {
|
||||
model_index: 0,
|
||||
entity: *entity,
|
||||
snapshot: model_budget_data::Snapshot {
|
||||
max_budget: None,
|
||||
local_spend: None,
|
||||
legacy_spend: None,
|
||||
shared_spend: None,
|
||||
shared_started_at: None,
|
||||
shared_window_seconds: None,
|
||||
now: 0.0,
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
..facts
|
||||
};
|
||||
assert_eq!(
|
||||
decide(&facts).unwrap(),
|
||||
Decision::ReadBudget {
|
||||
model_index: 0,
|
||||
entity: expected
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -133,9 +133,22 @@ fn check_key(check: KeyCheck) -> Result<Step, Error> {
|
|||
if key.blocked {
|
||||
return Step::deny(Denial::KeyBlocked);
|
||||
}
|
||||
Step::effect(Effect::EnforceModelAccess, move |(): ()| {
|
||||
check_owner(check, key)
|
||||
})
|
||||
Step::effect(
|
||||
Effect::EnforceModelAccess,
|
||||
move |facts: crate::key_model_access::Facts| match crate::key_model_access::decide(
|
||||
facts,
|
||||
)? {
|
||||
crate::key_model_access::Decision::Allow => {
|
||||
Step::effect(Effect::PrefetchAuthObjects, move |(): ()| {
|
||||
check_owner(check, key)
|
||||
})
|
||||
}
|
||||
crate::key_model_access::Decision::Deny { denial } => {
|
||||
Step::deny(Denial::ModelAccessDenied { decision: denial })
|
||||
}
|
||||
_ => Err(Error::InvalidAnswer),
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -157,43 +170,54 @@ fn check_owner(check: KeyCheck, key: KeyFacts) -> Result<Step, Error> {
|
|||
}
|
||||
|
||||
fn check_budget_exemption(check: KeyCheck, key: KeyFacts) -> Result<Step, Error> {
|
||||
Step::effect(Effect::CheckBudgetExemption, move |exempt: bool| {
|
||||
if exempt || key.team_member_spend.is_none() || !check.settings.has_db {
|
||||
return check_expiry(check, key, exempt);
|
||||
}
|
||||
let (Some(user_id), Some(team_id)) = (key.user_id.clone(), key.team_id.clone()) else {
|
||||
return check_expiry(check, key, exempt);
|
||||
};
|
||||
Step::effect(
|
||||
Effect::ReadTeamMemberBudget {
|
||||
user_id: user_id.clone(),
|
||||
team_id: team_id.clone(),
|
||||
},
|
||||
move |budget: Option<TeamMemberBudget>| {
|
||||
let Some(budget) = budget else {
|
||||
return check_expiry(check, key, false);
|
||||
};
|
||||
if budget.spend < budget.max_budget {
|
||||
return check_expiry(check, key, false);
|
||||
}
|
||||
Step::effect(
|
||||
Effect::AlertTeamMemberBudget {
|
||||
user_id: user_id.clone(),
|
||||
team_id: team_id.clone(),
|
||||
budget: budget.clone(),
|
||||
},
|
||||
move |(): ()| {
|
||||
Step::deny(Denial::TeamMemberBudgetExceeded {
|
||||
user_id,
|
||||
team_id,
|
||||
spend: budget.spend,
|
||||
max_budget: budget.max_budget,
|
||||
})
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
Step::effect(
|
||||
Effect::CheckBudgetExemption,
|
||||
move |facts: crate::budget_pricing::ExemptionFacts| {
|
||||
let decision = crate::budget_pricing::exemption(&facts);
|
||||
if decision.read_pass_through
|
||||
|| decision.read_price_cache
|
||||
|| decision.read_model.is_some()
|
||||
{
|
||||
return Err(Error::InvalidAnswer);
|
||||
}
|
||||
let exempt = decision.exempt;
|
||||
if exempt || key.team_member_spend.is_none() || !check.settings.has_db {
|
||||
return check_expiry(check, key, exempt);
|
||||
}
|
||||
let (Some(user_id), Some(team_id)) = (key.user_id.clone(), key.team_id.clone()) else {
|
||||
return check_expiry(check, key, exempt);
|
||||
};
|
||||
Step::effect(
|
||||
Effect::ReadTeamMemberBudget {
|
||||
user_id: user_id.clone(),
|
||||
team_id: team_id.clone(),
|
||||
},
|
||||
move |budget: Option<TeamMemberBudget>| {
|
||||
let Some(budget) = budget else {
|
||||
return check_expiry(check, key, false);
|
||||
};
|
||||
if budget.spend < budget.max_budget {
|
||||
return check_expiry(check, key, false);
|
||||
}
|
||||
Step::effect(
|
||||
Effect::AlertTeamMemberBudget {
|
||||
user_id: user_id.clone(),
|
||||
team_id: team_id.clone(),
|
||||
budget: budget.clone(),
|
||||
},
|
||||
move |(): ()| {
|
||||
Step::deny(Denial::TeamMemberBudgetExceeded {
|
||||
user_id,
|
||||
team_id,
|
||||
spend: budget.spend,
|
||||
max_budget: budget.max_budget,
|
||||
})
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn check_expiry(check: KeyCheck, key: KeyFacts, exempt: bool) -> Result<Step, Error> {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
Error,
|
||||
fallback_access::{GroupedScope, grouped},
|
||||
model_access::Decision as ModelDecision,
|
||||
};
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Facts {
|
||||
pub config_nonempty: bool,
|
||||
pub key_models: Vec<String>,
|
||||
pub primary_loaded: bool,
|
||||
pub primary: Option<GroupedScope>,
|
||||
pub fallback_names: Option<Vec<String>>,
|
||||
pub fallback_scopes: Vec<GroupedScope>,
|
||||
pub routing: Vec<Routing>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub enum Routing {
|
||||
Routed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Decision {
|
||||
Allow,
|
||||
Deny { denial: ModelDecision },
|
||||
ReadPrimary,
|
||||
ReadPrimaryGroups,
|
||||
ReadFallbackNames,
|
||||
ReadFallbackScope { index: usize },
|
||||
ReadFallbackGroups { index: usize },
|
||||
RouteFallback { index: usize },
|
||||
RoutingFailure { index: usize },
|
||||
}
|
||||
|
||||
pub fn decide(facts: Facts) -> Result<Decision, Error> {
|
||||
if facts.config_nonempty
|
||||
|| facts
|
||||
.key_models
|
||||
.iter()
|
||||
.any(|model| model == "all-team-models")
|
||||
{
|
||||
return Ok(Decision::Allow);
|
||||
}
|
||||
if !facts.primary_loaded {
|
||||
return Ok(Decision::ReadPrimary);
|
||||
}
|
||||
if let Some(primary) = facts.primary {
|
||||
let decision = grouped(primary, false)?;
|
||||
if matches!(
|
||||
decision,
|
||||
ModelDecision::ReadGroups | ModelDecision::ReadGroupScope { .. }
|
||||
) {
|
||||
return Ok(Decision::ReadPrimaryGroups);
|
||||
}
|
||||
if !decision.allowed() {
|
||||
return Ok(Decision::Deny { denial: decision });
|
||||
}
|
||||
}
|
||||
let Some(names) = facts.fallback_names else {
|
||||
return Ok(Decision::ReadFallbackNames);
|
||||
};
|
||||
for index in 0..names.len() {
|
||||
let Some(scope) = facts.fallback_scopes.get(index) else {
|
||||
return Ok(Decision::ReadFallbackScope { index });
|
||||
};
|
||||
let decision = grouped(scope.clone(), false)?;
|
||||
if matches!(
|
||||
decision,
|
||||
ModelDecision::ReadGroups | ModelDecision::ReadGroupScope { .. }
|
||||
) {
|
||||
return Ok(Decision::ReadFallbackGroups { index });
|
||||
}
|
||||
if !decision.allowed() {
|
||||
return Ok(Decision::Deny { denial: decision });
|
||||
}
|
||||
match facts.routing.get(index) {
|
||||
None => return Ok(Decision::RouteFallback { index }),
|
||||
Some(Routing::Failed) => return Ok(Decision::RoutingFailure { index }),
|
||||
Some(Routing::Routed) => {}
|
||||
}
|
||||
}
|
||||
Ok(Decision::Allow)
|
||||
}
|
||||
|
|
@ -1,10 +1,52 @@
|
|||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod access;
|
||||
pub mod admission;
|
||||
pub mod agent_access;
|
||||
pub mod agent_ceilings;
|
||||
pub mod agent_grants;
|
||||
pub mod auth_context;
|
||||
pub mod budget_alerts;
|
||||
pub mod budget_context;
|
||||
pub mod budget_lookup;
|
||||
pub mod budget_pricing;
|
||||
pub mod common_budgets;
|
||||
pub mod common_flow;
|
||||
pub mod credential;
|
||||
pub mod end_user_context;
|
||||
mod error;
|
||||
pub mod fallback_access;
|
||||
pub mod fallback_candidate;
|
||||
mod key;
|
||||
pub mod key_model_access;
|
||||
pub mod managed;
|
||||
pub mod managed_models;
|
||||
pub mod member_models;
|
||||
pub mod model_access;
|
||||
pub mod model_attribution;
|
||||
pub mod model_budget_data;
|
||||
mod model_budgets;
|
||||
pub mod model_groups;
|
||||
pub mod model_helpers;
|
||||
pub mod model_projection;
|
||||
pub mod org_routes;
|
||||
pub mod owner_teams;
|
||||
pub mod patterns;
|
||||
pub mod personal_budget;
|
||||
pub mod personal_model_listing;
|
||||
pub mod personal_model_request;
|
||||
pub mod personal_models;
|
||||
pub mod personal_owner;
|
||||
pub mod protocol;
|
||||
pub mod reservation;
|
||||
pub mod resolved_models;
|
||||
pub mod route_access;
|
||||
pub mod route_enable;
|
||||
pub mod route_roles;
|
||||
pub mod spend_counter_policy;
|
||||
pub mod team_aliases;
|
||||
pub mod team_override;
|
||||
pub mod unpriced_models;
|
||||
|
||||
pub use error::Error;
|
||||
pub use key::{start, start_resolved_key};
|
||||
|
|
|
|||
348
litellm-rust/crates/request-auth-core/src/managed.rs
Normal file
348
litellm-rust/crates/request-auth-core/src/managed.rs
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct BoundIdentity {
|
||||
pub active: bool,
|
||||
pub revision: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Agent {
|
||||
pub agent_id: String,
|
||||
pub enabled: bool,
|
||||
pub identity_managed: bool,
|
||||
pub identity: Option<BoundIdentity>,
|
||||
pub execution_mode: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Context {
|
||||
pub agent_id: String,
|
||||
pub binding_revision: Option<String>,
|
||||
pub mode: String,
|
||||
pub user_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum ManagedFacts {
|
||||
Actor {
|
||||
agent: Agent,
|
||||
context: Option<Context>,
|
||||
},
|
||||
ActorLookup {
|
||||
agent_id: Option<String>,
|
||||
has_database: bool,
|
||||
registry_loaded: bool,
|
||||
registry_managed: bool,
|
||||
agent_loaded: bool,
|
||||
agent: Option<Agent>,
|
||||
retired: Option<bool>,
|
||||
context: Option<Context>,
|
||||
jwt_claims: bool,
|
||||
delegation_verified: bool,
|
||||
human_grants: Option<Vec<String>>,
|
||||
policy_assigned: bool,
|
||||
},
|
||||
UnionGrants {
|
||||
grants: Vec<Option<Vec<String>>>,
|
||||
},
|
||||
IntersectGrants {
|
||||
left: Option<Vec<String>>,
|
||||
right: Option<Vec<String>>,
|
||||
},
|
||||
DeclaredGrants {
|
||||
direct: Vec<String>,
|
||||
declared_groups: Vec<String>,
|
||||
unified_groups: Vec<String>,
|
||||
group_agents: Option<Vec<String>>,
|
||||
unified_agents: Option<Vec<String>>,
|
||||
},
|
||||
ManagedAgentGrants {
|
||||
permission_present: bool,
|
||||
own: Option<Vec<String>>,
|
||||
ceilings: Option<Vec<Vec<String>>>,
|
||||
caller_loaded: bool,
|
||||
caller: Option<Vec<String>>,
|
||||
mode: Option<String>,
|
||||
user_present: bool,
|
||||
human: Option<Vec<String>>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize, strum::VariantArray)]
|
||||
pub enum ManagedDenial {
|
||||
Disabled,
|
||||
MissingContext,
|
||||
ChangedIdentity,
|
||||
ExecutionMode,
|
||||
MissingHuman,
|
||||
DatabaseRequired,
|
||||
Removed,
|
||||
VerifiedIdentityRequired,
|
||||
HumanNotPermitted,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize, strum::VariantArray)]
|
||||
pub enum ManagedAction {
|
||||
Allow,
|
||||
Deny,
|
||||
ReadRegistry,
|
||||
ReadAgent,
|
||||
ReadRetired,
|
||||
AssignPolicy,
|
||||
ReadHumanGrants,
|
||||
Grants,
|
||||
ReadDeclaredGroups,
|
||||
ReadUnifiedGroups,
|
||||
ReadOwn,
|
||||
ReadCeilings,
|
||||
ReadCaller,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize)]
|
||||
pub struct ManagedDecision {
|
||||
pub action: ManagedAction,
|
||||
pub denial: Option<ManagedDenial>,
|
||||
pub grants: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
fn action(action: ManagedAction) -> ManagedDecision {
|
||||
ManagedDecision {
|
||||
action,
|
||||
denial: None,
|
||||
grants: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn denied(denial: ManagedDenial) -> ManagedDecision {
|
||||
ManagedDecision {
|
||||
action: ManagedAction::Deny,
|
||||
denial: Some(denial),
|
||||
grants: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn actor(agent: &Agent, context: Option<&Context>) -> ManagedDecision {
|
||||
if !agent.enabled
|
||||
|| !agent
|
||||
.identity
|
||||
.as_ref()
|
||||
.is_some_and(|identity| identity.active)
|
||||
{
|
||||
return denied(ManagedDenial::Disabled);
|
||||
}
|
||||
let Some(context) = context else {
|
||||
return denied(ManagedDenial::MissingContext);
|
||||
};
|
||||
if context.agent_id != agent.agent_id
|
||||
|| agent
|
||||
.identity
|
||||
.as_ref()
|
||||
.is_some_and(|identity| context.binding_revision.as_ref() != Some(&identity.revision))
|
||||
{
|
||||
return denied(ManagedDenial::ChangedIdentity);
|
||||
}
|
||||
if agent.execution_mode != context.mode && agent.execution_mode != "both" {
|
||||
return denied(ManagedDenial::ExecutionMode);
|
||||
}
|
||||
if context.mode == "delegated"
|
||||
&& !context
|
||||
.user_id
|
||||
.as_ref()
|
||||
.is_some_and(|user| !user.is_empty())
|
||||
{
|
||||
return denied(ManagedDenial::MissingHuman);
|
||||
}
|
||||
action(ManagedAction::Allow)
|
||||
}
|
||||
|
||||
pub fn decide(facts: ManagedFacts) -> ManagedDecision {
|
||||
match facts {
|
||||
ManagedFacts::Actor { agent, context } => actor(&agent, context.as_ref()),
|
||||
ManagedFacts::ActorLookup {
|
||||
agent_id,
|
||||
has_database,
|
||||
registry_loaded,
|
||||
registry_managed,
|
||||
agent_loaded,
|
||||
agent,
|
||||
retired,
|
||||
context,
|
||||
jwt_claims,
|
||||
delegation_verified,
|
||||
human_grants,
|
||||
policy_assigned,
|
||||
} => {
|
||||
if agent_id.is_none() {
|
||||
return action(ManagedAction::Allow);
|
||||
}
|
||||
if !has_database {
|
||||
if !registry_loaded {
|
||||
return action(ManagedAction::ReadRegistry);
|
||||
}
|
||||
return if context.is_some() || registry_managed {
|
||||
denied(ManagedDenial::DatabaseRequired)
|
||||
} else {
|
||||
action(ManagedAction::Allow)
|
||||
};
|
||||
}
|
||||
if !agent_loaded {
|
||||
return action(ManagedAction::ReadAgent);
|
||||
}
|
||||
let Some(agent) = agent else {
|
||||
return match retired {
|
||||
None => action(ManagedAction::ReadRetired),
|
||||
Some(retired) if retired || context.is_some() => denied(ManagedDenial::Removed),
|
||||
Some(_) => action(ManagedAction::Allow),
|
||||
};
|
||||
};
|
||||
if !agent.identity_managed {
|
||||
return action(ManagedAction::Allow);
|
||||
}
|
||||
if jwt_claims && context.is_none() {
|
||||
return denied(ManagedDenial::VerifiedIdentityRequired);
|
||||
}
|
||||
let checked = actor(&agent, context.as_ref());
|
||||
if checked.denial.is_some() {
|
||||
return checked;
|
||||
}
|
||||
if !policy_assigned {
|
||||
return action(ManagedAction::AssignPolicy);
|
||||
}
|
||||
if context
|
||||
.as_ref()
|
||||
.is_some_and(|context| context.mode == "delegated")
|
||||
&& !delegation_verified
|
||||
{
|
||||
return match human_grants {
|
||||
None => action(ManagedAction::ReadHumanGrants),
|
||||
Some(grants) if !grants.contains(&agent.agent_id) => {
|
||||
denied(ManagedDenial::HumanNotPermitted)
|
||||
}
|
||||
Some(_) => action(ManagedAction::Allow),
|
||||
};
|
||||
}
|
||||
action(ManagedAction::Allow)
|
||||
}
|
||||
ManagedFacts::UnionGrants { grants } => {
|
||||
let grants = if !grants.is_empty() && grants.iter().all(Option::is_none) {
|
||||
None
|
||||
} else {
|
||||
Some(grants.into_iter().flatten().flatten().collect())
|
||||
};
|
||||
ManagedDecision {
|
||||
action: ManagedAction::Grants,
|
||||
denial: None,
|
||||
grants,
|
||||
}
|
||||
}
|
||||
ManagedFacts::IntersectGrants { left, right } => {
|
||||
let grants = match (left, right) {
|
||||
(None, right) => right,
|
||||
(left, None) => left,
|
||||
(Some(left), Some(right)) => {
|
||||
Some(left.into_iter().filter(|id| right.contains(id)).collect())
|
||||
}
|
||||
};
|
||||
ManagedDecision {
|
||||
action: ManagedAction::Grants,
|
||||
denial: None,
|
||||
grants,
|
||||
}
|
||||
}
|
||||
ManagedFacts::DeclaredGrants {
|
||||
direct,
|
||||
declared_groups,
|
||||
unified_groups,
|
||||
group_agents,
|
||||
unified_agents,
|
||||
} => {
|
||||
if !declared_groups.is_empty() && group_agents.is_none() {
|
||||
return action(ManagedAction::ReadDeclaredGroups);
|
||||
}
|
||||
if !unified_groups.is_empty() && unified_agents.is_none() {
|
||||
return action(ManagedAction::ReadUnifiedGroups);
|
||||
}
|
||||
let grants =
|
||||
if direct.is_empty() && declared_groups.is_empty() && unified_groups.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
direct
|
||||
.into_iter()
|
||||
.chain(group_agents.into_iter().flatten())
|
||||
.chain(unified_agents.into_iter().flatten())
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
ManagedDecision {
|
||||
action: ManagedAction::Grants,
|
||||
denial: None,
|
||||
grants,
|
||||
}
|
||||
}
|
||||
ManagedFacts::ManagedAgentGrants {
|
||||
permission_present,
|
||||
own,
|
||||
ceilings,
|
||||
caller_loaded,
|
||||
caller,
|
||||
mode,
|
||||
user_present,
|
||||
human,
|
||||
} => {
|
||||
let grants = |ids| ManagedDecision {
|
||||
action: ManagedAction::Grants,
|
||||
denial: None,
|
||||
grants: Some(ids),
|
||||
};
|
||||
if !permission_present {
|
||||
return grants(vec![]);
|
||||
}
|
||||
let Some(own) = own else {
|
||||
return action(ManagedAction::ReadOwn);
|
||||
};
|
||||
let Some(ceilings) = ceilings else {
|
||||
return action(ManagedAction::ReadCeilings);
|
||||
};
|
||||
if !caller_loaded {
|
||||
return action(ManagedAction::ReadCaller);
|
||||
}
|
||||
let capped: Vec<_> = own
|
||||
.into_iter()
|
||||
.filter(|id| {
|
||||
ceilings.iter().all(|ceiling| ceiling.contains(id))
|
||||
&& caller.as_ref().is_none_or(|ids| ids.contains(id))
|
||||
})
|
||||
.collect();
|
||||
if mode.is_none() || mode.as_deref() == Some("autonomous") {
|
||||
return grants(capped);
|
||||
}
|
||||
if !user_present {
|
||||
return grants(vec![]);
|
||||
}
|
||||
let Some(human) = human else {
|
||||
return action(ManagedAction::ReadHumanGrants);
|
||||
};
|
||||
grants(capped.into_iter().filter(|id| human.contains(id)).collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::empty(vec![], Some(vec![]))]
|
||||
#[case::unrestricted(vec![None], None)]
|
||||
#[case::mixed(vec![None, Some(vec!["a".into()])], Some(vec!["a".into()]))]
|
||||
fn union(#[case] grants: Vec<Option<Vec<String>>>, #[case] expected: Option<Vec<String>>) {
|
||||
assert_eq!(
|
||||
decide(ManagedFacts::UnionGrants { grants }).grants,
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
253
litellm-rust/crates/request-auth-core/src/managed_models.rs
Normal file
253
litellm-rust/crates/request-auth-core/src/managed_models.rs
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
use crate::{
|
||||
Error,
|
||||
fallback_access::{self, GroupedScope},
|
||||
member_models,
|
||||
model_access::{self, Decision as ModelDecision},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Default, Deserialize, PartialEq, Eq, strum::VariantArray)]
|
||||
pub enum Purpose {
|
||||
#[default]
|
||||
All,
|
||||
Ceilings,
|
||||
Caller,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
pub struct Facts {
|
||||
pub purpose: Purpose,
|
||||
pub model_present: bool,
|
||||
pub token_present: bool,
|
||||
pub managed_loaded: bool,
|
||||
pub managed_present: bool,
|
||||
pub managed_models_valid: bool,
|
||||
pub managed_models_count: usize,
|
||||
pub managed: Option<model_access::Facts>,
|
||||
pub ceilings: Option<Vec<Vec<String>>>,
|
||||
pub aliases_loaded: bool,
|
||||
pub scopes: Vec<model_access::Facts>,
|
||||
pub caller_loaded: bool,
|
||||
pub caller_present: bool,
|
||||
pub team_loaded: bool,
|
||||
pub team_present: bool,
|
||||
pub team: Option<GroupedScope>,
|
||||
pub member: Option<member_models::Facts>,
|
||||
pub user_loaded: bool,
|
||||
pub user_present: bool,
|
||||
pub user_grants: Vec<String>,
|
||||
pub user: Option<model_access::Facts>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Decision {
|
||||
Allow,
|
||||
Deny {
|
||||
denial: ModelDecision,
|
||||
member_models: Option<Vec<String>>,
|
||||
},
|
||||
ReadManaged,
|
||||
ReadManagedScope,
|
||||
ReadCeilings,
|
||||
ReadAliases,
|
||||
ReadCeilingScope {
|
||||
index: usize,
|
||||
},
|
||||
ReadCaller,
|
||||
ReadTeam,
|
||||
ReadTeamScope,
|
||||
ReadTeamGroups,
|
||||
ReadMember,
|
||||
ReadUser,
|
||||
ReadUserScope,
|
||||
}
|
||||
|
||||
pub fn decide(facts: &Facts) -> Result<Decision, Error> {
|
||||
let deny = |denial| {
|
||||
Ok(Decision::Deny {
|
||||
denial,
|
||||
member_models: None,
|
||||
})
|
||||
};
|
||||
if facts.purpose == Purpose::All && !facts.managed_loaded {
|
||||
return Ok(Decision::ReadManaged);
|
||||
}
|
||||
if !facts.model_present || !facts.token_present {
|
||||
return Ok(Decision::Allow);
|
||||
}
|
||||
if facts.purpose == Purpose::All && facts.managed_present {
|
||||
if !facts.managed_models_valid || facts.managed_models_count == 0 {
|
||||
return deny(ModelDecision::NoAgentModels);
|
||||
}
|
||||
let Some(scope) = &facts.managed else {
|
||||
return Ok(Decision::ReadManagedScope);
|
||||
};
|
||||
let result = model_access::check(scope)?;
|
||||
if !result.allowed() {
|
||||
return deny(result);
|
||||
}
|
||||
}
|
||||
if facts.purpose != Purpose::Caller {
|
||||
let Some(ceilings) = &facts.ceilings else {
|
||||
return Ok(Decision::ReadCeilings);
|
||||
};
|
||||
if !facts.aliases_loaded {
|
||||
return Ok(Decision::ReadAliases);
|
||||
}
|
||||
for (index, models) in ceilings.iter().enumerate() {
|
||||
if models.is_empty() {
|
||||
return deny(ModelDecision::EmptyAgentCeiling);
|
||||
}
|
||||
let Some(scope) = facts.scopes.get(index) else {
|
||||
return Ok(Decision::ReadCeilingScope { index });
|
||||
};
|
||||
let result = model_access::check(scope)?;
|
||||
if !result.allowed() {
|
||||
return deny(result);
|
||||
}
|
||||
}
|
||||
if facts.purpose == Purpose::Ceilings {
|
||||
return Ok(Decision::Allow);
|
||||
}
|
||||
}
|
||||
if !facts.caller_loaded {
|
||||
return Ok(Decision::ReadCaller);
|
||||
}
|
||||
if !facts.caller_present {
|
||||
return Ok(Decision::Allow);
|
||||
}
|
||||
if !facts.team_loaded {
|
||||
return Ok(Decision::ReadTeam);
|
||||
}
|
||||
if facts.team_present {
|
||||
let Some(team) = &facts.team else {
|
||||
return Ok(Decision::ReadTeamScope);
|
||||
};
|
||||
let result = fallback_access::grouped(team.clone(), true)?;
|
||||
if matches!(
|
||||
result,
|
||||
ModelDecision::ReadGroups | ModelDecision::ReadGroupScope { .. }
|
||||
) {
|
||||
return Ok(Decision::ReadTeamGroups);
|
||||
}
|
||||
if !result.allowed() {
|
||||
return deny(result);
|
||||
}
|
||||
let Some(member) = &facts.member else {
|
||||
return Ok(Decision::ReadMember);
|
||||
};
|
||||
return match member_models::decide(member)? {
|
||||
member_models::Decision::Allow => Ok(Decision::Allow),
|
||||
member_models::Decision::Deny {
|
||||
denial,
|
||||
member_models,
|
||||
} => Ok(Decision::Deny {
|
||||
denial,
|
||||
member_models,
|
||||
}),
|
||||
_ => Err(Error::InvalidAnswer),
|
||||
};
|
||||
}
|
||||
if !facts.user_loaded {
|
||||
return Ok(Decision::ReadUser);
|
||||
}
|
||||
let precheck = model_access::decide(model_access::Request::UserPrecheck {
|
||||
present: facts.user_present,
|
||||
grants: facts.user_grants.clone(),
|
||||
})?;
|
||||
let model_access::Response::Decision(precheck) = precheck else {
|
||||
return Err(Error::InvalidAnswer);
|
||||
};
|
||||
if !matches!(precheck, ModelDecision::ReadScope) {
|
||||
return if precheck.allowed() {
|
||||
Ok(Decision::Allow)
|
||||
} else {
|
||||
deny(precheck)
|
||||
};
|
||||
}
|
||||
let Some(user) = &facts.user else {
|
||||
return Ok(Decision::ReadUserScope);
|
||||
};
|
||||
let result = model_access::check(user)?;
|
||||
if result.allowed() {
|
||||
Ok(Decision::Allow)
|
||||
} else {
|
||||
deny(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
pub struct CeilingSource {
|
||||
pub agent_present: bool,
|
||||
pub managed_loaded: bool,
|
||||
pub managed_present: bool,
|
||||
pub loaded: bool,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum SourceDecision {
|
||||
ReadManaged,
|
||||
ReadManagedCeilings,
|
||||
ReadUnmanagedCeiling,
|
||||
Complete { indices: Vec<usize> },
|
||||
}
|
||||
|
||||
pub fn ceiling_source(facts: &CeilingSource) -> SourceDecision {
|
||||
if !facts.agent_present {
|
||||
return SourceDecision::Complete { indices: vec![] };
|
||||
}
|
||||
if !facts.managed_loaded {
|
||||
return SourceDecision::ReadManaged;
|
||||
}
|
||||
if facts.loaded {
|
||||
return SourceDecision::Complete {
|
||||
indices: (0..facts.count).collect(),
|
||||
};
|
||||
}
|
||||
if facts.managed_present {
|
||||
SourceDecision::ReadManagedCeilings
|
||||
} else {
|
||||
SourceDecision::ReadUnmanagedCeiling
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct GrantFacts {
|
||||
pub models: Option<Vec<Option<String>>>,
|
||||
pub ceilings: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
pub fn grants(facts: &GrantFacts) -> std::collections::BTreeSet<String> {
|
||||
facts
|
||||
.models
|
||||
.iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.chain(facts.ceilings.iter().flatten())
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::main(Purpose::All, Decision::ReadManaged)]
|
||||
#[case::groups(Purpose::Ceilings, Decision::Allow)]
|
||||
#[case::caller(Purpose::Caller, Decision::Allow)]
|
||||
fn empty_model_read_order(#[case] purpose: Purpose, #[case] expected: Decision) {
|
||||
assert_eq!(
|
||||
decide(&Facts {
|
||||
purpose,
|
||||
..Facts::default()
|
||||
})
|
||||
.unwrap(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
77
litellm-rust/crates/request-auth-core/src/member_models.rs
Normal file
77
litellm-rust/crates/request-auth-core/src/member_models.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
use crate::{Error, model_access};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Facts {
|
||||
pub user_present: bool,
|
||||
pub team_present: bool,
|
||||
pub membership_loaded: bool,
|
||||
pub grants: Option<Vec<String>>,
|
||||
pub scope: Option<model_access::Facts>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Decision {
|
||||
Allow,
|
||||
Deny {
|
||||
denial: model_access::Decision,
|
||||
member_models: Option<Vec<String>>,
|
||||
},
|
||||
ReadMembership,
|
||||
ReadGrants,
|
||||
ReadScope,
|
||||
}
|
||||
|
||||
pub fn decide(facts: &Facts) -> Result<Decision, Error> {
|
||||
if !facts.user_present || !facts.team_present {
|
||||
return Ok(Decision::Allow);
|
||||
}
|
||||
if !facts.membership_loaded {
|
||||
return Ok(Decision::ReadMembership);
|
||||
}
|
||||
let Some(grants) = &facts.grants else {
|
||||
return Ok(Decision::ReadGrants);
|
||||
};
|
||||
if grants.is_empty() {
|
||||
return Ok(Decision::Allow);
|
||||
}
|
||||
let Some(scope) = &facts.scope else {
|
||||
return Ok(Decision::ReadScope);
|
||||
};
|
||||
let denial = model_access::check(scope)?;
|
||||
if denial.allowed() {
|
||||
return Ok(Decision::Allow);
|
||||
}
|
||||
let member_models =
|
||||
matches!(denial, model_access::Decision::Deny { .. }).then(|| grants.clone());
|
||||
Ok(Decision::Deny {
|
||||
denial,
|
||||
member_models,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::missing(false, false, Decision::Allow)]
|
||||
#[case::lookup(true, false, Decision::ReadMembership)]
|
||||
#[case::preloaded(true, true, Decision::ReadGrants)]
|
||||
fn membership_read(
|
||||
#[case] user_present: bool,
|
||||
#[case] membership_loaded: bool,
|
||||
#[case] expected: Decision,
|
||||
) {
|
||||
let facts = Facts {
|
||||
user_present,
|
||||
team_present: true,
|
||||
membership_loaded,
|
||||
grants: None,
|
||||
scope: None,
|
||||
};
|
||||
assert_eq!(decide(&facts).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
674
litellm-rust/crates/request-auth-core/src/model_access.rs
Normal file
674
litellm-rust/crates/request-auth-core/src/model_access.rs
Normal file
|
|
@ -0,0 +1,674 @@
|
|||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Error, patterns};
|
||||
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
pub struct Provider {
|
||||
pub stripped_model: String,
|
||||
pub provider: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
pub struct ModelRecord {
|
||||
pub access_groups: Vec<String>,
|
||||
pub provider: Option<Provider>,
|
||||
pub deployment_team: Option<String>,
|
||||
pub deployment_public_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
pub struct Registry {
|
||||
pub present: bool,
|
||||
pub names: Vec<String>,
|
||||
pub aliases: BTreeMap<String, String>,
|
||||
pub records: BTreeMap<String, ModelRecord>,
|
||||
pub compaction_parents: BTreeMap<String, String>,
|
||||
pub helper_present: Option<bool>,
|
||||
pub evaluations: Option<Vec<crate::model_projection::Evaluation>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
pub struct Aliases {
|
||||
pub team: BTreeMap<String, String>,
|
||||
pub team_order: Option<Vec<String>>,
|
||||
pub key: BTreeMap<String, String>,
|
||||
#[serde(rename = "global_")]
|
||||
pub global: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Facts {
|
||||
pub requested: Vec<String>,
|
||||
pub grants: Vec<String>,
|
||||
pub team_id: Option<String>,
|
||||
pub aliases: Aliases,
|
||||
pub registry: Registry,
|
||||
pub object_type: ObjectType,
|
||||
pub fallback_depth: usize,
|
||||
pub max_depth: usize,
|
||||
pub is_list: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Deserialize, Serialize, Debug, PartialEq, Eq, strum::VariantArray)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ObjectType {
|
||||
User,
|
||||
Customer,
|
||||
Team,
|
||||
Key,
|
||||
Org,
|
||||
Project,
|
||||
Agent,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Decision {
|
||||
Allow,
|
||||
Deny {
|
||||
model: String,
|
||||
grants: Vec<String>,
|
||||
object_type: ObjectType,
|
||||
},
|
||||
Depth {
|
||||
model: Option<String>,
|
||||
},
|
||||
NoDefaultModels,
|
||||
NoAgentModels,
|
||||
EmptyAgentCeiling,
|
||||
PersonalTeam {
|
||||
model: String,
|
||||
},
|
||||
ReadGroups,
|
||||
ReadScope,
|
||||
ReadGroupScope {
|
||||
grants: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Decision {
|
||||
pub fn allowed(&self) -> bool {
|
||||
matches!(self, Self::Allow)
|
||||
}
|
||||
}
|
||||
|
||||
fn unique(values: impl Iterator<Item = String>) -> Vec<String> {
|
||||
let mut seen = BTreeSet::new();
|
||||
values.filter(|value| seen.insert(value.clone())).collect()
|
||||
}
|
||||
|
||||
pub fn resolve_key_grants(
|
||||
grants: &[String],
|
||||
team_id: Option<&str>,
|
||||
team_grants: &[String],
|
||||
) -> Vec<String> {
|
||||
if grants.iter().any(|grant| grant == "all-team-models") {
|
||||
return if team_id.is_none() {
|
||||
Vec::new()
|
||||
} else {
|
||||
team_grants.to_vec()
|
||||
};
|
||||
}
|
||||
grants.to_vec()
|
||||
}
|
||||
|
||||
fn expanded_grants(facts: &Facts) -> Vec<String> {
|
||||
if !facts.grants.iter().any(|grant| grant == "all-team-models")
|
||||
|| facts.team_id.is_none()
|
||||
|| !facts.registry.present
|
||||
{
|
||||
return facts.grants.clone();
|
||||
}
|
||||
let others = facts
|
||||
.grants
|
||||
.iter()
|
||||
.filter(|grant| grant.as_str() != "all-team-models")
|
||||
.cloned();
|
||||
if facts.registry.names.is_empty() {
|
||||
let values: Vec<_> = others.collect();
|
||||
return if values.is_empty() {
|
||||
facts.grants.clone()
|
||||
} else {
|
||||
values
|
||||
};
|
||||
}
|
||||
unique(others.chain(facts.registry.names.iter().cloned()))
|
||||
}
|
||||
|
||||
fn helper(model: &str, facts: &Facts, aliases: bool) -> Result<bool, Error> {
|
||||
let grants = expanded_grants(facts);
|
||||
let record = facts.registry.records.get(model);
|
||||
let groups = record.map_or(&[][..], |record| record.access_groups.as_slice());
|
||||
if grants.iter().any(|grant| groups.contains(grant)) {
|
||||
return Ok(true);
|
||||
}
|
||||
let filtered: Vec<_> = grants
|
||||
.iter()
|
||||
.filter(|grant| !groups.contains(grant))
|
||||
.collect();
|
||||
if aliases && facts.aliases.team.contains_key(model) {
|
||||
return Ok(true);
|
||||
}
|
||||
for grant in &filtered {
|
||||
if patterns::model_pattern(model, grant)? {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
if let Some(provider) = record.and_then(|record| record.provider.as_ref())
|
||||
&& (provider.stripped_model != model || !model.contains('/'))
|
||||
{
|
||||
for grant in &filtered {
|
||||
if patterns::model_pattern(
|
||||
&format!("{}/{}", provider.provider, provider.stripped_model),
|
||||
grant,
|
||||
)? {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((filtered.is_empty() && grants.is_empty())
|
||||
|| filtered.iter().any(|grant| {
|
||||
matches!(grant.as_str(), "*" | "all-proxy-models") || grant.as_str() == model
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn candidate_names(
|
||||
model: &str,
|
||||
aliases: &Aliases,
|
||||
registry: &Registry,
|
||||
team_id: Option<&str>,
|
||||
) -> (Vec<String>, Vec<String>) {
|
||||
let after_team = aliases.team.get(model).map_or(model, String::as_str);
|
||||
let after_key = aliases
|
||||
.key
|
||||
.get(after_team)
|
||||
.map_or(after_team, String::as_str);
|
||||
let after_global = aliases
|
||||
.global
|
||||
.get(after_key)
|
||||
.map_or(after_key, String::as_str);
|
||||
let dispatched = aliases
|
||||
.key
|
||||
.get(after_global)
|
||||
.map_or(after_global, String::as_str);
|
||||
if after_key != after_team || dispatched != after_global {
|
||||
return (vec![dispatched.to_owned()], vec![]);
|
||||
}
|
||||
let candidates = std::iter::once(model.to_owned())
|
||||
.chain(registry.compaction_parents.get(model).cloned())
|
||||
.chain(
|
||||
aliases
|
||||
.global
|
||||
.get(model)
|
||||
.or_else(|| registry.aliases.get(model))
|
||||
.filter(|target| !target.is_empty())
|
||||
.cloned(),
|
||||
)
|
||||
.collect();
|
||||
let Some(team) = team_id else {
|
||||
return (candidates, vec![]);
|
||||
};
|
||||
if !registry.present || !model.starts_with(&format!("model_name_{team}_")) {
|
||||
return (candidates, vec![]);
|
||||
}
|
||||
let record = registry.records.get(model);
|
||||
let public = record
|
||||
.filter(|record| record.deployment_team.as_deref() == Some(team))
|
||||
.and_then(|record| record.deployment_public_name.clone())
|
||||
.filter(|name| !name.is_empty());
|
||||
let ordered: Vec<&String> = aliases.team_order.as_ref().map_or_else(
|
||||
|| aliases.team.keys().collect(),
|
||||
|names| names.iter().collect(),
|
||||
);
|
||||
let legacy = ordered
|
||||
.into_iter()
|
||||
.filter(|alias| {
|
||||
aliases
|
||||
.team
|
||||
.get(*alias)
|
||||
.is_some_and(|target| target == model)
|
||||
})
|
||||
.cloned();
|
||||
(candidates, public.into_iter().chain(legacy).collect())
|
||||
}
|
||||
|
||||
pub fn live_team_target(model: &str, facts: &Facts) -> String {
|
||||
let Some(target) = facts.aliases.team.get(model) else {
|
||||
return model.to_owned();
|
||||
};
|
||||
let team = facts.team_id.as_deref().unwrap_or("None");
|
||||
if facts.registry.present
|
||||
&& target.starts_with(&format!("model_name_{team}_"))
|
||||
&& !facts.registry.names.contains(target)
|
||||
{
|
||||
return model.to_owned();
|
||||
}
|
||||
target.clone()
|
||||
}
|
||||
|
||||
pub fn check(facts: &Facts) -> Result<Decision, Error> {
|
||||
check_at(facts, 0)
|
||||
}
|
||||
|
||||
fn check_at(facts: &Facts, skip: usize) -> Result<Decision, Error> {
|
||||
if let Some(evaluations) = &facts.registry.evaluations {
|
||||
let Some(evaluation) = evaluations
|
||||
.iter()
|
||||
.skip(skip)
|
||||
.find(|evaluation| evaluation.grants == facts.grants)
|
||||
else {
|
||||
return Ok(Decision::ReadGroupScope {
|
||||
grants: facts.grants.clone(),
|
||||
});
|
||||
};
|
||||
let scoped = Facts {
|
||||
aliases: evaluation
|
||||
.aliases
|
||||
.clone()
|
||||
.unwrap_or_else(|| facts.aliases.clone()),
|
||||
..facts.clone()
|
||||
};
|
||||
return match crate::model_projection::decide(&scoped, &evaluation.reads)? {
|
||||
crate::model_projection::Decision::Complete { decision } => Ok(decision),
|
||||
_ => Err(Error::InvalidAnswer),
|
||||
};
|
||||
}
|
||||
if facts.fallback_depth >= facts.max_depth {
|
||||
return Ok(Decision::Depth { model: None });
|
||||
}
|
||||
for model in &facts.requested {
|
||||
if facts.is_list && facts.fallback_depth + 1 >= facts.max_depth {
|
||||
return Ok(Decision::Depth {
|
||||
model: Some(model.clone()),
|
||||
});
|
||||
}
|
||||
let (candidates, owning_names) = candidate_names(
|
||||
model,
|
||||
&facts.aliases,
|
||||
&facts.registry,
|
||||
facts.team_id.as_deref(),
|
||||
);
|
||||
let mut allowed = false;
|
||||
for candidate in candidates {
|
||||
if helper(&candidate, facts, true)? {
|
||||
allowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
for candidate in owning_names {
|
||||
if helper(&candidate, facts, false)? {
|
||||
allowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return Ok(Decision::Deny {
|
||||
model: model.clone(),
|
||||
grants: facts.grants.clone(),
|
||||
object_type: facts.object_type,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Decision::Allow)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub enum Request {
|
||||
Object(Facts),
|
||||
Grouped {
|
||||
facts: Facts,
|
||||
group_ids: Vec<String>,
|
||||
group_models: Option<Vec<String>>,
|
||||
merge: bool,
|
||||
},
|
||||
Helper {
|
||||
model: String,
|
||||
facts: Facts,
|
||||
},
|
||||
User {
|
||||
facts: Facts,
|
||||
present: bool,
|
||||
},
|
||||
UserPrecheck {
|
||||
present: bool,
|
||||
grants: Vec<String>,
|
||||
},
|
||||
Customer(Facts),
|
||||
Agent {
|
||||
facts: Facts,
|
||||
managed_grants: Option<Vec<String>>,
|
||||
ceilings: Vec<Vec<String>>,
|
||||
},
|
||||
Personal {
|
||||
requested: Vec<String>,
|
||||
teams: Vec<PersonalTeam>,
|
||||
},
|
||||
KeyGrants {
|
||||
grants: Vec<String>,
|
||||
team_id: Option<String>,
|
||||
team_grants: Vec<String>,
|
||||
},
|
||||
Candidates(Facts),
|
||||
PersonalApplies {
|
||||
enabled: bool,
|
||||
token_present: bool,
|
||||
team_id: Option<String>,
|
||||
user_id: Option<String>,
|
||||
master: bool,
|
||||
role: Option<String>,
|
||||
api_key_present: bool,
|
||||
inheritance: bool,
|
||||
},
|
||||
AssignedGroups {
|
||||
groups: Vec<AssignedGroup>,
|
||||
team_id: Option<String>,
|
||||
key_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AssignedGroup {
|
||||
pub assigned_team_ids: Vec<String>,
|
||||
pub assigned_key_ids: Vec<String>,
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PersonalTeam {
|
||||
pub blocked: bool,
|
||||
pub available: bool,
|
||||
pub team: Facts,
|
||||
pub member: Option<Facts>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Response {
|
||||
Decision(Decision),
|
||||
Models(Vec<String>),
|
||||
Predicate(bool),
|
||||
}
|
||||
|
||||
pub fn decide(request: Request) -> Result<Response, Error> {
|
||||
let decision = match request {
|
||||
Request::Object(facts) => check(&facts)?,
|
||||
Request::Grouped {
|
||||
facts,
|
||||
group_ids,
|
||||
group_models,
|
||||
merge,
|
||||
} => {
|
||||
let primary = check(&facts)?;
|
||||
if primary.allowed()
|
||||
|| !matches!(primary, Decision::Deny { .. })
|
||||
|| group_ids.is_empty()
|
||||
{
|
||||
primary
|
||||
} else if let Some(models) = group_models {
|
||||
if models.is_empty() {
|
||||
primary
|
||||
} else {
|
||||
check_at(
|
||||
&Facts {
|
||||
grants: if merge {
|
||||
unique(facts.grants.iter().cloned().chain(models))
|
||||
} else {
|
||||
models
|
||||
},
|
||||
..facts
|
||||
},
|
||||
1,
|
||||
)?
|
||||
}
|
||||
} else {
|
||||
Decision::ReadGroups
|
||||
}
|
||||
}
|
||||
Request::Helper { model, facts } => {
|
||||
return Ok(Response::Predicate(helper(&model, &facts, true)?));
|
||||
}
|
||||
Request::User { facts, present } => {
|
||||
if !present {
|
||||
Decision::Allow
|
||||
} else if facts
|
||||
.grants
|
||||
.iter()
|
||||
.any(|grant| grant == "no-default-models")
|
||||
{
|
||||
Decision::NoDefaultModels
|
||||
} else {
|
||||
check(&facts)?
|
||||
}
|
||||
}
|
||||
Request::UserPrecheck { present, grants } => {
|
||||
if !present {
|
||||
Decision::Allow
|
||||
} else if grants.iter().any(|grant| grant == "no-default-models") {
|
||||
Decision::NoDefaultModels
|
||||
} else {
|
||||
Decision::ReadScope
|
||||
}
|
||||
}
|
||||
Request::Customer(facts) => {
|
||||
for name in &facts.requested {
|
||||
let target = live_team_target(name, &facts);
|
||||
if target != *name && facts.grants.contains(name) {
|
||||
continue;
|
||||
}
|
||||
let customer = Facts {
|
||||
requested: vec![target],
|
||||
is_list: false,
|
||||
team_id: None,
|
||||
aliases: Aliases {
|
||||
key: facts.aliases.key.clone(),
|
||||
global: facts.aliases.global.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
..facts.clone()
|
||||
};
|
||||
let result = check(&customer)?;
|
||||
if !result.allowed() {
|
||||
return Ok(Response::Decision(result));
|
||||
}
|
||||
}
|
||||
Decision::Allow
|
||||
}
|
||||
Request::Agent {
|
||||
facts,
|
||||
managed_grants,
|
||||
ceilings,
|
||||
} => {
|
||||
if facts.requested.is_empty()
|
||||
|| (facts.requested.len() == 1 && !facts.is_list && facts.requested[0].is_empty())
|
||||
{
|
||||
return Ok(Response::Decision(Decision::Allow));
|
||||
}
|
||||
let dispatched = facts
|
||||
.requested
|
||||
.iter()
|
||||
.map(|name| live_team_target(name, &facts))
|
||||
.collect();
|
||||
let scope = Facts {
|
||||
requested: dispatched,
|
||||
aliases: Aliases {
|
||||
key: facts.aliases.key.clone(),
|
||||
global: facts.aliases.global.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
object_type: ObjectType::Agent,
|
||||
..facts
|
||||
};
|
||||
if let Some(grants) = managed_grants {
|
||||
if grants.is_empty() {
|
||||
return Ok(Response::Decision(Decision::NoAgentModels));
|
||||
}
|
||||
let result = check(&Facts {
|
||||
grants,
|
||||
..scope.clone()
|
||||
})?;
|
||||
if !result.allowed() {
|
||||
return Ok(Response::Decision(result));
|
||||
}
|
||||
}
|
||||
for grants in ceilings {
|
||||
if grants.is_empty() {
|
||||
return Ok(Response::Decision(Decision::EmptyAgentCeiling));
|
||||
}
|
||||
let result = check(&Facts {
|
||||
grants,
|
||||
..scope.clone()
|
||||
})?;
|
||||
if !result.allowed() {
|
||||
return Ok(Response::Decision(result));
|
||||
}
|
||||
}
|
||||
Decision::Allow
|
||||
}
|
||||
Request::Personal { requested, teams } => {
|
||||
for name in requested {
|
||||
let mut allowed = false;
|
||||
for team in &teams {
|
||||
if team.blocked || !team.available {
|
||||
continue;
|
||||
}
|
||||
if !check(&Facts {
|
||||
requested: vec![name.clone()],
|
||||
is_list: false,
|
||||
..team.team.clone()
|
||||
})?
|
||||
.allowed()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(member) = &team.member
|
||||
&& !check(&Facts {
|
||||
requested: vec![name.clone()],
|
||||
is_list: false,
|
||||
..member.clone()
|
||||
})?
|
||||
.allowed()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
allowed = true;
|
||||
break;
|
||||
}
|
||||
if !allowed {
|
||||
return Ok(Response::Decision(Decision::PersonalTeam { model: name }));
|
||||
}
|
||||
}
|
||||
Decision::Allow
|
||||
}
|
||||
Request::KeyGrants {
|
||||
grants,
|
||||
team_id,
|
||||
team_grants,
|
||||
} => {
|
||||
return Ok(Response::Models(resolve_key_grants(
|
||||
&grants,
|
||||
team_id.as_deref(),
|
||||
&team_grants,
|
||||
)));
|
||||
}
|
||||
Request::Candidates(facts) => {
|
||||
return Ok(Response::Models(unique(facts.requested.iter().flat_map(
|
||||
|model| {
|
||||
let (candidates, owned) = candidate_names(
|
||||
model,
|
||||
&facts.aliases,
|
||||
&facts.registry,
|
||||
facts.team_id.as_deref(),
|
||||
);
|
||||
candidates.into_iter().chain(owned)
|
||||
},
|
||||
))));
|
||||
}
|
||||
Request::PersonalApplies {
|
||||
enabled,
|
||||
token_present,
|
||||
team_id,
|
||||
user_id,
|
||||
master,
|
||||
role,
|
||||
api_key_present,
|
||||
inheritance,
|
||||
} => {
|
||||
return Ok(Response::Predicate(
|
||||
enabled
|
||||
&& token_present
|
||||
&& team_id.is_none()
|
||||
&& user_id.is_some()
|
||||
&& !master
|
||||
&& role.as_deref() != Some("proxy_admin")
|
||||
&& (!inheritance || api_key_present),
|
||||
));
|
||||
}
|
||||
Request::AssignedGroups {
|
||||
groups,
|
||||
team_id,
|
||||
key_id,
|
||||
} => {
|
||||
return Ok(Response::Models(unique(
|
||||
groups
|
||||
.into_iter()
|
||||
.filter(|group| {
|
||||
team_id.as_ref().is_some_and(|id| {
|
||||
!id.is_empty() && group.assigned_team_ids.contains(id)
|
||||
}) || key_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| !id.is_empty() && group.assigned_key_ids.contains(id))
|
||||
})
|
||||
.flat_map(|group| group.models),
|
||||
)));
|
||||
}
|
||||
};
|
||||
Ok(Response::Decision(decision))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::absent(false, vec!["no-default-models"], Decision::Allow)]
|
||||
#[case::restricted(true, vec!["no-default-models"], Decision::NoDefaultModels)]
|
||||
#[case::empty(true, vec![], Decision::ReadScope)]
|
||||
#[case::grants(true, vec!["model"], Decision::ReadScope)]
|
||||
fn user_precheck(#[case] present: bool, #[case] grants: Vec<&str>, #[case] expected: Decision) {
|
||||
let response = decide(Request::UserPrecheck {
|
||||
present,
|
||||
grants: grants.into_iter().map(str::to_owned).collect(),
|
||||
})
|
||||
.unwrap();
|
||||
let Response::Decision(decision) = response else {
|
||||
panic!("expected decision")
|
||||
};
|
||||
assert_eq!(decision, expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::empty(vec![], true)]
|
||||
#[case::wildcard(vec!["*"], true)]
|
||||
#[case::proxy(vec!["all-proxy-models"], true)]
|
||||
#[case::other(vec!["other"], false)]
|
||||
#[case::exact(vec!["model"], true)]
|
||||
fn model_grants(#[case] grants: Vec<&str>, #[case] expected: bool) {
|
||||
let facts = Facts {
|
||||
requested: vec!["model".into()],
|
||||
grants: grants.into_iter().map(str::to_owned).collect(),
|
||||
team_id: None,
|
||||
aliases: Aliases::default(),
|
||||
registry: Registry::default(),
|
||||
object_type: ObjectType::Key,
|
||||
fallback_depth: 0,
|
||||
max_depth: 10,
|
||||
is_list: false,
|
||||
};
|
||||
assert_eq!(check(&facts).unwrap().allowed(), expected);
|
||||
}
|
||||
}
|
||||
147
litellm-rust/crates/request-auth-core/src/model_attribution.rs
Normal file
147
litellm-rust/crates/request-auth-core/src/model_attribution.rs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
pub struct Facts {
|
||||
pub model_present: bool,
|
||||
pub token_present: bool,
|
||||
pub router_present: bool,
|
||||
pub database_present: bool,
|
||||
pub team_present: bool,
|
||||
pub user_present: bool,
|
||||
pub membership_loaded: bool,
|
||||
pub token_org: Option<String>,
|
||||
pub team_org: Option<String>,
|
||||
pub registry_loaded: bool,
|
||||
pub registry: Option<Vec<String>>,
|
||||
pub covering: Option<Vec<String>>,
|
||||
pub grants: Vec<Vec<String>>,
|
||||
pub org_failed: bool,
|
||||
pub io_failed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Decision {
|
||||
Complete { groups: Vec<String>, stamp: bool },
|
||||
ReadRegistry,
|
||||
ReadCovering,
|
||||
ReadKey,
|
||||
ReadTeam,
|
||||
ReadMember { lookup: bool, include: bool },
|
||||
ReadProject,
|
||||
ReadOrg { id: Option<String> },
|
||||
}
|
||||
|
||||
pub fn decide(facts: &Facts) -> Decision {
|
||||
let empty = || Decision::Complete {
|
||||
groups: vec![],
|
||||
stamp: false,
|
||||
};
|
||||
if facts.io_failed
|
||||
|| !facts.model_present
|
||||
|| !facts.token_present
|
||||
|| !facts.router_present
|
||||
|| !facts.database_present
|
||||
{
|
||||
return empty();
|
||||
}
|
||||
if !facts.registry_loaded {
|
||||
return Decision::ReadRegistry;
|
||||
}
|
||||
if facts.registry.as_ref().is_some_and(Vec::is_empty) {
|
||||
return empty();
|
||||
}
|
||||
let Some(covering) = &facts.covering else {
|
||||
return Decision::ReadCovering;
|
||||
};
|
||||
let budgeted: BTreeSet<_> = covering
|
||||
.iter()
|
||||
.filter(|group| {
|
||||
facts
|
||||
.registry
|
||||
.as_ref()
|
||||
.is_none_or(|registry| registry.contains(group))
|
||||
})
|
||||
.collect();
|
||||
if budgeted.is_empty() {
|
||||
return empty();
|
||||
}
|
||||
match facts.grants.len() {
|
||||
0 => return Decision::ReadKey,
|
||||
1 => return Decision::ReadTeam,
|
||||
2 => {
|
||||
return Decision::ReadMember {
|
||||
lookup: facts.team_present && facts.user_present && !facts.membership_loaded,
|
||||
include: facts.team_present && facts.user_present,
|
||||
};
|
||||
}
|
||||
3 => return Decision::ReadProject,
|
||||
4 => {
|
||||
return Decision::ReadOrg {
|
||||
id: facts
|
||||
.token_org
|
||||
.as_ref()
|
||||
.filter(|id| !id.is_empty())
|
||||
.or(facts.team_org.as_ref())
|
||||
.cloned(),
|
||||
};
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
let granted: BTreeSet<_> = facts
|
||||
.grants
|
||||
.iter()
|
||||
.take(if facts.org_failed { 4 } else { 5 })
|
||||
.flatten()
|
||||
.collect();
|
||||
let groups: Vec<_> = budgeted
|
||||
.intersection(&granted)
|
||||
.map(|group| (*group).clone())
|
||||
.collect();
|
||||
Decision::Complete {
|
||||
stamp: !groups.is_empty(),
|
||||
groups,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::unknown(None, vec!["a", "b"])]
|
||||
#[case::known(Some(vec!["a".into()]), vec!["a"])]
|
||||
#[case::empty(Some(vec![]), vec![])]
|
||||
fn intersects_budgeted_covering_and_granted(
|
||||
#[case] registry: Option<Vec<String>>,
|
||||
#[case] expected: Vec<&str>,
|
||||
) {
|
||||
let facts = Facts {
|
||||
model_present: true,
|
||||
token_present: true,
|
||||
router_present: true,
|
||||
database_present: true,
|
||||
registry_loaded: true,
|
||||
registry,
|
||||
covering: Some(vec!["b".into(), "a".into(), "b".into()]),
|
||||
grants: vec![
|
||||
vec!["a".into()],
|
||||
vec![],
|
||||
vec!["b".into()],
|
||||
vec![],
|
||||
vec!["c".into()],
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
decide(&facts),
|
||||
Decision::Complete {
|
||||
stamp: !expected.is_empty(),
|
||||
groups: expected.into_iter().map(str::to_owned).collect()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
263
litellm-rust/crates/request-auth-core/src/model_budget_data.rs
Normal file
263
litellm-rust/crates/request-auth-core/src/model_budget_data.rs
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
use std::cmp::Ordering::{Equal, Greater, Less};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct Entry {
|
||||
pub name: String,
|
||||
pub max_budget: Option<f64>,
|
||||
pub duration: Option<String>,
|
||||
pub duration_seconds: Option<f64>,
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
fn usable(&self) -> bool {
|
||||
self.duration.is_some() && self.duration_seconds.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ResolveFacts {
|
||||
pub model: String,
|
||||
pub bedrock_base: String,
|
||||
pub bedrock_provider: String,
|
||||
pub entries: Vec<Entry>,
|
||||
pub key_entries: Vec<Entry>,
|
||||
pub team: bool,
|
||||
pub access_groups: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Resolution {
|
||||
pub read_groups: bool,
|
||||
pub entry: Option<Entry>,
|
||||
pub named_model: bool,
|
||||
}
|
||||
|
||||
fn candidates(facts: &ResolveFacts) -> Vec<&str> {
|
||||
let base_names = if facts.bedrock_provider.starts_with("bedrock") {
|
||||
vec![
|
||||
facts.bedrock_base.as_str(),
|
||||
facts.bedrock_base.split_once('.').map_or("", |(_, s)| s),
|
||||
]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
std::iter::once(facts.model.as_str())
|
||||
.chain(std::iter::once(
|
||||
facts.model.rsplit('/').next().unwrap_or(&facts.model),
|
||||
))
|
||||
.chain(base_names.into_iter().filter(|s| !s.is_empty()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn exact<'a>(entries: &'a [Entry], names: &[&str]) -> Option<&'a Entry> {
|
||||
names.iter().find_map(|name| {
|
||||
entries
|
||||
.iter()
|
||||
.find(|entry| entry.name == *name && entry.usable())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve(facts: &ResolveFacts) -> Resolution {
|
||||
let names = candidates(facts);
|
||||
let key_replaces = facts.team
|
||||
&& exact(&facts.key_entries, &names)
|
||||
.is_some_and(|entry| entry.max_budget.is_some_and(|limit| limit >= 0.0));
|
||||
if !key_replaces && let Some(entry) = exact(&facts.entries, &names) {
|
||||
return Resolution {
|
||||
read_groups: false,
|
||||
entry: Some(entry.clone()),
|
||||
named_model: true,
|
||||
};
|
||||
}
|
||||
let group_entries: Vec<&Entry> = facts
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|entry| !entry.name.contains('*') && !names.contains(&entry.name.as_str()))
|
||||
.collect();
|
||||
if !group_entries.is_empty() && facts.access_groups.is_none() {
|
||||
return Resolution {
|
||||
read_groups: true,
|
||||
entry: None,
|
||||
named_model: false,
|
||||
};
|
||||
}
|
||||
let group = group_entries
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
entry.usable()
|
||||
&& facts
|
||||
.access_groups
|
||||
.as_ref()
|
||||
.is_some_and(|groups| groups.contains(&entry.name))
|
||||
})
|
||||
.min_by(|a, b| a.name.cmp(&b.name));
|
||||
if let Some(entry) = group {
|
||||
return Resolution {
|
||||
read_groups: false,
|
||||
entry: Some(entry.clone()),
|
||||
named_model: false,
|
||||
};
|
||||
}
|
||||
let entry = facts
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
entry.usable()
|
||||
&& !(key_replaces && names.contains(&entry.name.as_str()))
|
||||
&& entry.name.contains('*')
|
||||
&& names.iter().any(|name| glob(&entry.name, name))
|
||||
})
|
||||
.max_by_key(|entry| {
|
||||
let wildcards = entry.name.chars().filter(|c| *c == '*').count();
|
||||
(
|
||||
entry.name.chars().count() - wildcards,
|
||||
std::cmp::Reverse(wildcards),
|
||||
&entry.name,
|
||||
)
|
||||
})
|
||||
.cloned();
|
||||
Resolution {
|
||||
read_groups: false,
|
||||
entry,
|
||||
named_model: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn glob(pattern: &str, name: &str) -> bool {
|
||||
let parts: Vec<&str> = pattern.split('*').collect();
|
||||
if parts.len() == 1 {
|
||||
return pattern == name;
|
||||
}
|
||||
let head = parts[0];
|
||||
let tail = parts[parts.len() - 1];
|
||||
if !name.starts_with(head) || !name.ends_with(tail) || head.len() + tail.len() > name.len() {
|
||||
return false;
|
||||
}
|
||||
parts[1..parts.len() - 1]
|
||||
.iter()
|
||||
.try_fold(head.len(), |position, piece| {
|
||||
name[position..name.len() - tail.len()]
|
||||
.find(piece)
|
||||
.map(|index| position + index + piece.len())
|
||||
})
|
||||
.is_some()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, strum::VariantArray)]
|
||||
pub enum Entity {
|
||||
Key,
|
||||
User,
|
||||
Team,
|
||||
TeamMember,
|
||||
EndUser,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct Snapshot {
|
||||
pub max_budget: Option<f64>,
|
||||
pub local_spend: Option<f64>,
|
||||
pub legacy_spend: Option<f64>,
|
||||
pub shared_spend: Option<f64>,
|
||||
pub shared_started_at: Option<f64>,
|
||||
pub shared_window_seconds: Option<f64>,
|
||||
pub now: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct QueryFacts {
|
||||
pub entity: Entity,
|
||||
pub snapshot: Snapshot,
|
||||
pub duration_seconds: Option<f64>,
|
||||
pub process_uptime: f64,
|
||||
pub same_counter: bool,
|
||||
pub redis_present: bool,
|
||||
pub shared_read: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, strum::VariantArray)]
|
||||
pub enum Read {
|
||||
Local,
|
||||
Legacy,
|
||||
Shared,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct Decision {
|
||||
pub read: Option<Read>,
|
||||
pub denied: bool,
|
||||
pub current_spend: f64,
|
||||
}
|
||||
|
||||
pub fn check(snapshot: &Snapshot) -> Decision {
|
||||
let local = snapshot.local_spend.unwrap_or(0.0) + snapshot.legacy_spend.unwrap_or(0.0);
|
||||
let current_spend = match (
|
||||
snapshot.shared_spend,
|
||||
snapshot.shared_started_at,
|
||||
snapshot.shared_window_seconds,
|
||||
) {
|
||||
(Some(spend), Some(start), Some(seconds))
|
||||
if snapshot.now < start + seconds && spend > local =>
|
||||
{
|
||||
spend
|
||||
}
|
||||
_ => local,
|
||||
};
|
||||
Decision {
|
||||
read: None,
|
||||
denied: snapshot
|
||||
.max_budget
|
||||
.is_some_and(|limit| limit.partial_cmp(&0.0) != Some(Less) && current_spend >= limit),
|
||||
current_spend,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn query(facts: &QueryFacts) -> Decision {
|
||||
let done = check(&facts.snapshot);
|
||||
let Some(limit) = facts.snapshot.max_budget else {
|
||||
return done;
|
||||
};
|
||||
if limit < 0.0 {
|
||||
return done;
|
||||
}
|
||||
let read = if facts.snapshot.local_spend.is_none() {
|
||||
Some(Read::Local)
|
||||
} else if matches!(facts.entity, Entity::Key | Entity::EndUser)
|
||||
&& facts
|
||||
.duration_seconds
|
||||
.is_some_and(|seconds| facts.process_uptime < seconds)
|
||||
&& !facts.same_counter
|
||||
&& facts.snapshot.legacy_spend.is_none()
|
||||
{
|
||||
Some(Read::Legacy)
|
||||
} else if !matches!(
|
||||
(facts.snapshot.local_spend.unwrap_or(0.0) + facts.snapshot.legacy_spend.unwrap_or(0.0))
|
||||
.partial_cmp(&limit),
|
||||
Some(Greater | Equal)
|
||||
) && !facts.redis_present
|
||||
&& facts.entity != Entity::EndUser
|
||||
&& !facts.shared_read
|
||||
{
|
||||
Some(Read::Shared)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Decision { read, ..done }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::empty("*", "", true)]
|
||||
#[case::unicode("é*界", "é世世界", true)]
|
||||
#[case::overlap("ab*bc", "abc", false)]
|
||||
#[case::middle("a*b*c", "axybzc", true)]
|
||||
#[case::not_regex("a?*", "ab", false)]
|
||||
fn wildcard(#[case] pattern: &str, #[case] name: &str, #[case] matches: bool) {
|
||||
assert_eq!(glob(pattern, name), matches);
|
||||
}
|
||||
}
|
||||
|
|
@ -53,7 +53,8 @@ fn key_models(
|
|||
Effect::CheckKeyModelBudget {
|
||||
model: model.clone(),
|
||||
},
|
||||
move |exceeded: Option<ModelBudgetExceeded>| {
|
||||
move |reading: ModelBudgetRead| {
|
||||
let exceeded = reading.exceeded();
|
||||
let Some(exceeded) = exceeded else {
|
||||
return key_models(fallbacks, rest, rewritable, then);
|
||||
};
|
||||
|
|
@ -86,7 +87,8 @@ fn first_affordable(
|
|||
Effect::CheckKeyModelBudget {
|
||||
model: candidate.clone(),
|
||||
},
|
||||
move |over: Option<ModelBudgetExceeded>| {
|
||||
move |reading: ModelBudgetRead| {
|
||||
let over = reading.exceeded();
|
||||
if over.is_some() {
|
||||
return first_affordable(rest, exceeded, found);
|
||||
}
|
||||
|
|
@ -94,9 +96,16 @@ fn first_affordable(
|
|||
Effect::AuthorizeBudgetFallback {
|
||||
model: candidate.clone(),
|
||||
},
|
||||
move |authorized: bool| {
|
||||
if !authorized {
|
||||
return Step::deny(exceeded);
|
||||
move |facts: crate::fallback_access::Facts| {
|
||||
match crate::fallback_access::decide(facts)? {
|
||||
crate::fallback_access::Decision::Allow => (),
|
||||
crate::fallback_access::Decision::Deny {
|
||||
denial: Some(denial @ crate::model_access::Decision::Depth { .. }),
|
||||
} => return Step::deny(Denial::ModelAccessDenied { decision: denial }),
|
||||
crate::fallback_access::Decision::Deny { .. } => {
|
||||
return Step::deny(exceeded);
|
||||
}
|
||||
_ => return Err(Error::InvalidAnswer),
|
||||
}
|
||||
Step::effect(
|
||||
Effect::RewriteRequestModel {
|
||||
|
|
@ -122,7 +131,7 @@ fn user_models(enabled: bool, models: Vec<String>, then: Then) -> Result<Step, E
|
|||
Effect::CheckUserModelBudget {
|
||||
model: model.clone(),
|
||||
},
|
||||
move |exceeded: Option<ModelBudgetExceeded>| match exceeded {
|
||||
move |reading: ModelBudgetRead| match reading.exceeded() {
|
||||
Some(exceeded) => Step::deny(exceeded),
|
||||
None => user_models(enabled, rest, then),
|
||||
},
|
||||
|
|
@ -155,7 +164,8 @@ fn member_models(
|
|||
Effect::CheckTeamMemberModelBudget {
|
||||
model: model.clone(),
|
||||
},
|
||||
move |exceeded: Option<ModelBudgetExceeded>| {
|
||||
move |reading: ModelBudgetRead| {
|
||||
let exceeded = reading.exceeded();
|
||||
let Some(exceeded) = exceeded else {
|
||||
return member_models(rest, rewritable, can_fall_back);
|
||||
};
|
||||
|
|
@ -197,7 +207,12 @@ fn first_usable(
|
|||
Effect::VetTeamMemberFallback {
|
||||
target: target.clone(),
|
||||
},
|
||||
move |usable: bool| {
|
||||
move |facts: crate::fallback_candidate::Facts| {
|
||||
let crate::fallback_candidate::Decision::Complete { usable, .. } =
|
||||
crate::fallback_candidate::decide(&facts)?
|
||||
else {
|
||||
return Err(Error::InvalidAnswer);
|
||||
};
|
||||
if !usable {
|
||||
return first_usable(rest, exceeded, found);
|
||||
}
|
||||
|
|
@ -234,11 +249,42 @@ mod tests {
|
|||
assert!(
|
||||
matches!(read.node, Node::Effect(Effect::CheckKeyModelBudget { ref model }) if model == "first")
|
||||
);
|
||||
let authorize = (read.resume.unwrap())(Answer::Exceeded(None)).unwrap();
|
||||
let authorize = (read.resume.unwrap())(Answer::ModelBudgetData(ModelBudgetRead {
|
||||
snapshot: crate::model_budget_data::Snapshot {
|
||||
max_budget: None,
|
||||
local_spend: None,
|
||||
legacy_spend: None,
|
||||
shared_spend: None,
|
||||
shared_started_at: None,
|
||||
shared_window_seconds: None,
|
||||
now: 0.0,
|
||||
},
|
||||
message: String::new(),
|
||||
entity_type: None,
|
||||
entity_id: None,
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(authorize.node, Node::Effect(Effect::AuthorizeBudgetFallback { ref model }) if model == "first")
|
||||
);
|
||||
let result = (authorize.resume.unwrap())(Answer::Bool(false)).unwrap();
|
||||
let result = (authorize.resume.unwrap())(Answer::FallbackAccess(Box::new(
|
||||
crate::fallback_access::Facts {
|
||||
refuse_agent_keys: true,
|
||||
agent_id: Some("agent".into()),
|
||||
key: None,
|
||||
team: None,
|
||||
embedded_team: None,
|
||||
assigned_models: None,
|
||||
assigned_scope: None,
|
||||
member: None,
|
||||
project: None,
|
||||
team_present: false,
|
||||
embedded_team_grants: vec![],
|
||||
member_grants: vec![],
|
||||
project_grants: vec![],
|
||||
},
|
||||
)))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
result.node,
|
||||
Node::Outcome(Outcome::Deny {
|
||||
|
|
|
|||
73
litellm-rust/crates/request-auth-core/src/model_groups.rs
Normal file
73
litellm-rust/crates/request-auth-core/src/model_groups.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Group {
|
||||
pub assigned_team_ids: Vec<String>,
|
||||
pub assigned_key_ids: Vec<String>,
|
||||
pub resources: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "kind", content = "group")]
|
||||
pub enum Lookup {
|
||||
Found(Group),
|
||||
Missing,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Facts {
|
||||
pub subject_present: bool,
|
||||
pub database_present: bool,
|
||||
pub cache_present: bool,
|
||||
pub group_ids: Vec<String>,
|
||||
pub team_id: Option<String>,
|
||||
pub key_id: Option<String>,
|
||||
pub check_db_only: bool,
|
||||
pub strict_errors: bool,
|
||||
pub loaded: Vec<Lookup>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Decision {
|
||||
Read { index: usize },
|
||||
Raise { index: usize },
|
||||
Complete { resources: Vec<String> },
|
||||
}
|
||||
|
||||
pub fn decide(facts: Facts) -> Decision {
|
||||
if !facts.subject_present || !facts.database_present || !facts.cache_present {
|
||||
return Decision::Complete { resources: vec![] };
|
||||
}
|
||||
let mut resources = BTreeSet::new();
|
||||
for index in 0..facts.group_ids.len() {
|
||||
let Some(loaded) = facts.loaded.get(index) else {
|
||||
return Decision::Read { index };
|
||||
};
|
||||
match loaded {
|
||||
Lookup::Missing | Lookup::Failed if facts.check_db_only || facts.strict_errors => {
|
||||
return Decision::Raise { index };
|
||||
}
|
||||
Lookup::Missing | Lookup::Failed => {}
|
||||
Lookup::Found(group) => {
|
||||
if facts
|
||||
.team_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| !id.is_empty() && group.assigned_team_ids.contains(id))
|
||||
|| facts
|
||||
.key_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| !id.is_empty() && group.assigned_key_ids.contains(id))
|
||||
{
|
||||
resources.extend(group.resources.iter().cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Decision::Complete {
|
||||
resources: resources.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
148
litellm-rust/crates/request-auth-core/src/model_helpers.rs
Normal file
148
litellm-rust/crates/request-auth-core/src/model_helpers.rs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
use crate::{Error, model_access::Provider, patterns};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Clone, Copy, Deserialize, PartialEq, Eq, strum::VariantArray)]
|
||||
pub enum Kind {
|
||||
Group,
|
||||
Helper,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Facts {
|
||||
pub kind: Kind,
|
||||
pub model: String,
|
||||
pub grants: Option<Vec<String>>,
|
||||
pub team_id: Option<String>,
|
||||
pub team_aliases: Vec<String>,
|
||||
pub router_present: bool,
|
||||
pub groups: Option<Vec<String>>,
|
||||
pub names: Option<Vec<String>>,
|
||||
pub provider_loaded: bool,
|
||||
pub provider: Option<Provider>,
|
||||
pub provider_reads: Option<Vec<Option<Provider>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Decision {
|
||||
Complete { allowed: bool },
|
||||
ReadGroups,
|
||||
ReadNames,
|
||||
ReadProvider,
|
||||
}
|
||||
|
||||
pub fn decide(facts: &Facts) -> Result<Decision, Error> {
|
||||
let done = |allowed| Ok(Decision::Complete { allowed });
|
||||
if facts.kind == Kind::Group
|
||||
&& facts
|
||||
.grants
|
||||
.as_ref()
|
||||
.is_none_or(|grants| grants.contains(&facts.model))
|
||||
{
|
||||
return done(true);
|
||||
}
|
||||
let grants = facts.grants.as_ref().ok_or(Error::InvalidAnswer)?;
|
||||
if facts.router_present && facts.groups.is_none() {
|
||||
return Ok(Decision::ReadGroups);
|
||||
}
|
||||
let groups = facts.groups.as_deref().unwrap_or_default();
|
||||
if facts.kind == Kind::Group {
|
||||
return done(grants.iter().any(|grant| groups.contains(grant)));
|
||||
}
|
||||
let expanded = if facts.router_present
|
||||
&& facts.team_id.is_some()
|
||||
&& grants.iter().any(|grant| grant == "all-team-models")
|
||||
{
|
||||
let Some(names) = &facts.names else {
|
||||
return Ok(Decision::ReadNames);
|
||||
};
|
||||
let mut seen = BTreeSet::new();
|
||||
let values: Vec<_> = grants
|
||||
.iter()
|
||||
.filter(|grant| *grant != "all-team-models")
|
||||
.chain(names)
|
||||
.filter(|grant| seen.insert(*grant))
|
||||
.cloned()
|
||||
.collect();
|
||||
if values.is_empty() {
|
||||
grants.clone()
|
||||
} else {
|
||||
values
|
||||
}
|
||||
} else {
|
||||
grants.clone()
|
||||
};
|
||||
if expanded.iter().any(|grant| groups.contains(grant))
|
||||
|| facts.team_aliases.contains(&facts.model)
|
||||
{
|
||||
return done(true);
|
||||
}
|
||||
let filtered: Vec<_> = expanded
|
||||
.iter()
|
||||
.filter(|grant| !groups.contains(grant))
|
||||
.collect();
|
||||
let wildcards: Vec<_> = filtered
|
||||
.iter()
|
||||
.filter(|grant| grant.contains('*'))
|
||||
.collect();
|
||||
for pattern in &wildcards {
|
||||
if patterns::model_pattern(&facts.model, pattern)? {
|
||||
return done(true);
|
||||
}
|
||||
}
|
||||
for (index, pattern) in wildcards.iter().enumerate() {
|
||||
let provider = if let Some(providers) = &facts.provider_reads {
|
||||
let Some(provider) = providers.get(index) else {
|
||||
return Ok(Decision::ReadProvider);
|
||||
};
|
||||
provider
|
||||
} else {
|
||||
if !facts.provider_loaded {
|
||||
return Ok(Decision::ReadProvider);
|
||||
}
|
||||
&facts.provider
|
||||
};
|
||||
if let Some(provider) = provider
|
||||
&& (provider.stripped_model != facts.model || !facts.model.contains('/'))
|
||||
&& patterns::model_pattern(
|
||||
&format!("{}/{}", provider.provider, provider.stripped_model),
|
||||
pattern,
|
||||
)?
|
||||
{
|
||||
return done(true);
|
||||
}
|
||||
}
|
||||
done(
|
||||
(filtered.is_empty() && expanded.is_empty())
|
||||
|| filtered.iter().any(|grant| {
|
||||
matches!(grant.as_str(), "*" | "all-proxy-models") || **grant == facts.model
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::group(Kind::Group, Decision::Complete { allowed: true })]
|
||||
#[case::helper(Kind::Helper, Decision::ReadGroups)]
|
||||
fn direct_match_read_order(#[case] kind: Kind, #[case] expected: Decision) {
|
||||
let facts = Facts {
|
||||
kind,
|
||||
model: "model".into(),
|
||||
grants: Some(vec!["model".into()]),
|
||||
team_id: None,
|
||||
team_aliases: vec![],
|
||||
router_present: true,
|
||||
groups: None,
|
||||
names: None,
|
||||
provider_loaded: false,
|
||||
provider: None,
|
||||
provider_reads: None,
|
||||
};
|
||||
assert_eq!(decide(&facts).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
237
litellm-rust/crates/request-auth-core/src/model_projection.rs
Normal file
237
litellm-rust/crates/request-auth-core/src/model_projection.rs
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
use crate::{
|
||||
Error,
|
||||
model_access::{self, Decision as ModelDecision, Facts, ModelRecord, Provider, Registry},
|
||||
model_helpers,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, strum::VariantArray)]
|
||||
pub enum ReadKind {
|
||||
Compaction,
|
||||
Aliases,
|
||||
AliasTarget,
|
||||
Deployment,
|
||||
Groups,
|
||||
Names,
|
||||
Provider,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Reading {
|
||||
pub kind: ReadKind,
|
||||
pub model: String,
|
||||
pub values: Vec<String>,
|
||||
pub value: Option<String>,
|
||||
pub team: Option<String>,
|
||||
pub provider: Option<Provider>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Evaluation {
|
||||
pub grants: Vec<String>,
|
||||
pub reads: Vec<Reading>,
|
||||
pub aliases: Option<model_access::Aliases>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Decision {
|
||||
Complete { decision: ModelDecision },
|
||||
Read { read: ReadKind, model: String },
|
||||
}
|
||||
|
||||
enum Pending {
|
||||
Read(ReadKind, String),
|
||||
Invalid(Error),
|
||||
}
|
||||
impl From<Error> for Pending {
|
||||
fn from(error: Error) -> Self {
|
||||
Self::Invalid(error)
|
||||
}
|
||||
}
|
||||
|
||||
struct Reader<'a> {
|
||||
reads: &'a [Reading],
|
||||
index: usize,
|
||||
}
|
||||
impl<'a> Reader<'a> {
|
||||
fn read(&mut self, kind: ReadKind, model: &str) -> Result<&'a Reading, Pending> {
|
||||
let Some(value) = self.reads.get(self.index) else {
|
||||
return Err(Pending::Read(kind, model.to_owned()));
|
||||
};
|
||||
if value.kind != kind || value.model != model {
|
||||
return Err(Pending::Invalid(Error::InvalidAnswer));
|
||||
}
|
||||
self.index += 1;
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn helper(
|
||||
model: &str,
|
||||
facts: &Facts,
|
||||
aliases: bool,
|
||||
reader: &mut Reader<'_>,
|
||||
) -> Result<bool, Pending> {
|
||||
let mut helper = model_helpers::Facts {
|
||||
kind: model_helpers::Kind::Helper,
|
||||
model: model.to_owned(),
|
||||
grants: Some(facts.grants.clone()),
|
||||
team_id: facts.team_id.clone(),
|
||||
team_aliases: if aliases {
|
||||
facts.aliases.team.keys().cloned().collect()
|
||||
} else {
|
||||
vec![]
|
||||
},
|
||||
router_present: facts
|
||||
.registry
|
||||
.helper_present
|
||||
.unwrap_or(facts.registry.present),
|
||||
groups: None,
|
||||
names: None,
|
||||
provider_loaded: false,
|
||||
provider: None,
|
||||
provider_reads: Some(vec![]),
|
||||
};
|
||||
loop {
|
||||
match model_helpers::decide(&helper)? {
|
||||
model_helpers::Decision::Complete { allowed } => return Ok(allowed),
|
||||
model_helpers::Decision::ReadGroups => {
|
||||
helper.groups = Some(reader.read(ReadKind::Groups, model)?.values.clone())
|
||||
}
|
||||
model_helpers::Decision::ReadNames => {
|
||||
helper.names = Some(reader.read(ReadKind::Names, model)?.values.clone())
|
||||
}
|
||||
model_helpers::Decision::ReadProvider => {
|
||||
helper
|
||||
.provider_reads
|
||||
.as_mut()
|
||||
.ok_or(Error::InvalidAnswer)?
|
||||
.push(reader.read(ReadKind::Provider, model)?.provider.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_one(model: &str, facts: &Facts, reader: &mut Reader<'_>) -> Result<bool, Pending> {
|
||||
let compaction = reader.read(ReadKind::Compaction, model)?.value.clone();
|
||||
let mut registry = Registry {
|
||||
present: facts.registry.present,
|
||||
..Registry::default()
|
||||
};
|
||||
if let Some(parent) = compaction {
|
||||
registry.compaction_parents.insert(model.to_owned(), parent);
|
||||
}
|
||||
if !facts.aliases.global.contains_key(model) && registry.present {
|
||||
let aliases = reader.read(ReadKind::Aliases, model)?;
|
||||
if aliases.values.iter().any(|alias| alias == model)
|
||||
&& let Some(target) = &reader.read(ReadKind::AliasTarget, model)?.value
|
||||
{
|
||||
registry.aliases.insert(model.to_owned(), target.clone());
|
||||
}
|
||||
}
|
||||
let after_team = facts.aliases.team.get(model).map_or(model, String::as_str);
|
||||
let after_key = facts
|
||||
.aliases
|
||||
.key
|
||||
.get(after_team)
|
||||
.map_or(after_team, String::as_str);
|
||||
let after_global = facts
|
||||
.aliases
|
||||
.global
|
||||
.get(after_key)
|
||||
.map_or(after_key, String::as_str);
|
||||
let dispatched = facts
|
||||
.aliases
|
||||
.key
|
||||
.get(after_global)
|
||||
.map_or(after_global, String::as_str);
|
||||
let key_alias = after_key != after_team || dispatched != after_global;
|
||||
if !key_alias
|
||||
&& registry.present
|
||||
&& facts
|
||||
.team_id
|
||||
.as_ref()
|
||||
.is_some_and(|team| model.starts_with(&format!("model_name_{team}_")))
|
||||
{
|
||||
let deployment = reader.read(ReadKind::Deployment, model)?;
|
||||
registry.records.insert(
|
||||
model.to_owned(),
|
||||
ModelRecord {
|
||||
deployment_team: deployment.team.clone(),
|
||||
deployment_public_name: deployment.value.clone(),
|
||||
..ModelRecord::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
let (candidates, owning) =
|
||||
model_access::candidate_names(model, &facts.aliases, ®istry, facts.team_id.as_deref());
|
||||
for candidate in candidates {
|
||||
if helper(&candidate, facts, true, reader)? {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
for candidate in owning {
|
||||
if helper(&candidate, facts, false, reader)? {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn check(facts: &Facts, reader: &mut Reader<'_>) -> Result<ModelDecision, Pending> {
|
||||
if facts.fallback_depth >= facts.max_depth {
|
||||
return Ok(ModelDecision::Depth { model: None });
|
||||
}
|
||||
for model in &facts.requested {
|
||||
if facts.is_list && facts.fallback_depth + 1 >= facts.max_depth {
|
||||
return Ok(ModelDecision::Depth {
|
||||
model: Some(model.clone()),
|
||||
});
|
||||
}
|
||||
if !check_one(model, facts, reader)? {
|
||||
return Ok(ModelDecision::Deny {
|
||||
model: model.clone(),
|
||||
grants: facts.grants.clone(),
|
||||
object_type: facts.object_type,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(ModelDecision::Allow)
|
||||
}
|
||||
|
||||
pub fn decide(facts: &Facts, reads: &[Reading]) -> Result<Decision, Error> {
|
||||
let mut reader = Reader { reads, index: 0 };
|
||||
match check(facts, &mut reader) {
|
||||
Ok(decision) if reader.index == reads.len() => Ok(Decision::Complete { decision }),
|
||||
Ok(_) => Err(Error::InvalidAnswer),
|
||||
Err(Pending::Read(read, model)) => Ok(Decision::Read { read, model }),
|
||||
Err(Pending::Invalid(error)) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
#[rstest]
|
||||
#[case::empty(vec![], true)]
|
||||
#[case::model(vec!["model".to_owned()], false)]
|
||||
fn empty_list_never_reads_registry(#[case] requested: Vec<String>, #[case] complete: bool) {
|
||||
let facts = Facts {
|
||||
requested,
|
||||
grants: vec![],
|
||||
team_id: None,
|
||||
aliases: Default::default(),
|
||||
registry: Default::default(),
|
||||
object_type: model_access::ObjectType::Key,
|
||||
fallback_depth: 0,
|
||||
max_depth: 5,
|
||||
is_list: true,
|
||||
};
|
||||
assert_eq!(
|
||||
matches!(decide(&facts, &[]).unwrap(), Decision::Complete { .. }),
|
||||
complete
|
||||
);
|
||||
}
|
||||
}
|
||||
304
litellm-rust/crates/request-auth-core/src/org_routes.rs
Normal file
304
litellm-rust/crates/request-auth-core/src/org_routes.rs
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Membership {
|
||||
pub organization_id: Option<String>,
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Facts {
|
||||
pub route: String,
|
||||
pub user_present: bool,
|
||||
pub user_role: Option<String>,
|
||||
pub memberships: Option<Vec<Membership>>,
|
||||
pub organization_id: Option<String>,
|
||||
pub org_admin_routes: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub organization_id_present: bool,
|
||||
#[serde(default)]
|
||||
pub identifier_pending: bool,
|
||||
#[serde(default)]
|
||||
pub hash_failure: Option<String>,
|
||||
#[serde(default)]
|
||||
pub request_pending: bool,
|
||||
#[serde(default)]
|
||||
pub memberships_pending: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, strum::VariantArray)]
|
||||
pub enum Denial {
|
||||
CreateOrganization,
|
||||
NoMemberships,
|
||||
MissingOrganization,
|
||||
MissingRole,
|
||||
WrongRole,
|
||||
TeamMissingOrganization,
|
||||
TeamWrongRole,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct Decision {
|
||||
pub denial: Option<Denial>,
|
||||
pub organization_role: Option<String>,
|
||||
pub organization_ids: Vec<String>,
|
||||
pub read_identifier: bool,
|
||||
pub hash_failure: Option<String>,
|
||||
pub read_request: bool,
|
||||
pub read_memberships: bool,
|
||||
}
|
||||
|
||||
pub fn decide(facts: Facts) -> Decision {
|
||||
if facts.user_present && facts.request_pending {
|
||||
return Decision {
|
||||
read_request: true,
|
||||
..Decision::default()
|
||||
};
|
||||
}
|
||||
if facts.user_present
|
||||
&& facts.user_role.as_deref() != Some("proxy_admin")
|
||||
&& facts.route != "/organization/new"
|
||||
&& (facts.org_admin_routes.contains(&facts.route) || facts.route == "/team/new")
|
||||
&& facts.memberships_pending
|
||||
{
|
||||
return Decision {
|
||||
read_memberships: true,
|
||||
..Decision::default()
|
||||
};
|
||||
}
|
||||
let memberships = facts.memberships.as_deref().unwrap_or(&[]);
|
||||
let ids = memberships
|
||||
.iter()
|
||||
.filter_map(|item| item.organization_id.clone())
|
||||
.collect();
|
||||
let role = memberships
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|item| {
|
||||
item.organization_id.is_some() && item.organization_id == facts.organization_id
|
||||
})
|
||||
.and_then(|item| item.role.clone());
|
||||
let present = facts.organization_id_present || facts.organization_id.is_some();
|
||||
let needs_lookup = facts.user_present
|
||||
&& facts.user_role.as_deref() != Some("proxy_admin")
|
||||
&& facts.route != "/organization/new"
|
||||
&& present
|
||||
&& ((facts.org_admin_routes.contains(&facts.route) && facts.memberships.is_some())
|
||||
|| facts.route == "/team/new" && !memberships.is_empty());
|
||||
if needs_lookup && (facts.identifier_pending || facts.hash_failure.is_some()) {
|
||||
return Decision {
|
||||
denial: None,
|
||||
organization_role: role,
|
||||
organization_ids: ids,
|
||||
read_identifier: facts.identifier_pending,
|
||||
hash_failure: facts.hash_failure,
|
||||
..Decision::default()
|
||||
};
|
||||
}
|
||||
let denial = if !facts.user_present || facts.user_role.as_deref() == Some("proxy_admin") {
|
||||
None
|
||||
} else if facts.route == "/organization/new" {
|
||||
Some(Denial::CreateOrganization)
|
||||
} else if facts.org_admin_routes.contains(&facts.route) {
|
||||
if facts.memberships.is_none() {
|
||||
Some(Denial::NoMemberships)
|
||||
} else if !present {
|
||||
Some(Denial::MissingOrganization)
|
||||
} else if role.is_none() {
|
||||
Some(Denial::MissingRole)
|
||||
} else if role.as_deref() != Some("org_admin") {
|
||||
Some(Denial::WrongRole)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else if facts.route == "/team/new" && !memberships.is_empty() {
|
||||
if !present {
|
||||
Some(Denial::TeamMissingOrganization)
|
||||
} else if role.as_deref() != Some("org_admin") {
|
||||
Some(Denial::TeamWrongRole)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Decision {
|
||||
denial,
|
||||
organization_role: role,
|
||||
organization_ids: ids,
|
||||
read_identifier: false,
|
||||
hash_failure: None,
|
||||
..Decision::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn user_is_admin(memberships: &[Membership], organizations: &[String]) -> bool {
|
||||
!organizations.is_empty()
|
||||
&& organizations.iter().all(|id| {
|
||||
memberships.iter().any(|item| {
|
||||
item.organization_id.as_ref() == Some(id)
|
||||
&& item.role.as_deref() == Some("org_admin")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Identifier {
|
||||
pub text: Option<String>,
|
||||
pub hash_failure: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AdminFacts {
|
||||
pub memberships: Option<Vec<Membership>>,
|
||||
pub request_read: bool,
|
||||
pub singular_present: bool,
|
||||
pub plural_count: usize,
|
||||
pub candidate_index: usize,
|
||||
pub candidate: Option<Identifier>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, strum::VariantArray)]
|
||||
pub enum AdminAction {
|
||||
ReadRequest,
|
||||
ReadSingular,
|
||||
ReadPlural,
|
||||
Allow,
|
||||
Deny,
|
||||
HashFailure,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct AdminDecision {
|
||||
pub action: AdminAction,
|
||||
pub candidate_index: usize,
|
||||
pub plural_index: usize,
|
||||
pub hash_failure: Option<String>,
|
||||
}
|
||||
|
||||
pub fn admin(facts: &AdminFacts) -> AdminDecision {
|
||||
let result = |action| AdminDecision {
|
||||
action,
|
||||
candidate_index: 0,
|
||||
plural_index: 0,
|
||||
hash_failure: None,
|
||||
};
|
||||
let Some(memberships) = &facts.memberships else {
|
||||
return result(AdminAction::Deny);
|
||||
};
|
||||
if !facts.request_read {
|
||||
return result(AdminAction::ReadRequest);
|
||||
}
|
||||
let count = usize::from(facts.singular_present) + facts.plural_count;
|
||||
if count == 0 {
|
||||
return result(AdminAction::Deny);
|
||||
}
|
||||
let next = if let Some(candidate) = &facts.candidate {
|
||||
if let Some(message) = &candidate.hash_failure {
|
||||
return AdminDecision {
|
||||
hash_failure: Some(message.clone()),
|
||||
..result(AdminAction::HashFailure)
|
||||
};
|
||||
}
|
||||
if !candidate.text.as_ref().is_some_and(|id| {
|
||||
memberships.iter().any(|item| {
|
||||
item.organization_id.as_ref() == Some(id)
|
||||
&& item.role.as_deref() == Some("org_admin")
|
||||
})
|
||||
}) {
|
||||
return result(AdminAction::Deny);
|
||||
}
|
||||
facts.candidate_index + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if next == count {
|
||||
return result(AdminAction::Allow);
|
||||
}
|
||||
AdminDecision {
|
||||
action: if facts.singular_present && next == 0 {
|
||||
AdminAction::ReadSingular
|
||||
} else {
|
||||
AdminAction::ReadPlural
|
||||
},
|
||||
candidate_index: next,
|
||||
plural_index: next.saturating_sub(usize::from(facts.singular_present)),
|
||||
hash_failure: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::no_memberships(false, false, None, None, AdminAction::Deny)]
|
||||
#[case::read_request(true, false, None, None, AdminAction::ReadRequest)]
|
||||
#[case::read_singular(true, true, None, None, AdminAction::ReadSingular)]
|
||||
#[case::allowed(true, true, Some("org"), None, AdminAction::Allow)]
|
||||
#[case::wrong_org(true, true, Some("other"), None, AdminAction::Deny)]
|
||||
#[case::unhashable(
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
Some("unhashable type: 'list'"),
|
||||
AdminAction::HashFailure
|
||||
)]
|
||||
fn raw_admin_stages(
|
||||
#[case] present: bool,
|
||||
#[case] request_read: bool,
|
||||
#[case] id: Option<&str>,
|
||||
#[case] failure: Option<&str>,
|
||||
#[case] expected: AdminAction,
|
||||
) {
|
||||
let memberships = present.then(|| {
|
||||
vec![Membership {
|
||||
organization_id: Some("org".into()),
|
||||
role: Some("org_admin".into()),
|
||||
}]
|
||||
});
|
||||
let candidate = (id.is_some() || failure.is_some()).then(|| Identifier {
|
||||
text: id.map(str::to_owned),
|
||||
hash_failure: failure.map(str::to_owned),
|
||||
});
|
||||
assert_eq!(
|
||||
admin(&AdminFacts {
|
||||
memberships,
|
||||
request_read,
|
||||
singular_present: true,
|
||||
plural_count: 0,
|
||||
candidate_index: 0,
|
||||
candidate
|
||||
})
|
||||
.action,
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::none(None, Some(Denial::NoMemberships))]
|
||||
#[case::empty(Some(vec![]), Some(Denial::MissingOrganization))]
|
||||
fn membership_presence(
|
||||
#[case] memberships: Option<Vec<Membership>>,
|
||||
#[case] expected: Option<Denial>,
|
||||
) {
|
||||
assert_eq!(
|
||||
decide(Facts {
|
||||
route: "/organization/update".into(),
|
||||
user_present: true,
|
||||
user_role: None,
|
||||
memberships,
|
||||
organization_id: None,
|
||||
org_admin_routes: vec!["/organization/update".into()],
|
||||
organization_id_present: false,
|
||||
identifier_pending: false,
|
||||
hash_failure: None,
|
||||
request_pending: false,
|
||||
memberships_pending: false,
|
||||
})
|
||||
.denial,
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
173
litellm-rust/crates/request-auth-core/src/owner_teams.rs
Normal file
173
litellm-rust/crates/request-auth-core/src/owner_teams.rs
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
use crate::{Error, personal_budget};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Clone, Copy, Deserialize, PartialEq, Eq, strum::VariantArray)]
|
||||
pub enum Purpose {
|
||||
Models,
|
||||
Grants,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Load {
|
||||
pub present: bool,
|
||||
pub failed: bool,
|
||||
pub blocked: bool,
|
||||
pub org: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Budget {
|
||||
pub index: usize,
|
||||
pub facts: personal_budget::Facts,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Facts {
|
||||
pub purpose: Purpose,
|
||||
pub user_present: bool,
|
||||
pub database_present: bool,
|
||||
pub ids: Vec<String>,
|
||||
pub ui_team_id: String,
|
||||
pub org: Option<String>,
|
||||
pub loaded: bool,
|
||||
pub loads: Vec<Load>,
|
||||
pub budgets: Vec<Budget>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Decision {
|
||||
ReadTeams {
|
||||
ids: Vec<String>,
|
||||
},
|
||||
ReadBudgets {
|
||||
indices: Vec<usize>,
|
||||
},
|
||||
Complete {
|
||||
selected: Vec<usize>,
|
||||
withheld: Vec<usize>,
|
||||
unreadable: bool,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn decide(facts: &Facts) -> Result<Decision, Error> {
|
||||
if facts.purpose == Purpose::Grants && (!facts.user_present || !facts.database_present) {
|
||||
return Ok(Decision::Complete {
|
||||
selected: vec![],
|
||||
withheld: vec![],
|
||||
unreadable: false,
|
||||
});
|
||||
}
|
||||
if !facts.loaded {
|
||||
let mut seen = BTreeSet::new();
|
||||
let ids = facts
|
||||
.ids
|
||||
.iter()
|
||||
.filter(|id| {
|
||||
facts.purpose == Purpose::Models || (!id.is_empty() && **id != facts.ui_team_id)
|
||||
})
|
||||
.filter(|id| seen.insert(*id))
|
||||
.cloned()
|
||||
.collect();
|
||||
return Ok(Decision::ReadTeams { ids });
|
||||
}
|
||||
let eligible: Vec<_> = facts
|
||||
.loads
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, load)| load.present && !load.blocked)
|
||||
.filter(|(_, load)| {
|
||||
facts.purpose == Purpose::Models
|
||||
|| facts.org.is_none()
|
||||
|| load.org.is_none()
|
||||
|| load.org == facts.org
|
||||
})
|
||||
.map(|(index, _)| index)
|
||||
.collect();
|
||||
let unreadable = facts.loads.iter().any(|load| load.failed);
|
||||
if facts.purpose == Purpose::Models {
|
||||
return Ok(Decision::Complete {
|
||||
selected: eligible,
|
||||
withheld: vec![],
|
||||
unreadable,
|
||||
});
|
||||
}
|
||||
let missing: Vec<_> = eligible
|
||||
.iter()
|
||||
.filter(|index| !facts.budgets.iter().any(|budget| budget.index == **index))
|
||||
.copied()
|
||||
.collect();
|
||||
if !missing.is_empty() {
|
||||
return Ok(Decision::ReadBudgets { indices: missing });
|
||||
}
|
||||
let results: Result<Vec<_>, _> = eligible
|
||||
.iter()
|
||||
.map(|index| {
|
||||
let budget = facts
|
||||
.budgets
|
||||
.iter()
|
||||
.find(|budget| budget.index == *index)
|
||||
.ok_or(Error::InvalidAnswer)?;
|
||||
match personal_budget::decide(&budget.facts)? {
|
||||
personal_budget::Decision::Complete { allowed, .. } => Ok((*index, allowed)),
|
||||
_ => Err(Error::InvalidAnswer),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let results = results?;
|
||||
let blocked = facts
|
||||
.loads
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, load)| {
|
||||
load.present
|
||||
&& load.blocked
|
||||
&& (facts.org.is_none() || load.org.is_none() || load.org == facts.org)
|
||||
})
|
||||
.map(|(index, _)| index);
|
||||
let withheld = blocked
|
||||
.chain(
|
||||
results
|
||||
.iter()
|
||||
.filter_map(|(index, allowed)| (!allowed).then_some(*index)),
|
||||
)
|
||||
.collect();
|
||||
Ok(Decision::Complete {
|
||||
selected: results
|
||||
.into_iter()
|
||||
.filter_map(|(index, allowed)| allowed.then_some(index))
|
||||
.collect(),
|
||||
withheld,
|
||||
unreadable,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::models(Purpose::Models, vec!["", "ui", "a"])]
|
||||
#[case::grants(Purpose::Grants, vec!["a"])]
|
||||
fn id_filter(#[case] purpose: Purpose, #[case] expected: Vec<&str>) {
|
||||
let facts = Facts {
|
||||
purpose,
|
||||
user_present: true,
|
||||
database_present: true,
|
||||
ids: vec!["".into(), "ui".into(), "a".into(), "a".into()],
|
||||
ui_team_id: "ui".into(),
|
||||
org: None,
|
||||
loaded: false,
|
||||
loads: vec![],
|
||||
budgets: vec![],
|
||||
};
|
||||
assert_eq!(
|
||||
decide(&facts).unwrap(),
|
||||
Decision::ReadTeams {
|
||||
ids: expected.into_iter().map(str::to_owned).collect()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
134
litellm-rust/crates/request-auth-core/src/patterns.rs
Normal file
134
litellm-rust/crates/request-auth-core/src/patterns.rs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
use fancy_regex::Regex;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::{Arc, LazyLock, Mutex},
|
||||
};
|
||||
|
||||
use crate::Error;
|
||||
|
||||
static EXPRESSIONS: LazyLock<Mutex<BTreeMap<String, Arc<Regex>>>> =
|
||||
LazyLock::new(|| Mutex::new(BTreeMap::new()));
|
||||
|
||||
fn expression(pattern: &str) -> Result<Arc<Regex>, Error> {
|
||||
if let Some(compiled) = EXPRESSIONS
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.get(pattern)
|
||||
{
|
||||
return Ok(Arc::clone(compiled));
|
||||
}
|
||||
let compiled = Arc::new(Regex::new(pattern).map_err(Error::Pattern)?);
|
||||
let mut expressions = EXPRESSIONS
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
if expressions.len() >= 1024 {
|
||||
expressions.clear();
|
||||
}
|
||||
expressions.insert(pattern.to_owned(), Arc::clone(&compiled));
|
||||
Ok(compiled)
|
||||
}
|
||||
|
||||
pub fn regex_match(pattern: &str, value: &str) -> Result<bool, Error> {
|
||||
let expression = expression(pattern)?;
|
||||
Ok(expression
|
||||
.find(value)
|
||||
.map_err(Error::Pattern)?
|
||||
.is_some_and(|found| found.start() == 0))
|
||||
}
|
||||
|
||||
pub fn model_pattern(model: &str, pattern: &str) -> Result<bool, Error> {
|
||||
if !pattern.contains('*') {
|
||||
return Ok(false);
|
||||
}
|
||||
regex_match(
|
||||
&format!("^{}(?=\\n?\\z)", pattern.replace('*', ".*")),
|
||||
model,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn route_pattern(route: &str, pattern: &str) -> Result<bool, Error> {
|
||||
let mut expression = String::from("^");
|
||||
let mut rest = pattern;
|
||||
while let Some(start) = rest.find('{') {
|
||||
expression.push_str(&rest[..start]);
|
||||
let Some(end) = rest[start + 1..].find('}').map(|index| index + start + 1) else {
|
||||
expression.push_str(&rest[start..]);
|
||||
rest = "";
|
||||
break;
|
||||
};
|
||||
let name = &rest[start + 1..end];
|
||||
if name.is_empty() {
|
||||
expression.push_str("{}");
|
||||
} else if name.ends_with(":path") {
|
||||
expression.push_str(if rest[end + 1..].contains(':') {
|
||||
"[^:]+"
|
||||
} else {
|
||||
"[\\s\\S]+"
|
||||
});
|
||||
} else {
|
||||
expression.push_str("[^/]+");
|
||||
}
|
||||
rest = &rest[end + 1..];
|
||||
}
|
||||
expression.push_str(rest);
|
||||
expression.push_str("(?=\\n?\\z)");
|
||||
regex_match(&expression, route)
|
||||
}
|
||||
|
||||
pub fn wildcard(route: &str, pattern: &str) -> bool {
|
||||
pattern
|
||||
.strip_suffix('*')
|
||||
.map_or(route == pattern, |prefix| route.starts_with(prefix))
|
||||
}
|
||||
|
||||
pub fn prefix(route: &str, pattern: &str) -> bool {
|
||||
route == pattern
|
||||
|| route
|
||||
.strip_prefix(pattern)
|
||||
.is_some_and(|suffix| suffix.starts_with('/'))
|
||||
}
|
||||
|
||||
pub fn route_access(route: &str, patterns: &[String]) -> Result<bool, Error> {
|
||||
if patterns
|
||||
.iter()
|
||||
.any(|pattern| pattern == route || (pattern.ends_with('*') && wildcard(route, pattern)))
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
for pattern in patterns {
|
||||
if route_pattern(route, pattern)? {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::segment("/key/k/reset", "/key/{id}/reset", true)]
|
||||
#[case::segment_slash("/key/a/b/reset", "/key/{id}/reset", false)]
|
||||
#[case::path_slash("/credentials/a/b/start", "/credentials/{id:path}/start", true)]
|
||||
#[case::path_newline("/credentials/a\nb/start", "/credentials/{id:path}/start", true)]
|
||||
#[case::google_suffix(
|
||||
"/models/a:b:generateContent",
|
||||
"/models/{id:path}:generateContent",
|
||||
false
|
||||
)]
|
||||
#[case::final_newline("/models/a\n", "/models/{id}", true)]
|
||||
fn route_templates(#[case] route: &str, #[case] pattern: &str, #[case] expected: bool) {
|
||||
assert_eq!(super::route_pattern(route, pattern).unwrap(), expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::provider("openai/a", "openai/*", true)]
|
||||
#[case::exact_not_pattern("a", "a", false)]
|
||||
#[case::regex_dot("aa-1", "a.-*", true)]
|
||||
#[case::newline("a\nb", "*", false)]
|
||||
#[case::final_newline("a\n", "a*", true)]
|
||||
fn model_patterns(#[case] model: &str, #[case] pattern: &str, #[case] expected: bool) {
|
||||
assert_eq!(super::model_pattern(model, pattern).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
186
litellm-rust/crates/request-auth-core/src/personal_budget.rs
Normal file
186
litellm-rust/crates/request-auth-core/src/personal_budget.rs
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
Error,
|
||||
common_budgets::{self, BudgetFacts, MemberLimitFacts},
|
||||
};
|
||||
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
pub struct Facts {
|
||||
pub user_present: bool,
|
||||
pub team_id_present: bool,
|
||||
pub check_db_only: bool,
|
||||
pub token_org: Option<String>,
|
||||
pub team_org: Option<String>,
|
||||
pub source_ready: bool,
|
||||
pub team: Option<BudgetFacts>,
|
||||
pub team_alerted: bool,
|
||||
pub window_count: Option<usize>,
|
||||
pub windows: Vec<BudgetFacts>,
|
||||
pub membership_loaded: bool,
|
||||
pub member_limit: Option<MemberLimitFacts>,
|
||||
pub default_id: Option<String>,
|
||||
pub default_loaded: bool,
|
||||
pub member: Option<BudgetFacts>,
|
||||
pub member_alerted: bool,
|
||||
pub org_loaded: bool,
|
||||
pub org_failed: bool,
|
||||
pub org: Option<BudgetFacts>,
|
||||
pub org_alerted: bool,
|
||||
pub io_failed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Decision {
|
||||
Complete { allowed: bool, log_failure: bool },
|
||||
Source { org_id: Option<String> },
|
||||
ReadTeam,
|
||||
AlertTeam,
|
||||
ReadWindows,
|
||||
ReadWindow { index: usize },
|
||||
ReadMembership,
|
||||
ReadMemberLimit,
|
||||
ReadDefault { id: String },
|
||||
ReadMember { limit: Option<f64> },
|
||||
AlertMember,
|
||||
ReadOrg { id: String },
|
||||
AlertOrg { id: String },
|
||||
}
|
||||
|
||||
pub fn decide(facts: &Facts) -> Result<Decision, Error> {
|
||||
let complete = |allowed, log_failure| {
|
||||
Ok(Decision::Complete {
|
||||
allowed,
|
||||
log_failure,
|
||||
})
|
||||
};
|
||||
let org_id = facts
|
||||
.token_org
|
||||
.as_ref()
|
||||
.filter(|id| !id.is_empty())
|
||||
.or(facts.team_org.as_ref());
|
||||
if !facts.source_ready {
|
||||
return Ok(Decision::Source {
|
||||
org_id: org_id.cloned(),
|
||||
});
|
||||
}
|
||||
if facts.io_failed {
|
||||
return complete(false, false);
|
||||
}
|
||||
let Some(team) = &facts.team else {
|
||||
return Ok(Decision::ReadTeam);
|
||||
};
|
||||
let team = common_budgets::check(team);
|
||||
if team.read_spend {
|
||||
return Err(Error::InvalidAnswer);
|
||||
}
|
||||
if team.alert.is_some() && !facts.team_alerted {
|
||||
return Ok(Decision::AlertTeam);
|
||||
}
|
||||
if team.denied {
|
||||
return complete(false, false);
|
||||
}
|
||||
let Some(count) = facts.window_count else {
|
||||
return Ok(Decision::ReadWindows);
|
||||
};
|
||||
for index in 0..count {
|
||||
let Some(window) = facts.windows.get(index) else {
|
||||
return Ok(Decision::ReadWindow { index });
|
||||
};
|
||||
let budget = common_budgets::check(window);
|
||||
if budget.read_spend {
|
||||
return Err(Error::InvalidAnswer);
|
||||
}
|
||||
if budget.denied {
|
||||
return complete(false, false);
|
||||
}
|
||||
}
|
||||
if facts.check_db_only && !facts.user_present {
|
||||
return complete(false, false);
|
||||
}
|
||||
if facts.user_present
|
||||
&& (facts.team_id_present || facts.check_db_only)
|
||||
&& !facts.membership_loaded
|
||||
{
|
||||
return Ok(Decision::ReadMembership);
|
||||
}
|
||||
if facts.user_present && facts.team_id_present {
|
||||
let Some(limit) = &facts.member_limit else {
|
||||
return Ok(Decision::ReadMemberLimit);
|
||||
};
|
||||
if limit.member_limit.is_none()
|
||||
&& !facts.default_loaded
|
||||
&& let Some(id) = &facts.default_id
|
||||
{
|
||||
return Ok(Decision::ReadDefault { id: id.clone() });
|
||||
}
|
||||
let limit = match common_budgets::member_limit(limit) {
|
||||
Ok(limit) => limit,
|
||||
Err(Error::IncompatibleDateTimes) => return complete(false, true),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let Some(member) = &facts.member else {
|
||||
return Ok(Decision::ReadMember { limit });
|
||||
};
|
||||
let member = common_budgets::check(member);
|
||||
if member.read_spend {
|
||||
return Err(Error::InvalidAnswer);
|
||||
}
|
||||
if member.alert.is_some() && !facts.member_alerted {
|
||||
return Ok(Decision::AlertMember);
|
||||
}
|
||||
if member.denied {
|
||||
return complete(false, false);
|
||||
}
|
||||
}
|
||||
if let Some(id) = org_id {
|
||||
if !facts.org_loaded {
|
||||
return Ok(Decision::ReadOrg { id: id.clone() });
|
||||
}
|
||||
if !facts.org_failed
|
||||
&& let Some(org) = &facts.org
|
||||
{
|
||||
let org = common_budgets::check(org);
|
||||
if org.read_spend {
|
||||
return Err(Error::InvalidAnswer);
|
||||
}
|
||||
if org.alert.is_some() && !facts.org_alerted {
|
||||
return Ok(Decision::AlertOrg { id: id.clone() });
|
||||
}
|
||||
if org.denied {
|
||||
return complete(false, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
complete(true, false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::token(Some("token"), Some("team"), Some("token"))]
|
||||
#[case::empty_token(Some(""), Some("team"), Some("team"))]
|
||||
#[case::empty_team(None, Some(""), Some(""))]
|
||||
#[case::absent(None, None, None)]
|
||||
fn source_org(
|
||||
#[case] token: Option<&str>,
|
||||
#[case] team: Option<&str>,
|
||||
#[case] selected: Option<&str>,
|
||||
) {
|
||||
let facts = Facts {
|
||||
token_org: token.map(str::to_owned),
|
||||
team_org: team.map(str::to_owned),
|
||||
..Facts::default()
|
||||
};
|
||||
assert_eq!(
|
||||
decide(&facts).unwrap(),
|
||||
Decision::Source {
|
||||
org_id: selected.map(str::to_owned)
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
use crate::{Error, personal_model_request::Loaded, personal_models, personal_owner};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Facts {
|
||||
pub owner: personal_owner::Facts,
|
||||
pub names: Option<Vec<String>>,
|
||||
pub models: Vec<Loaded>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Decision {
|
||||
ReadOwner,
|
||||
ReadNames,
|
||||
ReadModel { index: usize, teams: Vec<usize> },
|
||||
Complete { selected: Vec<usize> },
|
||||
}
|
||||
|
||||
pub fn decide(facts: &Facts) -> Result<Decision, Error> {
|
||||
let teams = match personal_owner::decide(&facts.owner)? {
|
||||
personal_owner::Decision::Complete { selected, .. } => selected,
|
||||
_ => return Ok(Decision::ReadOwner),
|
||||
};
|
||||
if facts
|
||||
.owner
|
||||
.teams
|
||||
.as_ref()
|
||||
.is_none_or(|teams| teams.loads.is_empty())
|
||||
{
|
||||
return Ok(Decision::Complete { selected: vec![] });
|
||||
}
|
||||
let Some(names) = &facts.names else {
|
||||
return Ok(Decision::ReadNames);
|
||||
};
|
||||
let mut selected = vec![];
|
||||
for index in 0..names.len() {
|
||||
let Some(loaded) = facts.models.get(index) else {
|
||||
return Ok(Decision::ReadModel { index, teams });
|
||||
};
|
||||
match personal_models::decide(personal_models::Request::Loaded {
|
||||
present: loaded.present.clone(),
|
||||
scopes: loaded.scopes.clone(),
|
||||
})? {
|
||||
personal_models::Decision::Complete { allowed } => {
|
||||
if allowed {
|
||||
selected.push(index);
|
||||
}
|
||||
}
|
||||
_ => return Err(Error::InvalidAnswer),
|
||||
}
|
||||
}
|
||||
Ok(Decision::Complete { selected })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::missing_database(false, Decision::Complete { selected: vec![] })]
|
||||
#[case::load(true, Decision::ReadOwner)]
|
||||
fn owner_before_names(#[case] database_present: bool, #[case] expected: Decision) {
|
||||
let facts = Facts {
|
||||
owner: personal_owner::Facts {
|
||||
user_present: true,
|
||||
database_present,
|
||||
cache_present: true,
|
||||
logging_present: true,
|
||||
owner_loaded: false,
|
||||
owner_present: false,
|
||||
owner_failed: false,
|
||||
teams: None,
|
||||
},
|
||||
names: None,
|
||||
models: vec![],
|
||||
};
|
||||
assert_eq!(decide(&facts).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
use crate::{Error, owner_teams, personal_models};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Loaded {
|
||||
pub present: Vec<bool>,
|
||||
pub scopes: Vec<personal_models::Scope>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Facts {
|
||||
pub requested: Vec<String>,
|
||||
pub owner_present: bool,
|
||||
pub teams: Option<owner_teams::Facts>,
|
||||
pub models: Vec<Loaded>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Decision {
|
||||
Allow,
|
||||
Deny { model: String },
|
||||
ReadTeams,
|
||||
ReadModel { index: usize, teams: Vec<usize> },
|
||||
}
|
||||
|
||||
pub fn decide(facts: &Facts) -> Result<Decision, Error> {
|
||||
let teams = if facts.owner_present {
|
||||
let Some(teams) = &facts.teams else {
|
||||
return Ok(Decision::ReadTeams);
|
||||
};
|
||||
match owner_teams::decide(teams)? {
|
||||
owner_teams::Decision::Complete { selected, .. } => selected,
|
||||
_ => return Err(Error::InvalidAnswer),
|
||||
}
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
for (index, model) in facts.requested.iter().enumerate() {
|
||||
if !facts.owner_present {
|
||||
return Ok(Decision::Deny {
|
||||
model: model.clone(),
|
||||
});
|
||||
}
|
||||
let Some(loaded) = facts.models.get(index) else {
|
||||
return Ok(Decision::ReadModel { index, teams });
|
||||
};
|
||||
match personal_models::decide(personal_models::Request::Loaded {
|
||||
present: loaded.present.clone(),
|
||||
scopes: loaded.scopes.clone(),
|
||||
})? {
|
||||
personal_models::Decision::Complete { allowed: true } => (),
|
||||
personal_models::Decision::Complete { allowed: false } => {
|
||||
return Ok(Decision::Deny {
|
||||
model: model.clone(),
|
||||
});
|
||||
}
|
||||
_ => return Err(Error::InvalidAnswer),
|
||||
}
|
||||
}
|
||||
Ok(Decision::Allow)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::empty(false, vec![], Decision::Allow)]
|
||||
#[case::owner_lookup(true, vec![], Decision::ReadTeams)]
|
||||
#[case::absent(false, vec!["model".into()], Decision::Deny { model: "model".into() })]
|
||||
fn owner_and_empty_requests(
|
||||
#[case] owner_present: bool,
|
||||
#[case] requested: Vec<String>,
|
||||
#[case] expected: Decision,
|
||||
) {
|
||||
let facts = Facts {
|
||||
requested,
|
||||
owner_present,
|
||||
teams: None,
|
||||
models: vec![],
|
||||
};
|
||||
assert_eq!(decide(&facts).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
120
litellm-rust/crates/request-auth-core/src/personal_models.rs
Normal file
120
litellm-rust/crates/request-auth-core/src/personal_models.rs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
use crate::{
|
||||
Error,
|
||||
fallback_access::{self, GroupedScope},
|
||||
model_access,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Scope {
|
||||
pub user_present: bool,
|
||||
pub team_id_present: bool,
|
||||
pub team: Option<GroupedScope>,
|
||||
pub member_grants: Option<Vec<String>>,
|
||||
pub member: Option<model_access::Facts>,
|
||||
pub io_failed: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub enum Request {
|
||||
Scope(Box<Scope>),
|
||||
Loaded {
|
||||
present: Vec<bool>,
|
||||
scopes: Vec<Scope>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Decision {
|
||||
Complete { allowed: bool },
|
||||
ReadTeam,
|
||||
ReadGroups,
|
||||
ReadMembership,
|
||||
ReadMember,
|
||||
ReadScopes { indices: Vec<usize> },
|
||||
}
|
||||
|
||||
fn scope(facts: Scope) -> Result<Decision, Error> {
|
||||
if facts.io_failed {
|
||||
return Ok(Decision::Complete { allowed: false });
|
||||
}
|
||||
let Some(team) = facts.team else {
|
||||
return Ok(Decision::ReadTeam);
|
||||
};
|
||||
let team = fallback_access::grouped(team, true)?;
|
||||
if matches!(
|
||||
team,
|
||||
model_access::Decision::ReadGroups | model_access::Decision::ReadGroupScope { .. }
|
||||
) {
|
||||
return Ok(Decision::ReadGroups);
|
||||
}
|
||||
if !team.allowed() {
|
||||
return Ok(Decision::Complete { allowed: false });
|
||||
}
|
||||
if !facts.user_present || !facts.team_id_present {
|
||||
return Ok(Decision::Complete { allowed: true });
|
||||
}
|
||||
let Some(grants) = facts.member_grants else {
|
||||
return Ok(Decision::ReadMembership);
|
||||
};
|
||||
if grants.is_empty() {
|
||||
return Ok(Decision::Complete { allowed: true });
|
||||
}
|
||||
let Some(member) = facts.member else {
|
||||
return Ok(Decision::ReadMember);
|
||||
};
|
||||
Ok(Decision::Complete {
|
||||
allowed: model_access::check(&member)?.allowed(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decide(request: Request) -> Result<Decision, Error> {
|
||||
match request {
|
||||
Request::Scope(facts) => scope(*facts),
|
||||
Request::Loaded { present, scopes } => {
|
||||
let indices: Vec<_> = present
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, present)| present.then_some(index))
|
||||
.collect();
|
||||
if scopes.is_empty() && !indices.is_empty() {
|
||||
return Ok(Decision::ReadScopes { indices });
|
||||
}
|
||||
if scopes.len() != indices.len() {
|
||||
return Err(Error::InvalidAnswer);
|
||||
}
|
||||
let results: Result<Vec<_>, _> = scopes
|
||||
.into_iter()
|
||||
.map(|facts| match scope(facts)? {
|
||||
Decision::Complete { allowed } => Ok(allowed),
|
||||
_ => Err(Error::InvalidAnswer),
|
||||
})
|
||||
.collect();
|
||||
Ok(Decision::Complete {
|
||||
allowed: results?.into_iter().any(|allowed| allowed),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::none(vec![], Decision::Complete { allowed: false })]
|
||||
#[case::missing(vec![false], Decision::Complete { allowed: false })]
|
||||
#[case::read_all(vec![true, false, true], Decision::ReadScopes { indices: vec![0, 2] })]
|
||||
fn all_team_reads(#[case] present: Vec<bool>, #[case] expected: Decision) {
|
||||
assert_eq!(
|
||||
decide(Request::Loaded {
|
||||
present,
|
||||
scopes: vec![]
|
||||
})
|
||||
.unwrap(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
87
litellm-rust/crates/request-auth-core/src/personal_owner.rs
Normal file
87
litellm-rust/crates/request-auth-core/src/personal_owner.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
use crate::{Error, owner_teams};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Facts {
|
||||
pub user_present: bool,
|
||||
pub database_present: bool,
|
||||
pub cache_present: bool,
|
||||
pub logging_present: bool,
|
||||
pub owner_loaded: bool,
|
||||
pub owner_present: bool,
|
||||
pub owner_failed: bool,
|
||||
pub teams: Option<owner_teams::Facts>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Decision {
|
||||
ReadOwner,
|
||||
ReadTeams,
|
||||
Complete {
|
||||
selected: Vec<usize>,
|
||||
withheld: Vec<usize>,
|
||||
unreadable: bool,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn decide(facts: &Facts) -> Result<Decision, Error> {
|
||||
let empty = || {
|
||||
Ok(Decision::Complete {
|
||||
selected: vec![],
|
||||
withheld: vec![],
|
||||
unreadable: false,
|
||||
})
|
||||
};
|
||||
if !facts.user_present
|
||||
|| !facts.database_present
|
||||
|| !facts.cache_present
|
||||
|| !facts.logging_present
|
||||
{
|
||||
return empty();
|
||||
}
|
||||
if !facts.owner_loaded {
|
||||
return Ok(Decision::ReadOwner);
|
||||
}
|
||||
if facts.owner_failed || !facts.owner_present {
|
||||
return empty();
|
||||
}
|
||||
let Some(teams) = &facts.teams else {
|
||||
return Ok(Decision::ReadTeams);
|
||||
};
|
||||
match owner_teams::decide(teams)? {
|
||||
owner_teams::Decision::Complete {
|
||||
selected,
|
||||
withheld,
|
||||
unreadable,
|
||||
} => Ok(Decision::Complete {
|
||||
selected,
|
||||
withheld,
|
||||
unreadable,
|
||||
}),
|
||||
_ => Err(Error::InvalidAnswer),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::no_database(false, Decision::Complete { selected: vec![], withheld: vec![], unreadable: false })]
|
||||
#[case::lookup(true, Decision::ReadOwner)]
|
||||
fn prerequisites(#[case] database_present: bool, #[case] expected: Decision) {
|
||||
let facts = Facts {
|
||||
user_present: true,
|
||||
database_present,
|
||||
cache_present: true,
|
||||
logging_present: true,
|
||||
owner_loaded: false,
|
||||
owner_present: false,
|
||||
owner_failed: false,
|
||||
teams: None,
|
||||
};
|
||||
assert_eq!(decide(&facts).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
|
|
@ -63,6 +63,9 @@ pub enum Grant {
|
|||
#[derive(Clone, veil::Redact, Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Denial {
|
||||
ModelAccessDenied {
|
||||
decision: crate::model_access::Decision,
|
||||
},
|
||||
MissingKey,
|
||||
MalformedKey,
|
||||
UserAuthDisabled,
|
||||
|
|
@ -108,6 +111,30 @@ pub struct ModelBudgetExceeded {
|
|||
pub entity_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ModelBudgetRead {
|
||||
pub snapshot: crate::model_budget_data::Snapshot,
|
||||
pub message: String,
|
||||
pub entity_type: Option<String>,
|
||||
pub entity_id: Option<String>,
|
||||
}
|
||||
|
||||
impl ModelBudgetRead {
|
||||
pub fn exceeded(self) -> Option<ModelBudgetExceeded> {
|
||||
let result = crate::model_budget_data::check(&self.snapshot);
|
||||
if !result.denied {
|
||||
return None;
|
||||
}
|
||||
Some(ModelBudgetExceeded {
|
||||
message: self.message,
|
||||
current_cost: result.current_spend,
|
||||
max_budget: self.snapshot.max_budget?,
|
||||
entity_type: self.entity_type,
|
||||
entity_id: self.entity_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ModelBudgetExceeded> for Denial {
|
||||
fn from(value: ModelBudgetExceeded) -> Self {
|
||||
Self::ModelBudgetExceeded {
|
||||
|
|
@ -196,6 +223,7 @@ pub enum Effect {
|
|||
},
|
||||
InspectKey,
|
||||
EnforceModelAccess,
|
||||
PrefetchAuthObjects,
|
||||
LoadKeyOwner {
|
||||
user_id: String,
|
||||
},
|
||||
|
|
@ -267,8 +295,12 @@ pub enum Node {
|
|||
#[derive(Deserialize)]
|
||||
#[serde(tag = "kind", content = "value")]
|
||||
pub enum Answer {
|
||||
ModelAccess(Box<crate::key_model_access::Facts>),
|
||||
FallbackAccess(Box<crate::fallback_access::Facts>),
|
||||
Candidate(Box<crate::fallback_candidate::Facts>),
|
||||
Unit,
|
||||
Bool(bool),
|
||||
Exemption(crate::budget_pricing::ExemptionFacts),
|
||||
Presented(Option<PresentedKey>),
|
||||
Key(KeyFacts),
|
||||
Owner(Option<OwnerFacts>),
|
||||
|
|
@ -277,6 +309,7 @@ pub enum Answer {
|
|||
ProxySpend(Option<f64>),
|
||||
ModelScope(ModelBudgetScope),
|
||||
Exceeded(Option<ModelBudgetExceeded>),
|
||||
ModelBudgetData(ModelBudgetRead),
|
||||
TeamScope(TeamMemberModelBudgetScope),
|
||||
Targets(Vec<FallbackTarget>),
|
||||
}
|
||||
|
|
@ -298,6 +331,7 @@ macro_rules! answer {
|
|||
};
|
||||
}
|
||||
answer!(bool, Bool);
|
||||
answer!(crate::budget_pricing::ExemptionFacts, Exemption);
|
||||
answer!(Option<PresentedKey>, Presented);
|
||||
answer!(KeyFacts, Key);
|
||||
answer!(Option<OwnerFacts>, Owner);
|
||||
|
|
@ -306,9 +340,37 @@ answer!(f64, Spend);
|
|||
answer!(Option<f64>, ProxySpend);
|
||||
answer!(ModelBudgetScope, ModelScope);
|
||||
answer!(Option<ModelBudgetExceeded>, Exceeded);
|
||||
answer!(ModelBudgetRead, ModelBudgetData);
|
||||
answer!(TeamMemberModelBudgetScope, TeamScope);
|
||||
answer!(Vec<FallbackTarget>, Targets);
|
||||
|
||||
impl FromAnswer for crate::fallback_candidate::Facts {
|
||||
fn from_answer(answer: Answer) -> Result<Self, Error> {
|
||||
match answer {
|
||||
Answer::Candidate(value) => Ok(*value),
|
||||
_ => Err(Error::InvalidAnswer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromAnswer for crate::key_model_access::Facts {
|
||||
fn from_answer(answer: Answer) -> Result<Self, Error> {
|
||||
match answer {
|
||||
Answer::ModelAccess(value) => Ok(*value),
|
||||
_ => Err(Error::InvalidAnswer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromAnswer for crate::fallback_access::Facts {
|
||||
fn from_answer(answer: Answer) -> Result<Self, Error> {
|
||||
match answer {
|
||||
Answer::FallbackAccess(value) => Ok(*value),
|
||||
_ => Err(Error::InvalidAnswer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromAnswer for () {
|
||||
fn from_answer(answer: Answer) -> Result<Self, Error> {
|
||||
match answer {
|
||||
|
|
|
|||
312
litellm-rust/crates/request-auth-core/src/reservation.rs
Normal file
312
litellm-rust/crates/request-auth-core/src/reservation.rs
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::cmp::Ordering::Greater;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Facts {
|
||||
Gate {
|
||||
token_present: bool,
|
||||
llm_route: Option<bool>,
|
||||
unbilled: Option<bool>,
|
||||
model_present: Option<bool>,
|
||||
counter_count: Option<usize>,
|
||||
cost_read: bool,
|
||||
cost: Option<f64>,
|
||||
},
|
||||
Sources {
|
||||
key_present: bool,
|
||||
team_present: bool,
|
||||
team_id: Option<String>,
|
||||
user_present: bool,
|
||||
user_id: Option<String>,
|
||||
apply_user_budget_to_team_keys: bool,
|
||||
},
|
||||
Counter {
|
||||
scope: Scope,
|
||||
limit: Option<f64>,
|
||||
},
|
||||
Window {
|
||||
duration_present: bool,
|
||||
limit: Option<f64>,
|
||||
start_present: Option<bool>,
|
||||
},
|
||||
Identity {
|
||||
scope: IdentityScope,
|
||||
primary: Option<String>,
|
||||
fallback: Option<String>,
|
||||
},
|
||||
MemberGate {
|
||||
team_present: bool,
|
||||
team_id: Option<String>,
|
||||
user_present: bool,
|
||||
user_id: Option<String>,
|
||||
},
|
||||
MemberCap {
|
||||
member_limit: Option<f64>,
|
||||
default_read: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Deserialize, strum::VariantArray)]
|
||||
pub enum IdentityScope {
|
||||
EndUser,
|
||||
Organization,
|
||||
Project,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Deserialize, strum::VariantArray)]
|
||||
pub enum Scope {
|
||||
Key,
|
||||
Team,
|
||||
User,
|
||||
EndUser,
|
||||
Tag,
|
||||
AccessGroup,
|
||||
Member,
|
||||
Organization,
|
||||
Project,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Decision {
|
||||
ReadRoute,
|
||||
ReadUnbilled,
|
||||
ReadModel,
|
||||
ReadCounters,
|
||||
ReadEstimate,
|
||||
Reserve,
|
||||
Skip,
|
||||
ReadStart,
|
||||
WarnWindow,
|
||||
AppendWindow,
|
||||
ReadMember,
|
||||
ReadDefault,
|
||||
ReadCap,
|
||||
Identity { id: Option<String> },
|
||||
Sources { key: bool, team: bool, user: bool },
|
||||
Counter { include: bool },
|
||||
}
|
||||
|
||||
pub fn decide(facts: Facts) -> Decision {
|
||||
match facts {
|
||||
Facts::Identity {
|
||||
scope,
|
||||
primary,
|
||||
fallback,
|
||||
} => Decision::Identity {
|
||||
id: match scope {
|
||||
IdentityScope::EndUser => primary.filter(|id| !id.is_empty()).or(fallback),
|
||||
IdentityScope::Organization => primary.or(fallback),
|
||||
IdentityScope::Project => primary,
|
||||
},
|
||||
},
|
||||
Facts::MemberGate {
|
||||
team_present,
|
||||
team_id,
|
||||
user_present,
|
||||
user_id,
|
||||
} => {
|
||||
if team_present && team_id.is_some() && user_present && user_id.is_some() {
|
||||
Decision::ReadMember
|
||||
} else {
|
||||
Decision::Skip
|
||||
}
|
||||
}
|
||||
Facts::MemberCap {
|
||||
member_limit,
|
||||
default_read,
|
||||
} => {
|
||||
if member_limit.is_none() && !default_read {
|
||||
Decision::ReadDefault
|
||||
} else {
|
||||
Decision::ReadCap
|
||||
}
|
||||
}
|
||||
Facts::Gate {
|
||||
token_present,
|
||||
llm_route,
|
||||
unbilled,
|
||||
model_present,
|
||||
counter_count,
|
||||
cost_read,
|
||||
cost,
|
||||
} => {
|
||||
if !token_present {
|
||||
return Decision::Skip;
|
||||
}
|
||||
match llm_route {
|
||||
None => return Decision::ReadRoute,
|
||||
Some(false) => return Decision::Skip,
|
||||
Some(true) => {}
|
||||
}
|
||||
match unbilled {
|
||||
None => return Decision::ReadUnbilled,
|
||||
Some(true) => return Decision::Skip,
|
||||
Some(false) => {}
|
||||
}
|
||||
match model_present {
|
||||
None => return Decision::ReadModel,
|
||||
Some(false) => return Decision::Skip,
|
||||
Some(true) => {}
|
||||
}
|
||||
match counter_count {
|
||||
None => return Decision::ReadCounters,
|
||||
Some(0) => return Decision::Skip,
|
||||
Some(_) => {}
|
||||
}
|
||||
if !cost_read {
|
||||
return Decision::ReadEstimate;
|
||||
}
|
||||
match cost {
|
||||
None => Decision::Skip,
|
||||
Some(value) if value <= 0.0 => Decision::Skip,
|
||||
Some(_) => Decision::Reserve,
|
||||
}
|
||||
}
|
||||
Facts::Sources {
|
||||
key_present,
|
||||
team_present,
|
||||
team_id,
|
||||
user_present,
|
||||
user_id,
|
||||
apply_user_budget_to_team_keys,
|
||||
} => {
|
||||
let team = team_present && team_id.is_some();
|
||||
Decision::Sources {
|
||||
key: key_present,
|
||||
team,
|
||||
user: (!team || apply_user_budget_to_team_keys)
|
||||
&& user_present
|
||||
&& user_id.is_some(),
|
||||
}
|
||||
}
|
||||
Facts::Counter { scope, limit } => Decision::Counter {
|
||||
include: limit.is_some_and(|limit| match scope {
|
||||
Scope::Key | Scope::Team | Scope::User => limit.partial_cmp(&0.0) == Some(Greater),
|
||||
Scope::Project => limit.is_finite() && limit > 0.0,
|
||||
_ => matches!(limit.partial_cmp(&0.0), None | Some(Greater)),
|
||||
}),
|
||||
},
|
||||
Facts::Window {
|
||||
duration_present,
|
||||
limit,
|
||||
start_present,
|
||||
} => {
|
||||
if !duration_present || limit.is_none_or(|limit| limit <= 0.0) {
|
||||
return Decision::Skip;
|
||||
}
|
||||
match start_present {
|
||||
None => Decision::ReadStart,
|
||||
Some(false) => Decision::WarnWindow,
|
||||
Some(true) => Decision::AppendWindow,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::customer_empty(IdentityScope::EndUser, Some(""), Some("fallback"), Some("fallback"))]
|
||||
#[case::org_empty(IdentityScope::Organization, Some(""), Some("fallback"), Some(""))]
|
||||
#[case::org_fallback(IdentityScope::Organization, None, Some("fallback"), Some("fallback"))]
|
||||
#[case::project_no_fallback(IdentityScope::Project, None, Some("fallback"), None)]
|
||||
fn identities(
|
||||
#[case] scope: IdentityScope,
|
||||
#[case] primary: Option<&str>,
|
||||
#[case] fallback: Option<&str>,
|
||||
#[case] expected: Option<&str>,
|
||||
) {
|
||||
assert_eq!(
|
||||
decide(Facts::Identity {
|
||||
scope,
|
||||
primary: primary.map(str::to_owned),
|
||||
fallback: fallback.map(str::to_owned)
|
||||
}),
|
||||
Decision::Identity {
|
||||
id: expected.map(str::to_owned)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::member_cap(Some(0.0), false, Decision::ReadCap)]
|
||||
#[case::default_needed(None, false, Decision::ReadDefault)]
|
||||
#[case::default_read(None, true, Decision::ReadCap)]
|
||||
fn member_cap(
|
||||
#[case] member_limit: Option<f64>,
|
||||
#[case] default_read: bool,
|
||||
#[case] expected: Decision,
|
||||
) {
|
||||
assert_eq!(
|
||||
decide(Facts::MemberCap {
|
||||
member_limit,
|
||||
default_read
|
||||
}),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::all_present(true, Some(""), true, Some(""), Decision::ReadMember)]
|
||||
#[case::missing_team(true, None, true, Some("user"), Decision::Skip)]
|
||||
#[case::missing_user(true, Some("team"), false, Some("user"), Decision::Skip)]
|
||||
fn member_gate(
|
||||
#[case] team_present: bool,
|
||||
#[case] team_id: Option<&str>,
|
||||
#[case] user_present: bool,
|
||||
#[case] user_id: Option<&str>,
|
||||
#[case] expected: Decision,
|
||||
) {
|
||||
assert_eq!(
|
||||
decide(Facts::MemberGate {
|
||||
team_present,
|
||||
team_id: team_id.map(str::to_owned),
|
||||
user_present,
|
||||
user_id: user_id.map(str::to_owned)
|
||||
}),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::key_nan(Scope::Key, f64::NAN, false)]
|
||||
#[case::customer_nan(Scope::EndUser, f64::NAN, true)]
|
||||
#[case::project_infinite(Scope::Project, f64::INFINITY, false)]
|
||||
#[case::team_infinite(Scope::Team, f64::INFINITY, true)]
|
||||
#[case::zero(Scope::Organization, 0.0, false)]
|
||||
#[case::positive(Scope::User, 10.0, true)]
|
||||
fn limits(#[case] scope: Scope, #[case] limit: f64, #[case] include: bool) {
|
||||
assert_eq!(
|
||||
decide(Facts::Counter {
|
||||
scope,
|
||||
limit: Some(limit)
|
||||
}),
|
||||
Decision::Counter { include }
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::no_duration(false, None, Decision::Skip)]
|
||||
#[case::unread(true, None, Decision::ReadStart)]
|
||||
#[case::invalid(true, Some(false), Decision::WarnWindow)]
|
||||
#[case::valid(true, Some(true), Decision::AppendWindow)]
|
||||
fn windows(
|
||||
#[case] duration_present: bool,
|
||||
#[case] start_present: Option<bool>,
|
||||
#[case] expected: Decision,
|
||||
) {
|
||||
assert_eq!(
|
||||
decide(Facts::Window {
|
||||
duration_present,
|
||||
limit: Some(1.0),
|
||||
start_present
|
||||
}),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
219
litellm-rust/crates/request-auth-core/src/resolved_models.rs
Normal file
219
litellm-rust/crates/request-auth-core/src/resolved_models.rs
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
use crate::{
|
||||
Error,
|
||||
fallback_access::{self, GroupedScope},
|
||||
member_models,
|
||||
model_access::{self, Decision as ModelDecision},
|
||||
personal_model_request, team_override,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct TeamRead {
|
||||
pub found: bool,
|
||||
pub failed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
pub struct Facts {
|
||||
pub model: String,
|
||||
pub config_nonempty: bool,
|
||||
pub key_models: Vec<String>,
|
||||
pub team_id: Option<String>,
|
||||
pub user_id: Option<String>,
|
||||
pub project_present: bool,
|
||||
pub customer_present: bool,
|
||||
pub database_present: bool,
|
||||
pub personal_enabled: bool,
|
||||
pub master: bool,
|
||||
pub role: Option<String>,
|
||||
pub key: Option<GroupedScope>,
|
||||
pub team_read: Option<TeamRead>,
|
||||
pub embedded_ready: bool,
|
||||
pub team: Option<team_override::Facts>,
|
||||
pub member: Option<member_models::Facts>,
|
||||
pub owner_loaded: bool,
|
||||
pub owner_present: bool,
|
||||
pub owner_failed: bool,
|
||||
pub personal: Option<personal_model_request::Facts>,
|
||||
pub project_loaded: bool,
|
||||
pub project_grants: Vec<String>,
|
||||
pub project: Option<model_access::Facts>,
|
||||
pub customer_loaded: bool,
|
||||
pub customer_grants: Vec<String>,
|
||||
pub customer: Option<model_access::Facts>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Decision {
|
||||
Allow,
|
||||
Deny {
|
||||
denial: ModelDecision,
|
||||
member_models: Option<Vec<String>>,
|
||||
},
|
||||
ReadKey,
|
||||
ReadKeyGroups,
|
||||
ReadTeamObject,
|
||||
ReadEmbeddedTeam,
|
||||
ReadTeamScope,
|
||||
ReadMember,
|
||||
ReadOwner,
|
||||
ReadPersonal,
|
||||
ReadProjectObject,
|
||||
ReadProjectScope,
|
||||
ReadCustomerObject,
|
||||
ReadCustomerScope,
|
||||
}
|
||||
|
||||
pub fn decide(facts: &Facts) -> Result<Decision, Error> {
|
||||
let deny = |denial| {
|
||||
Ok(Decision::Deny {
|
||||
denial,
|
||||
member_models: None,
|
||||
})
|
||||
};
|
||||
if !facts.config_nonempty
|
||||
&& !facts
|
||||
.key_models
|
||||
.iter()
|
||||
.any(|model| model == "all-team-models")
|
||||
{
|
||||
let Some(key) = &facts.key else {
|
||||
return Ok(Decision::ReadKey);
|
||||
};
|
||||
let key = fallback_access::grouped(key.clone(), false)?;
|
||||
if matches!(
|
||||
key,
|
||||
ModelDecision::ReadGroups | ModelDecision::ReadGroupScope { .. }
|
||||
) {
|
||||
return Ok(Decision::ReadKeyGroups);
|
||||
}
|
||||
if !key.allowed() {
|
||||
return deny(key);
|
||||
}
|
||||
}
|
||||
if facts.team_id.is_some() {
|
||||
let Some(read) = &facts.team_read else {
|
||||
return Ok(Decision::ReadTeamObject);
|
||||
};
|
||||
if read.failed && !facts.embedded_ready {
|
||||
return Ok(Decision::ReadEmbeddedTeam);
|
||||
}
|
||||
if read.found || read.failed {
|
||||
let Some(team) = &facts.team else {
|
||||
return Ok(Decision::ReadTeamScope);
|
||||
};
|
||||
match team_override::decide(team)? {
|
||||
team_override::Decision::Allow => (),
|
||||
team_override::Decision::Deny { denial } => return deny(denial),
|
||||
_ => return Err(Error::InvalidAnswer),
|
||||
}
|
||||
if read.found && facts.user_id.is_some() {
|
||||
let Some(member) = &facts.member else {
|
||||
return Ok(Decision::ReadMember);
|
||||
};
|
||||
match member_models::decide(member)? {
|
||||
member_models::Decision::Allow => (),
|
||||
member_models::Decision::Deny {
|
||||
denial,
|
||||
member_models,
|
||||
} => {
|
||||
return Ok(Decision::Deny {
|
||||
denial,
|
||||
member_models,
|
||||
});
|
||||
}
|
||||
_ => return Err(Error::InvalidAnswer),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let model_access::Response::Predicate(personal) =
|
||||
model_access::decide(model_access::Request::PersonalApplies {
|
||||
enabled: facts.personal_enabled,
|
||||
token_present: true,
|
||||
team_id: facts.team_id.clone(),
|
||||
user_id: facts.user_id.clone(),
|
||||
master: facts.master,
|
||||
role: facts.role.clone(),
|
||||
api_key_present: true,
|
||||
inheritance: false,
|
||||
})?
|
||||
else {
|
||||
return Err(Error::InvalidAnswer);
|
||||
};
|
||||
if personal {
|
||||
if !facts.owner_loaded {
|
||||
return Ok(Decision::ReadOwner);
|
||||
}
|
||||
if facts.owner_failed || !facts.owner_present {
|
||||
return deny(ModelDecision::PersonalTeam {
|
||||
model: facts.model.clone(),
|
||||
});
|
||||
}
|
||||
let Some(personal) = &facts.personal else {
|
||||
return Ok(Decision::ReadPersonal);
|
||||
};
|
||||
match personal_model_request::decide(personal)? {
|
||||
personal_model_request::Decision::Allow => (),
|
||||
personal_model_request::Decision::Deny { model } => {
|
||||
return deny(ModelDecision::PersonalTeam { model });
|
||||
}
|
||||
_ => return Err(Error::InvalidAnswer),
|
||||
}
|
||||
}
|
||||
if facts.project_present {
|
||||
if !facts.project_loaded {
|
||||
return Ok(Decision::ReadProjectObject);
|
||||
}
|
||||
if !facts.project_grants.is_empty() {
|
||||
let Some(project) = &facts.project else {
|
||||
return Ok(Decision::ReadProjectScope);
|
||||
};
|
||||
let decision = model_access::check(project)?;
|
||||
if !decision.allowed() {
|
||||
return deny(decision);
|
||||
}
|
||||
}
|
||||
}
|
||||
if facts.customer_present && facts.database_present {
|
||||
if !facts.customer_loaded {
|
||||
return Ok(Decision::ReadCustomerObject);
|
||||
}
|
||||
if !facts.customer_grants.is_empty() {
|
||||
let Some(customer) = &facts.customer else {
|
||||
return Ok(Decision::ReadCustomerScope);
|
||||
};
|
||||
let model_access::Response::Decision(decision) =
|
||||
model_access::decide(model_access::Request::Customer(customer.clone()))?
|
||||
else {
|
||||
return Err(Error::InvalidAnswer);
|
||||
};
|
||||
if !decision.allowed() {
|
||||
return deny(decision);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Decision::Allow)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::key(false, Decision::ReadKey)]
|
||||
#[case::team(true, Decision::ReadTeamObject)]
|
||||
fn key_before_team(#[case] config_nonempty: bool, #[case] expected: Decision) {
|
||||
assert_eq!(
|
||||
decide(&Facts {
|
||||
config_nonempty,
|
||||
team_id: Some("team".into()),
|
||||
..Facts::default()
|
||||
})
|
||||
.unwrap(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
567
litellm-rust/crates/request-auth-core/src/route_access.rs
Normal file
567
litellm-rust/crates/request-auth-core/src/route_access.rs
Normal file
|
|
@ -0,0 +1,567 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Error, patterns};
|
||||
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
pub struct RouteCatalog {
|
||||
pub groups: BTreeMap<String, Vec<String>>,
|
||||
pub viewer_blocked: Vec<String>,
|
||||
pub viewer_write_blocked: Vec<String>,
|
||||
pub self_credentials: Vec<(String, String)>,
|
||||
pub managed_models: Vec<String>,
|
||||
pub managed_paths: Vec<String>,
|
||||
pub managed_realtime: Vec<String>,
|
||||
pub managed_live: Vec<String>,
|
||||
pub managed_mcp: Vec<String>,
|
||||
pub unbilled: Vec<String>,
|
||||
}
|
||||
|
||||
impl RouteCatalog {
|
||||
fn group(&self, name: &str) -> &[String] {
|
||||
self.groups.get(name).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
fn access(&self, route: &str, name: &str) -> Result<bool, Error> {
|
||||
patterns::route_access(route, self.group(name))
|
||||
}
|
||||
|
||||
fn contains(&self, route: &str, name: &str) -> bool {
|
||||
self.group(name).iter().any(|entry| entry == route)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
pub struct RouteContext {
|
||||
pub route: String,
|
||||
pub method: Option<String>,
|
||||
pub catalog: RouteCatalog,
|
||||
pub registered: bool,
|
||||
pub registered_auth: Option<bool>,
|
||||
pub configured_eu_jev: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
pub struct PassThroughFacts {
|
||||
pub key_allowed: Vec<String>,
|
||||
pub team_allowed: Vec<String>,
|
||||
pub denied: Vec<String>,
|
||||
pub forwarded: Vec<String>,
|
||||
pub jwt_team_routes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct VirtualRouteFacts {
|
||||
pub context: RouteContext,
|
||||
pub allowed_routes: Option<Vec<String>>,
|
||||
pub passthrough: PassThroughFacts,
|
||||
pub password_reset_required: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ViewerFacts {
|
||||
pub context: RouteContext,
|
||||
pub request_keys: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct NonAdminFacts {
|
||||
pub viewer: ViewerFacts,
|
||||
pub role: Option<String>,
|
||||
pub custom_admin_routes: Vec<String>,
|
||||
pub premium: bool,
|
||||
pub query_user_id: Option<String>,
|
||||
pub token_user_id: Option<String>,
|
||||
pub permissions: Vec<String>,
|
||||
pub organization_ids: Vec<String>,
|
||||
pub admin_organization_ids: Vec<String>,
|
||||
pub team_service_account: bool,
|
||||
pub allowed_routes: Option<Vec<String>>,
|
||||
pub passthrough: PassThroughFacts,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MatchFacts {
|
||||
pub route: String,
|
||||
pub patterns: Vec<String>,
|
||||
pub mode: MatchMode,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, strum::VariantArray)]
|
||||
pub enum MatchMode {
|
||||
Access,
|
||||
Template,
|
||||
Prefix,
|
||||
Wildcard,
|
||||
Denied,
|
||||
Jwt,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, strum::VariantArray)]
|
||||
pub enum Classification {
|
||||
Llm,
|
||||
Unbilled,
|
||||
Discovery,
|
||||
SelfCredential,
|
||||
Azure,
|
||||
Managed,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub enum Request {
|
||||
Match(MatchFacts),
|
||||
Classify {
|
||||
context: RouteContext,
|
||||
kind: Classification,
|
||||
},
|
||||
Virtual(VirtualRouteFacts),
|
||||
Viewer(ViewerFacts),
|
||||
NonAdmin(NonAdminFacts),
|
||||
PassThrough {
|
||||
context: RouteContext,
|
||||
facts: PassThroughFacts,
|
||||
required: bool,
|
||||
},
|
||||
DeniedPassThrough {
|
||||
route: String,
|
||||
facts: PassThroughFacts,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct Decision {
|
||||
pub allowed: bool,
|
||||
pub denial: Option<Denial>,
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, strum::VariantArray)]
|
||||
pub enum Denial {
|
||||
VirtualRoute,
|
||||
PasswordReset,
|
||||
PassThrough,
|
||||
DeniedPassThrough,
|
||||
CustomAdmin,
|
||||
UserInfo,
|
||||
AdminOnly,
|
||||
ViewerInference,
|
||||
ViewerWrite,
|
||||
ViewerParameter,
|
||||
}
|
||||
|
||||
impl Decision {
|
||||
fn allow() -> Self {
|
||||
Self {
|
||||
allowed: true,
|
||||
denial: None,
|
||||
detail: None,
|
||||
}
|
||||
}
|
||||
fn predicate(value: bool) -> Self {
|
||||
Self {
|
||||
allowed: value,
|
||||
denial: None,
|
||||
detail: None,
|
||||
}
|
||||
}
|
||||
fn deny(denial: Denial) -> Self {
|
||||
Self {
|
||||
allowed: false,
|
||||
denial: Some(denial),
|
||||
detail: None,
|
||||
}
|
||||
}
|
||||
fn detail(denial: Denial, detail: String) -> Self {
|
||||
Self {
|
||||
allowed: false,
|
||||
denial: Some(denial),
|
||||
detail: Some(detail),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn denied_pattern(route: &str, pattern: &str) -> bool {
|
||||
let normalized = pattern.trim_end_matches('/');
|
||||
normalized.is_empty()
|
||||
|| patterns::prefix(route, normalized)
|
||||
|| patterns::wildcard(route, pattern)
|
||||
}
|
||||
|
||||
fn jwt_grant(route: &str, entries: &[String]) -> bool {
|
||||
entries.iter().any(|entry| {
|
||||
!entry.trim_end_matches('*').trim_matches('/').is_empty()
|
||||
&& patterns::wildcard(route, entry)
|
||||
})
|
||||
}
|
||||
|
||||
fn passthrough_grant(route: &str, facts: &PassThroughFacts) -> bool {
|
||||
let entries = if facts.key_allowed.is_empty() {
|
||||
&facts.team_allowed
|
||||
} else {
|
||||
&facts.key_allowed
|
||||
};
|
||||
entries.iter().any(|entry| patterns::prefix(route, entry))
|
||||
}
|
||||
|
||||
fn denied_passthrough(facts: &PassThroughFacts) -> Option<&str> {
|
||||
facts
|
||||
.denied
|
||||
.iter()
|
||||
.find(|denied| {
|
||||
facts
|
||||
.forwarded
|
||||
.iter()
|
||||
.any(|route| denied_pattern(route, denied))
|
||||
})
|
||||
.map(String::as_str)
|
||||
}
|
||||
|
||||
fn require_passthrough(route: &str, facts: &PassThroughFacts) -> Decision {
|
||||
if let Some(denied) = denied_passthrough(facts) {
|
||||
return Decision::detail(Denial::DeniedPassThrough, denied.to_owned());
|
||||
}
|
||||
if passthrough_grant(route, facts) || jwt_grant(route, &facts.jwt_team_routes) {
|
||||
return Decision::allow();
|
||||
}
|
||||
Decision::deny(Denial::PassThrough)
|
||||
}
|
||||
|
||||
fn azure(route: &str) -> Result<bool, Error> {
|
||||
Ok(patterns::regex_match(
|
||||
"^/openai/deployments/[^/]+/[^/]+/chat/completions(?=\\n?\\z)",
|
||||
route,
|
||||
)? || patterns::regex_match("^/engines/[^/]+/chat/completions(?=\\n?\\z)", route)?)
|
||||
}
|
||||
|
||||
fn llm(context: &RouteContext) -> Result<bool, Error> {
|
||||
let route = &context.route;
|
||||
let catalog = &context.catalog;
|
||||
for group in ["openai_routes", "anthropic_routes", "google_routes"] {
|
||||
if catalog.contains(route, group) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
for group in [
|
||||
"mcp_inference_routes",
|
||||
"agent_inference_routes",
|
||||
"litellm_native_routes",
|
||||
] {
|
||||
if catalog.access(route, group)? {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
for group in ["openai_routes", "google_routes", "anthropic_routes"] {
|
||||
for pattern in catalog.group(group) {
|
||||
if pattern.contains('{') && patterns::route_pattern(route, pattern)? {
|
||||
return Ok(true);
|
||||
}
|
||||
if group == "openai_routes"
|
||||
&& pattern.ends_with('*')
|
||||
&& patterns::wildcard(route, pattern)
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(azure(route)?
|
||||
|| catalog
|
||||
.group("mapped_pass_through_routes")
|
||||
.iter()
|
||||
.any(|pattern| patterns::prefix(route, pattern))
|
||||
|| (route.trim_end_matches('/') == "/eu_jev/v1/systemone" && !context.configured_eu_jev))
|
||||
}
|
||||
|
||||
fn discovery(context: &RouteContext) -> bool {
|
||||
context.method.as_deref() == Some("GET")
|
||||
&& (context.route == "/v1/mcp/server"
|
||||
|| context
|
||||
.route
|
||||
.strip_prefix("/v1/mcp/server/")
|
||||
.is_some_and(|tail| !tail.is_empty() && !tail.contains('/')))
|
||||
}
|
||||
|
||||
fn self_credential(context: &RouteContext) -> Result<bool, Error> {
|
||||
for (method, pattern) in &context.catalog.self_credentials {
|
||||
if context.method.as_ref() == Some(method)
|
||||
&& patterns::route_access(&context.route, std::slice::from_ref(pattern))?
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn managed(context: &RouteContext) -> Result<bool, Error> {
|
||||
let route = &context.route;
|
||||
let method = context.method.as_deref();
|
||||
let catalog = &context.catalog;
|
||||
if route == "/agents" || route == "/v1/agents" {
|
||||
return Ok(matches!(method, None | Some("GET" | "HEAD")));
|
||||
}
|
||||
if catalog.managed_realtime.contains(route) || catalog.managed_live.contains(route) {
|
||||
return Ok(matches!(method, None | Some("GET")));
|
||||
}
|
||||
if catalog.managed_models.contains(route)
|
||||
|| patterns::route_access(route, &catalog.managed_paths)?
|
||||
{
|
||||
return Ok(matches!(method, None | Some("POST")));
|
||||
}
|
||||
Ok(patterns::route_access(route, &catalog.managed_mcp)?
|
||||
|| catalog.access(route, "agent_inference_routes")?)
|
||||
}
|
||||
|
||||
fn virtual_route(facts: &VirtualRouteFacts) -> Result<Decision, Error> {
|
||||
let Some(allowed) = &facts.allowed_routes else {
|
||||
return Ok(Decision::allow());
|
||||
};
|
||||
if allowed.is_empty() {
|
||||
return Ok(Decision::allow());
|
||||
}
|
||||
let context = &facts.context;
|
||||
let route = &context.route;
|
||||
if allowed
|
||||
.iter()
|
||||
.any(|pattern| patterns::prefix(route, pattern))
|
||||
{
|
||||
return Ok(Decision::allow());
|
||||
}
|
||||
let mut denied_passthrough = false;
|
||||
for group in allowed {
|
||||
if !context.catalog.groups.contains_key(group) {
|
||||
continue;
|
||||
}
|
||||
let is_llm = matches!(
|
||||
group.as_str(),
|
||||
"llm_api_routes" | "llm_api_routes_without_mcp"
|
||||
);
|
||||
if context.catalog.access(route, group)? {
|
||||
if (is_llm || group == "openai_routes") && context.registered_auth == Some(true) {
|
||||
if passthrough_grant(route, &facts.passthrough) {
|
||||
return Ok(Decision::allow());
|
||||
}
|
||||
denied_passthrough = true;
|
||||
} else {
|
||||
return Ok(Decision::allow());
|
||||
}
|
||||
}
|
||||
if !is_llm {
|
||||
continue;
|
||||
}
|
||||
if route == "/auto_router/session" && context.method.as_deref() == Some("GET") {
|
||||
return Ok(Decision::allow());
|
||||
}
|
||||
if context.registered {
|
||||
if context.registered_auth == Some(true) {
|
||||
if passthrough_grant(route, &facts.passthrough) {
|
||||
return Ok(Decision::allow());
|
||||
}
|
||||
denied_passthrough = true;
|
||||
} else {
|
||||
return Ok(Decision::allow());
|
||||
}
|
||||
}
|
||||
if group == "llm_api_routes"
|
||||
&& (discovery(context) || context.catalog.access(route, "agent_management_routes")?)
|
||||
{
|
||||
return Ok(Decision::allow());
|
||||
}
|
||||
}
|
||||
if allowed
|
||||
.iter()
|
||||
.any(|pattern| patterns::wildcard(route, pattern))
|
||||
{
|
||||
return Ok(Decision::allow());
|
||||
}
|
||||
Ok(Decision::deny(if denied_passthrough {
|
||||
Denial::PassThrough
|
||||
} else if facts.password_reset_required {
|
||||
Denial::PasswordReset
|
||||
} else {
|
||||
Denial::VirtualRoute
|
||||
}))
|
||||
}
|
||||
|
||||
fn viewer(facts: &ViewerFacts) -> Result<Decision, Error> {
|
||||
let context = &facts.context;
|
||||
let route = &context.route;
|
||||
let catalog = &context.catalog;
|
||||
if llm(context)? {
|
||||
return Ok(Decision::deny(Denial::ViewerInference));
|
||||
}
|
||||
let invalid = facts
|
||||
.request_keys
|
||||
.iter()
|
||||
.find(|key| key.as_str() != "user_email");
|
||||
if catalog.access(route, "management_routes")? {
|
||||
if route == "/user/update" {
|
||||
if let Some(key) = invalid {
|
||||
return Ok(Decision::detail(Denial::ViewerParameter, key.clone()));
|
||||
}
|
||||
} else if patterns::route_access(route, &catalog.viewer_blocked)?
|
||||
|| (route.starts_with("/key/")
|
||||
&& (route.ends_with("/regenerate") || route.ends_with("/reset_spend")))
|
||||
{
|
||||
return Ok(Decision::deny(Denial::ViewerWrite));
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
context.method.as_deref().unwrap_or("GET"),
|
||||
"GET" | "HEAD" | "OPTIONS"
|
||||
) {
|
||||
return Ok(Decision::allow());
|
||||
}
|
||||
if route == "/user/update" {
|
||||
return Ok(invalid.map_or_else(Decision::allow, |key| {
|
||||
Decision::detail(Denial::ViewerParameter, key.clone())
|
||||
}));
|
||||
}
|
||||
if route == "/user/password/change" || route == "/session/logout" || self_credential(context)? {
|
||||
return Ok(Decision::allow());
|
||||
}
|
||||
if patterns::route_access(route, &catalog.viewer_write_blocked)?
|
||||
|| (route.starts_with("/key/") && route.ends_with("/regenerate"))
|
||||
{
|
||||
return Ok(Decision::deny(Denial::ViewerWrite));
|
||||
}
|
||||
if catalog.access(route, "admin_viewer_routes")?
|
||||
|| catalog.access(route, "global_spend_tracking_routes")?
|
||||
{
|
||||
return Ok(Decision::allow());
|
||||
}
|
||||
Ok(Decision::deny(Denial::ViewerWrite))
|
||||
}
|
||||
|
||||
fn non_admin(facts: &NonAdminFacts) -> Result<Decision, Error> {
|
||||
let context = &facts.viewer.context;
|
||||
let route = &context.route;
|
||||
let catalog = &context.catalog;
|
||||
let role = facts.role.as_deref();
|
||||
if facts.premium && facts.custom_admin_routes.contains(route) {
|
||||
return Ok(Decision::deny(Denial::CustomAdmin));
|
||||
}
|
||||
if context.registered_auth == Some(true) {
|
||||
return Ok(require_passthrough(route, &facts.passthrough));
|
||||
}
|
||||
if llm(context)? {
|
||||
return Ok(Decision::allow());
|
||||
}
|
||||
if catalog.contains(route, "info_routes") {
|
||||
if route == "/user/info"
|
||||
&& facts
|
||||
.query_user_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| !id.is_empty())
|
||||
&& facts.query_user_id != facts.token_user_id
|
||||
&& role != Some("proxy_admin_viewer")
|
||||
{
|
||||
return Ok(Decision::deny(Denial::UserInfo));
|
||||
}
|
||||
return Ok(Decision::allow());
|
||||
}
|
||||
if catalog.contains(route, "global_spend_tracking_routes")
|
||||
&& facts
|
||||
.permissions
|
||||
.iter()
|
||||
.any(|entry| entry == "get_spend_routes")
|
||||
{
|
||||
return Ok(Decision::allow());
|
||||
}
|
||||
if patterns::prefix(route, "/lens") {
|
||||
return Ok(Decision::allow());
|
||||
}
|
||||
if role == Some("proxy_admin_viewer") {
|
||||
return viewer(&facts.viewer);
|
||||
}
|
||||
let org_admin = !facts.organization_ids.is_empty()
|
||||
&& facts
|
||||
.organization_ids
|
||||
.iter()
|
||||
.all(|id| facts.admin_organization_ids.contains(id));
|
||||
if (role == Some("internal_user")
|
||||
&& (catalog.access(route, "internal_user_routes")? || self_credential(context)?))
|
||||
|| (org_admin && catalog.access(route, "org_admin_allowed_routes")?)
|
||||
|| (role == Some("internal_user_viewer")
|
||||
&& catalog.access(route, "internal_user_view_only_routes")?)
|
||||
|| catalog.access(route, "self_managed_routes")?
|
||||
|| route.starts_with("/v1/mcp/")
|
||||
|| route.starts_with("/mcp-rest/")
|
||||
|| passthrough_grant(route, &facts.passthrough)
|
||||
|| (facts.team_service_account
|
||||
&& catalog.access(route, "team_service_account_key_routes")?)
|
||||
{
|
||||
return Ok(Decision::allow());
|
||||
}
|
||||
if facts.allowed_routes.as_ref().is_some_and(|entries| {
|
||||
entries
|
||||
.iter()
|
||||
.any(|entry| patterns::prefix(route, entry) || patterns::wildcard(route, entry))
|
||||
}) {
|
||||
return Ok(Decision::allow());
|
||||
}
|
||||
Ok(Decision::deny(Denial::AdminOnly))
|
||||
}
|
||||
|
||||
pub fn decide(request: Request) -> Result<Decision, Error> {
|
||||
match request {
|
||||
Request::Virtual(facts) => virtual_route(&facts),
|
||||
Request::Viewer(facts) => viewer(&facts),
|
||||
Request::NonAdmin(facts) => non_admin(&facts),
|
||||
Request::PassThrough {
|
||||
context,
|
||||
facts,
|
||||
required,
|
||||
} => Ok(if required {
|
||||
require_passthrough(&context.route, &facts)
|
||||
} else {
|
||||
Decision::predicate(passthrough_grant(&context.route, &facts))
|
||||
}),
|
||||
Request::DeniedPassThrough { route: _, facts } => Ok(denied_passthrough(&facts)
|
||||
.map_or_else(Decision::allow, |entry| {
|
||||
Decision::detail(Denial::DeniedPassThrough, entry.to_owned())
|
||||
})),
|
||||
Request::Match(facts) => {
|
||||
let matched = match facts.mode {
|
||||
MatchMode::Access => patterns::route_access(&facts.route, &facts.patterns)?,
|
||||
MatchMode::Jwt => jwt_grant(&facts.route, &facts.patterns),
|
||||
MatchMode::Template => {
|
||||
let mut found = false;
|
||||
for pattern in &facts.patterns {
|
||||
if patterns::route_pattern(&facts.route, pattern)? {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
MatchMode::Prefix => facts
|
||||
.patterns
|
||||
.iter()
|
||||
.any(|pattern| patterns::prefix(&facts.route, pattern)),
|
||||
MatchMode::Wildcard => facts
|
||||
.patterns
|
||||
.iter()
|
||||
.any(|pattern| patterns::wildcard(&facts.route, pattern)),
|
||||
MatchMode::Denied => facts
|
||||
.patterns
|
||||
.iter()
|
||||
.any(|pattern| denied_pattern(&facts.route, pattern)),
|
||||
};
|
||||
Ok(Decision::predicate(matched))
|
||||
}
|
||||
Request::Classify { context, kind } => Ok(Decision::predicate(match kind {
|
||||
Classification::Llm => llm(&context)?,
|
||||
Classification::Azure => azure(&context.route)?,
|
||||
Classification::Discovery => discovery(&context),
|
||||
Classification::SelfCredential => self_credential(&context)?,
|
||||
Classification::Managed => managed(&context)?,
|
||||
Classification::Unbilled => {
|
||||
let tail = context.route.rsplit('/').next().unwrap_or("");
|
||||
let (resource, action) = tail.split_once(':').unwrap_or((tail, ""));
|
||||
context.catalog.unbilled.contains(&context.route)
|
||||
|| matches!(resource, "count_tokens" | "count-tokens")
|
||||
|| action == "countTokens"
|
||||
}
|
||||
})),
|
||||
}
|
||||
}
|
||||
180
litellm-rust/crates/request-auth-core/src/route_enable.rs
Normal file
180
litellm-rust/crates/request-auth-core/src/route_enable.rs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
use crate::{
|
||||
Error, patterns,
|
||||
route_access::{self, Classification, RouteContext},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, strum::VariantArray)]
|
||||
pub enum Feature {
|
||||
Admin,
|
||||
Llm,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Setting {
|
||||
pub environment_present: bool,
|
||||
pub premium: bool,
|
||||
pub secret: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Facts {
|
||||
pub context: RouteContext,
|
||||
pub imported: bool,
|
||||
pub failed: Option<Failure>,
|
||||
pub admin: Option<Setting>,
|
||||
pub llm: Option<Setting>,
|
||||
pub exemptions: Option<Vec<String>>,
|
||||
pub eu_jev_configured: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Deserialize, strum::VariantArray)]
|
||||
pub enum Failure {
|
||||
Http,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, strum::VariantArray)]
|
||||
pub enum Reason {
|
||||
License,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Decision {
|
||||
Allow,
|
||||
PropagateHttp,
|
||||
ReadImport,
|
||||
ReadExemptions,
|
||||
ReadEuJev,
|
||||
ReadSettings { feature: Feature },
|
||||
ReadSecret { feature: Feature },
|
||||
Deny { feature: Feature, reason: Reason },
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, strum::VariantArray)]
|
||||
pub enum LlmDecision {
|
||||
Allow,
|
||||
Deny,
|
||||
ReadEuJev,
|
||||
}
|
||||
|
||||
pub fn llm_route(context: &RouteContext, configured: Option<bool>) -> Result<LlmDecision, Error> {
|
||||
let static_context = RouteContext {
|
||||
configured_eu_jev: true,
|
||||
..context.clone()
|
||||
};
|
||||
if route_access::decide(route_access::Request::Classify {
|
||||
context: static_context,
|
||||
kind: Classification::Llm,
|
||||
})?
|
||||
.allowed
|
||||
{
|
||||
return Ok(LlmDecision::Allow);
|
||||
}
|
||||
if context.route.trim_end_matches('/') != "/eu_jev/v1/systemone" {
|
||||
return Ok(LlmDecision::Deny);
|
||||
}
|
||||
Ok(match configured {
|
||||
None => LlmDecision::ReadEuJev,
|
||||
Some(true) => LlmDecision::Deny,
|
||||
Some(false) => LlmDecision::Allow,
|
||||
})
|
||||
}
|
||||
|
||||
fn setting(setting: Option<&Setting>, feature: Feature) -> Option<Decision> {
|
||||
let Some(setting) = setting else {
|
||||
return Some(Decision::ReadSettings { feature });
|
||||
};
|
||||
if setting.environment_present && !setting.premium {
|
||||
return Some(Decision::Deny {
|
||||
feature,
|
||||
reason: Reason::License,
|
||||
});
|
||||
}
|
||||
match setting.secret {
|
||||
None => Some(Decision::ReadSecret { feature }),
|
||||
Some(true) => Some(Decision::Deny {
|
||||
feature,
|
||||
reason: Reason::Disabled,
|
||||
}),
|
||||
Some(false) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decide(facts: &Facts) -> Result<Decision, Error> {
|
||||
if let Some(failed) = facts.failed {
|
||||
return Ok(match failed {
|
||||
Failure::Http => Decision::PropagateHttp,
|
||||
Failure::Other => Decision::Allow,
|
||||
});
|
||||
}
|
||||
if !facts.imported {
|
||||
return Ok(Decision::ReadImport);
|
||||
}
|
||||
let management = facts
|
||||
.context
|
||||
.catalog
|
||||
.groups
|
||||
.get("management_routes")
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default();
|
||||
if patterns::route_access(&facts.context.route, management)?
|
||||
&& let Some(decision) = setting(facts.admin.as_ref(), Feature::Admin)
|
||||
{
|
||||
return Ok(decision);
|
||||
}
|
||||
match llm_route(&facts.context, facts.eu_jev_configured)? {
|
||||
LlmDecision::Deny => return Ok(Decision::Allow),
|
||||
LlmDecision::ReadEuJev => return Ok(Decision::ReadEuJev),
|
||||
LlmDecision::Allow => (),
|
||||
}
|
||||
let Some(exemptions) = &facts.exemptions else {
|
||||
return Ok(Decision::ReadExemptions);
|
||||
};
|
||||
if exemptions.contains(&facts.context.route) {
|
||||
return Ok(Decision::Allow);
|
||||
}
|
||||
Ok(setting(facts.llm.as_ref(), Feature::Llm).unwrap_or(Decision::Allow))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::missing(None, LlmDecision::ReadEuJev)]
|
||||
#[case::builtin(Some(false), LlmDecision::Allow)]
|
||||
#[case::configured(Some(true), LlmDecision::Deny)]
|
||||
fn builtin_configuration(#[case] configured: Option<bool>, #[case] expected: LlmDecision) {
|
||||
let context = RouteContext {
|
||||
route: "/eu_jev/v1/systemone/".into(),
|
||||
..RouteContext::default()
|
||||
};
|
||||
assert_eq!(llm_route(&context, configured).unwrap(), expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::license(true, false, Decision::Deny { feature: Feature::Admin, reason: Reason::License })]
|
||||
#[case::absent(false, false, Decision::ReadSecret { feature: Feature::Admin })]
|
||||
#[case::premium(true, true, Decision::ReadSecret { feature: Feature::Admin })]
|
||||
fn license_precedes_secret(
|
||||
#[case] environment_present: bool,
|
||||
#[case] premium: bool,
|
||||
#[case] expected: Decision,
|
||||
) {
|
||||
assert_eq!(
|
||||
setting(
|
||||
Some(&Setting {
|
||||
environment_present,
|
||||
premium,
|
||||
secret: None
|
||||
}),
|
||||
Feature::Admin
|
||||
),
|
||||
Some(expected)
|
||||
);
|
||||
}
|
||||
}
|
||||
325
litellm-rust/crates/request-auth-core/src/route_roles.rs
Normal file
325
litellm-rust/crates/request-auth-core/src/route_roles.rs
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Error, patterns};
|
||||
|
||||
#[derive(
|
||||
Clone, Copy, PartialEq, Eq, strum::EnumString, strum::IntoStaticStr, strum::VariantArray,
|
||||
)]
|
||||
enum Role {
|
||||
#[strum(serialize = "proxy_admin")]
|
||||
Admin,
|
||||
#[strum(serialize = "proxy_admin_viewer")]
|
||||
Viewer,
|
||||
#[strum(serialize = "org_admin")]
|
||||
Organization,
|
||||
#[strum(serialize = "internal_user")]
|
||||
Internal,
|
||||
#[strum(serialize = "internal_user_viewer")]
|
||||
InternalViewer,
|
||||
#[strum(serialize = "team")]
|
||||
Team,
|
||||
#[strum(serialize = "customer")]
|
||||
Customer,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RoleFacts {
|
||||
pub user_present: bool,
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct RoleDecision {
|
||||
pub effective: &'static str,
|
||||
pub user_role: Option<&'static str>,
|
||||
pub admin: bool,
|
||||
}
|
||||
|
||||
pub fn role(facts: &RoleFacts) -> RoleDecision {
|
||||
let parsed = facts
|
||||
.role
|
||||
.as_deref()
|
||||
.and_then(|value| value.parse::<Role>().ok());
|
||||
let effective = parsed.unwrap_or(Role::Internal).into();
|
||||
RoleDecision {
|
||||
effective,
|
||||
user_role: facts.user_present.then_some(effective),
|
||||
admin: facts.user_present && parsed == Some(Role::Admin),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ApiFacts {
|
||||
pub token_present: bool,
|
||||
pub admin_read: bool,
|
||||
pub user: RoleFacts,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, strum::VariantArray)]
|
||||
pub enum ApiDecision {
|
||||
InvalidToken,
|
||||
ReadAdmin,
|
||||
NonAdmin,
|
||||
Allow,
|
||||
}
|
||||
|
||||
pub fn api(facts: &ApiFacts) -> ApiDecision {
|
||||
if !facts.token_present {
|
||||
ApiDecision::InvalidToken
|
||||
} else if !facts.admin_read {
|
||||
ApiDecision::ReadAdmin
|
||||
} else if role(&facts.user).admin {
|
||||
ApiDecision::Allow
|
||||
} else {
|
||||
ApiDecision::NonAdmin
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct JwtFacts {
|
||||
pub role: Option<String>,
|
||||
pub config_read: bool,
|
||||
pub config_missing: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, strum::VariantArray)]
|
||||
pub enum JwtDecision {
|
||||
ReadAdmin,
|
||||
ReadTeam,
|
||||
DefaultTeam,
|
||||
Check,
|
||||
Deny,
|
||||
}
|
||||
|
||||
pub fn jwt(facts: &JwtFacts) -> JwtDecision {
|
||||
match facts.role.as_deref() {
|
||||
Some("proxy_admin") if !facts.config_read => JwtDecision::ReadAdmin,
|
||||
Some("team") if !facts.config_read => JwtDecision::ReadTeam,
|
||||
Some("team") if facts.config_missing => JwtDecision::DefaultTeam,
|
||||
Some("proxy_admin" | "team") => JwtDecision::Check,
|
||||
_ => JwtDecision::Deny,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct InsideFacts {
|
||||
pub role: Option<String>,
|
||||
pub user_id: Option<String>,
|
||||
pub requested_user_id: Option<String>,
|
||||
}
|
||||
|
||||
pub fn inside(facts: &InsideFacts) -> bool {
|
||||
matches!(
|
||||
facts.role.as_deref(),
|
||||
Some("proxy_admin" | "proxy_admin_viewer")
|
||||
) || facts.user_id.is_some() && facts.user_id == facts.requested_user_id
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Entry {
|
||||
pub text: Option<String>,
|
||||
pub python_type: String,
|
||||
pub hash_failure: Option<String>,
|
||||
pub templates: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListedFacts {
|
||||
pub route: String,
|
||||
pub entry: Option<Entry>,
|
||||
pub exhausted: bool,
|
||||
pub template_index: usize,
|
||||
pub compiled: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, strum::VariantArray)]
|
||||
pub enum Action {
|
||||
ReadEntry,
|
||||
ReadTemplate,
|
||||
Allow,
|
||||
Deny,
|
||||
Keep,
|
||||
Expand,
|
||||
Failure,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, strum::VariantArray)]
|
||||
pub enum FailureKind {
|
||||
TypeError,
|
||||
AttributeError,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct Failure {
|
||||
pub kind: FailureKind,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct EntryDecision {
|
||||
pub action: Action,
|
||||
pub template_index: usize,
|
||||
pub failure: Option<Failure>,
|
||||
}
|
||||
|
||||
impl EntryDecision {
|
||||
fn action(action: Action) -> Self {
|
||||
Self {
|
||||
action,
|
||||
template_index: 0,
|
||||
failure: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(kind: FailureKind, message: String) -> Self {
|
||||
Self {
|
||||
action: Action::Failure,
|
||||
template_index: 0,
|
||||
failure: Some(Failure { kind, message }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn expand(entry: &Entry) -> EntryDecision {
|
||||
if let Some(message) = &entry.hash_failure {
|
||||
EntryDecision::failure(FailureKind::TypeError, message.clone())
|
||||
} else if entry.templates.is_some() {
|
||||
EntryDecision::action(Action::Expand)
|
||||
} else {
|
||||
EntryDecision::action(Action::Keep)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn listed(facts: &ListedFacts) -> Result<EntryDecision, Error> {
|
||||
if facts.exhausted {
|
||||
return Ok(EntryDecision::action(Action::Deny));
|
||||
}
|
||||
let Some(entry) = &facts.entry else {
|
||||
return Ok(EntryDecision::action(Action::ReadEntry));
|
||||
};
|
||||
if let Some(message) = &entry.hash_failure {
|
||||
return Ok(EntryDecision::failure(
|
||||
FailureKind::TypeError,
|
||||
message.clone(),
|
||||
));
|
||||
}
|
||||
let Some(text) = &entry.text else {
|
||||
return Ok(EntryDecision::failure(
|
||||
FailureKind::AttributeError,
|
||||
format!("'{}' object has no attribute 'endswith'", entry.python_type),
|
||||
));
|
||||
};
|
||||
let Some(templates) = &entry.templates else {
|
||||
return Ok(EntryDecision::action(
|
||||
if patterns::wildcard(&facts.route, text) {
|
||||
Action::Allow
|
||||
} else {
|
||||
Action::ReadEntry
|
||||
},
|
||||
));
|
||||
};
|
||||
let next = if let Some(compiled) = &facts.compiled {
|
||||
let expression = compiled
|
||||
.strip_suffix('$')
|
||||
.map_or_else(|| compiled.clone(), |prefix| format!("{prefix}(?=\\n?\\z)"));
|
||||
if patterns::regex_match(&expression, &facts.route)? {
|
||||
return Ok(EntryDecision::action(Action::Allow));
|
||||
}
|
||||
facts.template_index + 1
|
||||
} else {
|
||||
facts.template_index
|
||||
};
|
||||
Ok(if next < templates.len() {
|
||||
EntryDecision {
|
||||
action: Action::ReadTemplate,
|
||||
template_index: next,
|
||||
failure: None,
|
||||
}
|
||||
} else {
|
||||
EntryDecision::action(Action::ReadEntry)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::absent(false, None, None, false)]
|
||||
#[case::default(true, None, Some("internal_user"), false)]
|
||||
#[case::unknown(true, Some("ADMIN"), Some("internal_user"), false)]
|
||||
#[case::admin(true, Some("proxy_admin"), Some("proxy_admin"), true)]
|
||||
#[case::viewer(true, Some("proxy_admin_viewer"), Some("proxy_admin_viewer"), false)]
|
||||
fn roles(
|
||||
#[case] present: bool,
|
||||
#[case] raw: Option<&str>,
|
||||
#[case] expected: Option<&str>,
|
||||
#[case] admin: bool,
|
||||
) {
|
||||
let result = role(&RoleFacts {
|
||||
user_present: present,
|
||||
role: raw.map(str::to_owned),
|
||||
});
|
||||
assert_eq!(result.user_role, expected);
|
||||
assert_eq!(result.admin, admin);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::invalid(false, false, ApiDecision::InvalidToken)]
|
||||
#[case::read(true, false, ApiDecision::ReadAdmin)]
|
||||
#[case::nonadmin(true, true, ApiDecision::NonAdmin)]
|
||||
fn api_order(#[case] token: bool, #[case] read: bool, #[case] expected: ApiDecision) {
|
||||
assert_eq!(
|
||||
api(&ApiFacts {
|
||||
token_present: token,
|
||||
admin_read: read,
|
||||
user: RoleFacts {
|
||||
user_present: false,
|
||||
role: None
|
||||
}
|
||||
}),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::read("team", false, false, JwtDecision::ReadTeam)]
|
||||
#[case::default("team", true, true, JwtDecision::DefaultTeam)]
|
||||
#[case::empty("team", true, false, JwtDecision::Check)]
|
||||
#[case::other("internal_user", false, false, JwtDecision::Deny)]
|
||||
fn jwt_selection(
|
||||
#[case] role: &str,
|
||||
#[case] read: bool,
|
||||
#[case] missing: bool,
|
||||
#[case] expected: JwtDecision,
|
||||
) {
|
||||
assert_eq!(
|
||||
jwt(&JwtFacts {
|
||||
role: Some(role.into()),
|
||||
config_read: read,
|
||||
config_missing: missing
|
||||
}),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::self_empty(Some(""), Some(""), true)]
|
||||
#[case::absent(None, None, false)]
|
||||
#[case::different(Some("a"), Some("b"), false)]
|
||||
fn inside_identity(
|
||||
#[case] user: Option<&str>,
|
||||
#[case] requested: Option<&str>,
|
||||
#[case] expected: bool,
|
||||
) {
|
||||
assert_eq!(
|
||||
inside(&InsideFacts {
|
||||
role: None,
|
||||
user_id: user.map(str::to_owned),
|
||||
requested_user_id: requested.map(str::to_owned)
|
||||
}),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,299 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Facts {
|
||||
Spend {
|
||||
current: f64,
|
||||
verified: bool,
|
||||
fallback: f64,
|
||||
limit: Option<f64>,
|
||||
window: bool,
|
||||
fallback_authoritative: bool,
|
||||
strict_before: Option<bool>,
|
||||
authoritative_read: bool,
|
||||
authoritative: Option<f64>,
|
||||
strict_after: Option<bool>,
|
||||
},
|
||||
ThrottleKey {
|
||||
counter_key: String,
|
||||
token_present: bool,
|
||||
token: Option<String>,
|
||||
},
|
||||
Overflow {
|
||||
current: f64,
|
||||
limit: f64,
|
||||
},
|
||||
OverBudget {
|
||||
throttled: bool,
|
||||
current: f64,
|
||||
limit: f64,
|
||||
reservation: f64,
|
||||
strict: bool,
|
||||
},
|
||||
ReservationFailure {
|
||||
strict: bool,
|
||||
},
|
||||
ReservationGroups {
|
||||
currents: Vec<f64>,
|
||||
limits: Vec<f64>,
|
||||
reservation: f64,
|
||||
},
|
||||
Adjustment {
|
||||
current: Option<f64>,
|
||||
adjustment: f64,
|
||||
},
|
||||
ResizeConsistency {
|
||||
inconsistent: bool,
|
||||
reseed: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Decision {
|
||||
Value { spend: f64 },
|
||||
Repair { spend: f64 },
|
||||
ReadStrictBefore,
|
||||
ReadStrictAfter,
|
||||
ReadFloor,
|
||||
Unverifiable,
|
||||
ReadThrottle,
|
||||
Skip,
|
||||
Release,
|
||||
Reject { current: f64, estimate: Option<f64> },
|
||||
Resize { cost: f64 },
|
||||
CheckOverflow,
|
||||
Unavailable,
|
||||
Compatible,
|
||||
Inconsistent,
|
||||
RejectResize,
|
||||
ReservationGroups { groups: Vec<Vec<usize>> },
|
||||
}
|
||||
|
||||
pub fn decide(facts: Facts) -> Decision {
|
||||
match facts {
|
||||
Facts::Adjustment {
|
||||
current,
|
||||
adjustment,
|
||||
} => {
|
||||
if current.is_some_and(|current| !(adjustment < 0.0 && current + adjustment < -1e-12)) {
|
||||
Decision::Compatible
|
||||
} else {
|
||||
Decision::Inconsistent
|
||||
}
|
||||
}
|
||||
Facts::ResizeConsistency {
|
||||
inconsistent,
|
||||
reseed,
|
||||
} => {
|
||||
if inconsistent && !reseed {
|
||||
Decision::RejectResize
|
||||
} else {
|
||||
Decision::Compatible
|
||||
}
|
||||
}
|
||||
Facts::Spend {
|
||||
current,
|
||||
verified,
|
||||
fallback,
|
||||
limit,
|
||||
window,
|
||||
fallback_authoritative,
|
||||
strict_before,
|
||||
authoritative_read,
|
||||
authoritative,
|
||||
strict_after,
|
||||
} => {
|
||||
if limit.is_none_or(|limit| current >= limit) {
|
||||
return Decision::Value { spend: current };
|
||||
}
|
||||
let obvious_floor = fallback > current || window;
|
||||
if !obvious_floor && strict_before.is_none() {
|
||||
return Decision::ReadStrictBefore;
|
||||
}
|
||||
if obvious_floor || strict_before == Some(true) {
|
||||
if !authoritative_read {
|
||||
return Decision::ReadFloor;
|
||||
}
|
||||
if let Some(spend) = authoritative {
|
||||
if spend > current {
|
||||
return Decision::Repair { spend };
|
||||
}
|
||||
} else if fallback > current {
|
||||
return Decision::Value { spend: fallback };
|
||||
}
|
||||
}
|
||||
if !(verified || fallback_authoritative || authoritative.is_some()) {
|
||||
return match strict_after {
|
||||
None => Decision::ReadStrictAfter,
|
||||
Some(true) => Decision::Unverifiable,
|
||||
Some(false) => Decision::Value { spend: current },
|
||||
};
|
||||
}
|
||||
Decision::Value { spend: current }
|
||||
}
|
||||
Facts::ThrottleKey {
|
||||
counter_key,
|
||||
token_present,
|
||||
token,
|
||||
} => {
|
||||
if token_present
|
||||
&& counter_key == format!("spend:key:{}", token.as_deref().unwrap_or("None"))
|
||||
{
|
||||
Decision::ReadThrottle
|
||||
} else {
|
||||
Decision::Skip
|
||||
}
|
||||
}
|
||||
Facts::Overflow { current, limit } => {
|
||||
if current > limit {
|
||||
Decision::CheckOverflow
|
||||
} else {
|
||||
Decision::Skip
|
||||
}
|
||||
}
|
||||
Facts::OverBudget {
|
||||
throttled,
|
||||
current,
|
||||
limit,
|
||||
reservation,
|
||||
strict,
|
||||
} => {
|
||||
if throttled {
|
||||
return Decision::Release;
|
||||
}
|
||||
let remaining = limit - (current - reservation);
|
||||
if remaining <= 1e-12 {
|
||||
return Decision::Reject {
|
||||
current,
|
||||
estimate: None,
|
||||
};
|
||||
}
|
||||
if strict && current - limit > 1e-12 {
|
||||
return Decision::Reject {
|
||||
current: current - reservation,
|
||||
estimate: Some(reservation),
|
||||
};
|
||||
}
|
||||
Decision::Resize { cost: remaining }
|
||||
}
|
||||
Facts::ReservationFailure { strict } => {
|
||||
if strict {
|
||||
Decision::Unavailable
|
||||
} else {
|
||||
Decision::Skip
|
||||
}
|
||||
}
|
||||
Facts::ReservationGroups {
|
||||
currents,
|
||||
limits,
|
||||
reservation,
|
||||
} => {
|
||||
let indices: Vec<usize> = (0..currents.len()).collect();
|
||||
let groups = if currents.is_empty() {
|
||||
vec![]
|
||||
} else if currents
|
||||
.iter()
|
||||
.zip(&limits)
|
||||
.all(|(current, limit)| current + reservation <= *limit)
|
||||
{
|
||||
vec![indices]
|
||||
} else {
|
||||
indices.into_iter().map(|index| vec![index]).collect()
|
||||
};
|
||||
Decision::ReservationGroups { groups }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::missing(None, 1.0, Decision::Inconsistent)]
|
||||
#[case::positive(Some(1.0), 1.0, Decision::Compatible)]
|
||||
#[case::negative(Some(1.0), -2.0, Decision::Inconsistent)]
|
||||
#[case::zero(Some(1.0), -1.0, Decision::Compatible)]
|
||||
fn counter_adjustment(
|
||||
#[case] current: Option<f64>,
|
||||
#[case] adjustment: f64,
|
||||
#[case] expected: Decision,
|
||||
) {
|
||||
assert_eq!(
|
||||
decide(Facts::Adjustment {
|
||||
current,
|
||||
adjustment
|
||||
}),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::resize(true, false, Decision::RejectResize)]
|
||||
#[case::settlement(true, true, Decision::Compatible)]
|
||||
#[case::consistent(false, false, Decision::Compatible)]
|
||||
fn consistency(#[case] inconsistent: bool, #[case] reseed: bool, #[case] expected: Decision) {
|
||||
assert_eq!(
|
||||
decide(Facts::ResizeConsistency {
|
||||
inconsistent,
|
||||
reseed
|
||||
}),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::throttled(true, 12.0, true, Decision::Release)]
|
||||
#[case::empty(false, 12.0, false, Decision::Reject { current:12.0, estimate:None })]
|
||||
#[case::partial(false, 11.0, false, Decision::Resize { cost:1.0 })]
|
||||
#[case::strict(false, 11.0, true, Decision::Reject { current:9.0, estimate:Some(2.0) })]
|
||||
fn reservation_outcome(
|
||||
#[case] throttled: bool,
|
||||
#[case] current: f64,
|
||||
#[case] strict: bool,
|
||||
#[case] expected: Decision,
|
||||
) {
|
||||
assert_eq!(
|
||||
decide(Facts::OverBudget {
|
||||
throttled,
|
||||
current,
|
||||
limit: 10.0,
|
||||
reservation: 2.0,
|
||||
strict
|
||||
}),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::unchecked(None, false, None, Decision::Value { spend:2.0 })]
|
||||
#[case::strict_read(Some(3.0), false, None, Decision::ReadStrictBefore)]
|
||||
#[case::window(Some(3.0), true, None, Decision::ReadFloor)]
|
||||
#[case::strict_floor(Some(3.0), false, Some(true), Decision::ReadFloor)]
|
||||
#[case::second_read(Some(3.0), false, Some(false), Decision::ReadStrictAfter)]
|
||||
fn spend_read_order(
|
||||
#[case] limit: Option<f64>,
|
||||
#[case] window: bool,
|
||||
#[case] strict_before: Option<bool>,
|
||||
#[case] expected: Decision,
|
||||
) {
|
||||
assert_eq!(
|
||||
decide(Facts::Spend {
|
||||
current: 2.0,
|
||||
verified: false,
|
||||
fallback: 0.0,
|
||||
limit,
|
||||
window,
|
||||
fallback_authoritative: false,
|
||||
strict_before,
|
||||
authoritative_read: false,
|
||||
authoritative: None,
|
||||
strict_after: None
|
||||
}),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
77
litellm-rust/crates/request-auth-core/src/team_aliases.rs
Normal file
77
litellm-rust/crates/request-auth-core/src/team_aliases.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
use crate::Error;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
pub struct Facts {
|
||||
pub requested: Vec<String>,
|
||||
pub aliases: BTreeMap<String, String>,
|
||||
pub team_id: Option<String>,
|
||||
pub router_present: bool,
|
||||
pub names: Option<Vec<Vec<String>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Decision {
|
||||
ReadNames { count: usize },
|
||||
Complete { models: Vec<String> },
|
||||
}
|
||||
|
||||
pub fn decide(facts: &Facts) -> Result<Decision, Error> {
|
||||
let prefix = format!("model_name_{}_", facts.team_id.as_deref().unwrap_or("None"));
|
||||
let needs_names = |target: &str| facts.router_present && target.starts_with(&prefix);
|
||||
let count = facts
|
||||
.requested
|
||||
.iter()
|
||||
.filter_map(|model| facts.aliases.get(model))
|
||||
.filter(|target| needs_names(target))
|
||||
.count();
|
||||
let names = match &facts.names {
|
||||
None if count != 0 => return Ok(Decision::ReadNames { count }),
|
||||
Some(names) if names.len() != count => return Err(Error::InvalidAnswer),
|
||||
names => names.as_deref().unwrap_or_default(),
|
||||
};
|
||||
let mut reads = names.iter();
|
||||
let models = facts
|
||||
.requested
|
||||
.iter()
|
||||
.map(|model| {
|
||||
let Some(target) = facts.aliases.get(model) else {
|
||||
return model.clone();
|
||||
};
|
||||
if needs_names(target) && !reads.next().is_some_and(|names| names.contains(target)) {
|
||||
model.clone()
|
||||
} else {
|
||||
target.clone()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(Decision::Complete { models })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::missing(false, "alias")]
|
||||
#[case::live(true, "model_name_team_target")]
|
||||
fn deleted_target(#[case] live: bool, #[case] expected: &str) {
|
||||
let target = "model_name_team_target".to_owned();
|
||||
let facts = Facts {
|
||||
requested: vec!["alias".into()],
|
||||
aliases: BTreeMap::from([("alias".into(), target.clone())]),
|
||||
team_id: Some("team".into()),
|
||||
router_present: true,
|
||||
names: Some(vec![if live { vec![target] } else { vec![] }]),
|
||||
};
|
||||
assert_eq!(
|
||||
decide(&facts).unwrap(),
|
||||
Decision::Complete {
|
||||
models: vec![expected.into()]
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
70
litellm-rust/crates/request-auth-core/src/team_override.rs
Normal file
70
litellm-rust/crates/request-auth-core/src/team_override.rs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
use crate::{
|
||||
Error,
|
||||
fallback_access::{self, GroupedScope},
|
||||
model_access::{self, Decision as ModelDecision, Facts as ModelFacts, ObjectType},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
pub struct Facts {
|
||||
pub team: Option<GroupedScope>,
|
||||
pub assigned_models: Option<Vec<String>>,
|
||||
pub assigned_scope: Option<ModelFacts>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Decision {
|
||||
Allow,
|
||||
Deny { denial: ModelDecision },
|
||||
ReadTeam,
|
||||
ReadTeamGroups,
|
||||
ReadAssignedGroups,
|
||||
ReadAssignedScope,
|
||||
}
|
||||
|
||||
pub fn decide(facts: &Facts) -> Result<Decision, Error> {
|
||||
let Some(team) = &facts.team else {
|
||||
return Ok(Decision::ReadTeam);
|
||||
};
|
||||
let denial = fallback_access::grouped(team.clone(), true)?;
|
||||
match denial {
|
||||
ModelDecision::Allow => return Ok(Decision::Allow),
|
||||
ModelDecision::ReadGroups | ModelDecision::ReadGroupScope { .. } => {
|
||||
return Ok(Decision::ReadTeamGroups);
|
||||
}
|
||||
ModelDecision::Deny {
|
||||
object_type: ObjectType::Team,
|
||||
..
|
||||
} => (),
|
||||
_ => return Ok(Decision::Deny { denial }),
|
||||
}
|
||||
let Some(assigned) = &facts.assigned_models else {
|
||||
return Ok(Decision::ReadAssignedGroups);
|
||||
};
|
||||
if assigned.is_empty() {
|
||||
return Ok(Decision::Deny { denial });
|
||||
}
|
||||
let Some(scope) = &facts.assigned_scope else {
|
||||
return Ok(Decision::ReadAssignedScope);
|
||||
};
|
||||
match model_access::check(scope)? {
|
||||
ModelDecision::Allow => Ok(Decision::Allow),
|
||||
ModelDecision::Deny { .. } => Ok(Decision::Deny { denial }),
|
||||
error => Ok(Decision::Deny { denial: error }),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
fn team_is_read_before_override_groups() {
|
||||
assert!(matches!(
|
||||
decide(&Facts::default()).unwrap(),
|
||||
Decision::ReadTeam
|
||||
));
|
||||
}
|
||||
}
|
||||
144
litellm-rust/crates/request-auth-core/src/unpriced_models.rs
Normal file
144
litellm-rust/crates/request-auth-core/src/unpriced_models.rs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Metric {
|
||||
pub name: String,
|
||||
pub scalar: Option<f64>,
|
||||
pub nested: Vec<Option<f64>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Entry {
|
||||
pub tiered_present: bool,
|
||||
pub metrics: Vec<Metric>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Deployment {
|
||||
pub parameter_keys: Vec<String>,
|
||||
pub has_id: bool,
|
||||
pub info_loaded: bool,
|
||||
pub info: Option<Entry>,
|
||||
pub cost_keys: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Facts {
|
||||
pub model: Option<String>,
|
||||
pub router_present: bool,
|
||||
pub group_found: Option<bool>,
|
||||
pub deployments: Option<Vec<Deployment>>,
|
||||
pub cost_deployments: Option<Vec<Deployment>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Decision {
|
||||
Complete { unpriced: bool },
|
||||
ReadGroup,
|
||||
ReadDeployments,
|
||||
ReadCostDeployments,
|
||||
ReadInfo { index: usize },
|
||||
ReadCost { index: usize },
|
||||
}
|
||||
|
||||
pub fn entry_has_priced_metric(entry: &Entry) -> bool {
|
||||
entry.tiered_present
|
||||
|| entry.metrics.iter().any(|metric| {
|
||||
metric.name.contains("cost_per")
|
||||
&& (metric.scalar.is_some_and(|price| price > 0.0)
|
||||
|| metric
|
||||
.nested
|
||||
.iter()
|
||||
.any(|price| price.is_some_and(|value| value > 0.0)))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decide(facts: &Facts) -> Decision {
|
||||
if !facts.router_present || facts.model.as_deref().is_none_or(str::is_empty) {
|
||||
return Decision::Complete { unpriced: false };
|
||||
}
|
||||
match facts.group_found {
|
||||
None => return Decision::ReadGroup,
|
||||
Some(false) => return Decision::Complete { unpriced: false },
|
||||
Some(true) => {}
|
||||
}
|
||||
let Some(deployments) = &facts.deployments else {
|
||||
return Decision::ReadDeployments;
|
||||
};
|
||||
for (index, deployment) in deployments.iter().enumerate() {
|
||||
if deployment
|
||||
.parameter_keys
|
||||
.iter()
|
||||
.any(|key| key.contains("cost_per") || key == "tiered_pricing")
|
||||
{
|
||||
return Decision::Complete { unpriced: false };
|
||||
}
|
||||
if !deployment.has_id {
|
||||
continue;
|
||||
}
|
||||
if !deployment.info_loaded {
|
||||
return Decision::ReadInfo { index };
|
||||
}
|
||||
if deployment
|
||||
.info
|
||||
.as_ref()
|
||||
.is_some_and(entry_has_priced_metric)
|
||||
{
|
||||
return Decision::Complete { unpriced: false };
|
||||
}
|
||||
}
|
||||
let Some(cost_deployments) = &facts.cost_deployments else {
|
||||
return Decision::ReadCostDeployments;
|
||||
};
|
||||
for (index, deployment) in cost_deployments.iter().enumerate() {
|
||||
if !deployment.has_id {
|
||||
continue;
|
||||
}
|
||||
let Some(keys) = &deployment.cost_keys else {
|
||||
return Decision::ReadCost { index };
|
||||
};
|
||||
if keys.iter().any(|key| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"input_cost_per_token" | "output_cost_per_token"
|
||||
)
|
||||
}) {
|
||||
return Decision::Complete { unpriced: false };
|
||||
}
|
||||
}
|
||||
Decision::Complete { unpriced: true }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Entry, Metric, entry_has_priced_metric};
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::positive("input_cost_per_character", Some(0.1), vec![], false, true)]
|
||||
#[case::zero("input_cost_per_token", Some(0.0), vec![], false, false)]
|
||||
#[case::negative("cost_per_query", Some(-1.0), vec![], false, false)]
|
||||
#[case::nested("cost_per_image", None, vec![None, Some(0.2)], false, true)]
|
||||
#[case::non_price("context_window", Some(1000.0), vec![], false, false)]
|
||||
#[case::tiered("context_window", None, vec![], true, true)]
|
||||
fn metrics(
|
||||
#[case] name: &str,
|
||||
#[case] scalar: Option<f64>,
|
||||
#[case] nested: Vec<Option<f64>>,
|
||||
#[case] tiered_present: bool,
|
||||
#[case] expected: bool,
|
||||
) {
|
||||
assert_eq!(
|
||||
entry_has_priced_metric(&Entry {
|
||||
tiered_present,
|
||||
metrics: vec![Metric {
|
||||
name: name.to_owned(),
|
||||
scalar,
|
||||
nested
|
||||
}]
|
||||
}),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
200
litellm/_auth/access.py
Normal file
200
litellm/_auth/access.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Final, Literal, TypeAlias
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm._auth.rust_core import encode_facts
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Blocked:
|
||||
blocked: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UserParamRequest:
|
||||
enabled: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ClientTagRequest:
|
||||
enabled: bool
|
||||
llm_route: bool | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GuardrailRequest:
|
||||
requested_keys: tuple[str, ...]
|
||||
|
||||
|
||||
TraceSource: TypeAlias = Literal["Gate", "Header", "Body", "Traceparent", "Selected", "Other"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TraceRequest:
|
||||
required: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Search:
|
||||
name: str
|
||||
allowed: tuple[str, ...]
|
||||
strict: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TraceGate:
|
||||
required: bool
|
||||
method: str
|
||||
llm_route: bool
|
||||
passthrough: bool
|
||||
telemetry: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TraceStep:
|
||||
source: TraceSource
|
||||
value: str | None
|
||||
present: bool
|
||||
headers_only: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UserParam:
|
||||
enabled: bool
|
||||
method: str | None
|
||||
llm_route: bool
|
||||
mcp_route: bool
|
||||
has_user: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ClientTags:
|
||||
enabled: bool
|
||||
llm_route: bool
|
||||
has_tags: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Guardrails:
|
||||
requested_keys: tuple[str, ...]
|
||||
modify_guardrails: bool | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentTrace:
|
||||
required: bool
|
||||
header: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConcurrentFailures:
|
||||
failures: tuple[bool, ...]
|
||||
terminal: tuple[bool, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TeamTrace:
|
||||
required: bool
|
||||
method: str
|
||||
llm_route: bool
|
||||
passthrough: bool
|
||||
telemetry: bool
|
||||
headers_only: bool
|
||||
header: str | None
|
||||
body_trace: str | None
|
||||
body_trace_present: bool
|
||||
traceparent_id: str | None
|
||||
selected_trace: str | None
|
||||
selected_trace_present: bool
|
||||
other_trace: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Tools:
|
||||
requested: tuple[str, ...]
|
||||
key_allowed: tuple[str, ...] | None
|
||||
team_allowed: tuple[str, ...] | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GrantLayers:
|
||||
deny_by_default: bool
|
||||
identity_present: bool
|
||||
master_key: bool
|
||||
dashboard: bool
|
||||
virtual_key: bool
|
||||
session_token: bool
|
||||
team_present: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VectorStores:
|
||||
requested: tuple[str, ...]
|
||||
allowed: tuple[str, ...] | None
|
||||
strict: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TeamLookup:
|
||||
dashboard: bool
|
||||
missing: bool
|
||||
recorded_models: tuple[str, ...]
|
||||
allow_unavailable: bool | None
|
||||
|
||||
|
||||
AccessFacts: TypeAlias = (
|
||||
ConcurrentFailures
|
||||
| UserParamRequest
|
||||
| ClientTagRequest
|
||||
| GuardrailRequest
|
||||
| TraceRequest
|
||||
| Search
|
||||
| Blocked
|
||||
| TraceGate
|
||||
| TraceStep
|
||||
| UserParam
|
||||
| ClientTags
|
||||
| Guardrails
|
||||
| AgentTrace
|
||||
| TeamTrace
|
||||
| Tools
|
||||
| GrantLayers
|
||||
| VectorStores
|
||||
| TeamLookup
|
||||
)
|
||||
AccessDenial: TypeAlias = Literal[
|
||||
"Search",
|
||||
"Blocked",
|
||||
"UserParam",
|
||||
"ClientTags",
|
||||
"Guardrails",
|
||||
"AgentTrace",
|
||||
"TeamTrace",
|
||||
"Tools",
|
||||
"VectorStores",
|
||||
"MissingVectorGrant",
|
||||
"TeamLookup",
|
||||
]
|
||||
GrantLayer: TypeAlias = Literal["key", "team", "user"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccessDecision:
|
||||
read: Literal["UserParam", "ClientTagRoute", "ClientTags", "GuardrailPermission", "UnavailablePolicy"] | None = None
|
||||
denial: AccessDenial | None = None
|
||||
rejected: tuple[str, ...] = ()
|
||||
layers: tuple[GrantLayer, ...] = ()
|
||||
rejected_index: int | None = None
|
||||
next_trace: TraceSource | None = None
|
||||
failure_index: int | None = None
|
||||
|
||||
|
||||
_DECISION: Final = TypeAdapter(AccessDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def decide(facts: AccessFacts) -> AccessDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_access(encode_facts(facts)))
|
||||
77
litellm/_auth/admission.py
Normal file
77
litellm/_auth/admission.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm._auth.rust_core import encode_facts
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CommonGate:
|
||||
force_virtual_key_checks: bool
|
||||
public_route: bool
|
||||
additional_public_route: bool
|
||||
unauthenticated_passthrough: bool
|
||||
master_key_present: bool
|
||||
jwt_enabled: bool
|
||||
oauth_enabled: bool
|
||||
oauth_proxy_enabled: bool
|
||||
custom_auth: bool
|
||||
custom_auth_common_checks: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Reservation:
|
||||
skip_budget_checks: bool
|
||||
disabled: bool
|
||||
apply_user_budget_to_team_keys: bool
|
||||
fail_closed: bool
|
||||
billing_agent: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Ownership:
|
||||
virtual_key: bool
|
||||
custom_auth: bool
|
||||
jwt: bool
|
||||
oauth: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CommonPlan:
|
||||
skip: Literal["PublicRoute", "UnauthenticatedPassthrough", "NoAuthDevelopment", "CustomAuth"] | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReservationPlan:
|
||||
reserve: bool
|
||||
apply_user_budget_to_team_keys: bool
|
||||
fail_closed: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnershipPlan:
|
||||
owner: Literal["Python"]
|
||||
candidate: Literal["VirtualKey", "CustomAuth", "Validator", "Other"]
|
||||
|
||||
|
||||
_COMMON: Final = TypeAdapter(CommonPlan)
|
||||
_RESERVATION: Final = TypeAdapter(ReservationPlan)
|
||||
_OWNERSHIP: Final = TypeAdapter(OwnershipPlan)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def common_plan(facts: CommonGate) -> CommonPlan:
|
||||
return _COMMON.validate_python(_native.request_auth_admission(encode_facts(facts)))
|
||||
|
||||
|
||||
@native_boundary
|
||||
def reservation_plan(facts: Reservation) -> ReservationPlan:
|
||||
return _RESERVATION.validate_python(_native.request_auth_admission(encode_facts(facts)))
|
||||
|
||||
|
||||
@native_boundary
|
||||
def ownership_plan(facts: Ownership) -> OwnershipPlan:
|
||||
return _OWNERSHIP.validate_python(_native.request_auth_admission(encode_facts(facts)))
|
||||
68
litellm/_auth/agent_access.py
Normal file
68
litellm/_auth/agent_access.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Final, Literal, TypeAlias
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm._auth.managed import Agent
|
||||
from litellm._auth.rust_core import encode_facts
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentAccessFacts:
|
||||
registry_managed: bool
|
||||
database_present: bool
|
||||
target_loaded: bool = False
|
||||
target: Agent | None = None
|
||||
subject_present: bool = False
|
||||
key_hash_present: bool = False
|
||||
subject_managed: bool = False
|
||||
session: bool = False
|
||||
custom: bool = False
|
||||
authority_loaded: bool = False
|
||||
explicit_grants: tuple[str, ...] | None = None
|
||||
legacy_loaded: bool = False
|
||||
legacy_grants: tuple[str, ...] | None = None
|
||||
target_ids: tuple[str, ...] | None = None
|
||||
|
||||
|
||||
AgentAccessDecision: TypeAlias = Literal[
|
||||
"ReadTarget", "ReadAuthority", "ReadExplicitGrants", "ReadLegacyGrants", "ReadTargetIds", "Allow", "Deny"
|
||||
]
|
||||
_DECISION: Final[TypeAdapter[AgentAccessDecision]] = TypeAdapter(AgentAccessDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def decide(facts: AgentAccessFacts) -> AgentAccessDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_agent_access(encode_facts(facts)))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InvocationFacts:
|
||||
registered: Agent | None
|
||||
has_store: bool
|
||||
target_loaded: bool = False
|
||||
target: Agent | None = None
|
||||
actor_managed: bool = False
|
||||
actor_id_present: bool = False
|
||||
permissions: AgentAccessFacts | None = None
|
||||
assigned: bool = False
|
||||
fee_loaded: bool = False
|
||||
fee: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InvocationPlan:
|
||||
action: Literal["Complete", "ReadTarget", "ReadPermissions", "Assign", "ReadFee", "Deny"]
|
||||
denial: Literal["DatabaseRequired", "Removed", "CallerNotPermitted", "InvalidFee"] | None
|
||||
use_target: bool
|
||||
assign_billing: bool
|
||||
|
||||
|
||||
_INVOCATION: Final = TypeAdapter(InvocationPlan)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def invocation(facts: InvocationFacts) -> InvocationPlan:
|
||||
return _INVOCATION.validate_python(_native.request_auth_agent_invocation(encode_facts(facts)))
|
||||
60
litellm/_auth/agent_ceilings.py
Normal file
60
litellm/_auth/agent_ceilings.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
from dataclasses import asdict, dataclass
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentCeilingGroup:
|
||||
status: Literal["Found", "Missing", "Unavailable"]
|
||||
models: tuple[str, ...] = ()
|
||||
mcp_server_ids: tuple[str, ...] = ()
|
||||
agent_ids: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentCeilingFacts:
|
||||
ids: tuple[str, ...] | None = None
|
||||
groups: tuple[AgentCeilingGroup, ...] | None = None
|
||||
check_db_only: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentCeilingDecision:
|
||||
kind: Literal["ReadIds", "ReadGroups", "Complete"]
|
||||
authoritative: bool = False
|
||||
present: bool = False
|
||||
ids: tuple[str, ...] = ()
|
||||
models: frozenset[str] = frozenset()
|
||||
mcp_server_ids: frozenset[str] = frozenset()
|
||||
agent_ids: frozenset[str] = frozenset()
|
||||
indices: tuple[int, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentGroupLoadFacts:
|
||||
database_present: bool
|
||||
check_db_only: bool = False
|
||||
status: Literal["Success", "NotFound", "HttpError"] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentGroupLoadDecision:
|
||||
kind: Literal["Read", "Return", "DatabaseUnavailable", "HttpUnavailable", "Raise"]
|
||||
|
||||
|
||||
_DECISION: Final = TypeAdapter(AgentCeilingDecision)
|
||||
_LOAD: Final = TypeAdapter(AgentGroupLoadDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def agent_ceiling(facts: AgentCeilingFacts) -> AgentCeilingDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_agent_ceiling(asdict(facts)))
|
||||
|
||||
|
||||
@native_boundary
|
||||
def agent_group_load(facts: AgentGroupLoadFacts) -> AgentGroupLoadDecision:
|
||||
return _LOAD.validate_python(_native.request_auth_agent_group_load(asdict(facts)))
|
||||
128
litellm/_auth/agent_grants.py
Normal file
128
litellm/_auth/agent_grants.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Final, Literal, TypeAlias
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm._auth.rust_core import encode_facts
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Source:
|
||||
team_id: str | None
|
||||
grants: tuple[str, ...] | None
|
||||
unavailable: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Resolve:
|
||||
managed: bool
|
||||
strict: bool
|
||||
role: str | None
|
||||
key_team_loaded: bool = False
|
||||
key_team: tuple[str, ...] | None = None
|
||||
default_deny: bool | None = None
|
||||
caller_loaded: bool = False
|
||||
own: tuple[str, ...] | None = None
|
||||
ceiling_loaded: bool = False
|
||||
ceiling: tuple[str, ...] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Failure:
|
||||
strict: bool
|
||||
closed: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnerTeam:
|
||||
strict: bool
|
||||
permission_id_present: bool
|
||||
permission_present: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnerUnion:
|
||||
teams: tuple[Source, ...]
|
||||
withheld: tuple[Source, ...]
|
||||
unreadable: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BillingTeam:
|
||||
sources: tuple[Source, ...]
|
||||
agent_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Human:
|
||||
sources: tuple[Source, ...]
|
||||
team_id: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Delegated:
|
||||
sources: tuple[Source, ...]
|
||||
agent_id: str
|
||||
team_id: str | None
|
||||
explicit: bool
|
||||
allowed: tuple[str, ...] | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TeamLookup:
|
||||
subject: bool
|
||||
team_id: str | None
|
||||
database: bool
|
||||
strict: bool
|
||||
personal: bool | None = None
|
||||
loaded: bool = False
|
||||
row_present: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfiguredAgent:
|
||||
agent_id: str
|
||||
groups: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GroupMatches:
|
||||
agents: tuple[ConfiguredAgent, ...]
|
||||
groups: tuple[str, ...]
|
||||
|
||||
|
||||
Action: TypeAlias = Literal[
|
||||
"Complete",
|
||||
"ReadManaged",
|
||||
"ReadKeyTeam",
|
||||
"ReadDefault",
|
||||
"ReadCaller",
|
||||
"ReadCeiling",
|
||||
"ReadPersonalEligibility",
|
||||
"ReadOwnerTeams",
|
||||
"ReadTeam",
|
||||
"ReadDeclared",
|
||||
"Unavailable",
|
||||
"SelectTeam",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Decision:
|
||||
action: Action
|
||||
grants: tuple[str, ...] | None
|
||||
team_id: str | None
|
||||
|
||||
|
||||
_DECISION: Final = TypeAdapter(Decision)
|
||||
|
||||
Facts: TypeAlias = (
|
||||
Resolve | Failure | OwnerTeam | OwnerUnion | BillingTeam | Human | Delegated | TeamLookup | GroupMatches
|
||||
)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def decide(facts: Facts) -> Decision:
|
||||
return _DECISION.validate_python(_native.request_auth_agent_grants(encode_facts(facts)))
|
||||
74
litellm/_auth/auth_context.py
Normal file
74
litellm/_auth/auth_context.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm._auth.rust_core import encode_facts
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Fetch:
|
||||
team_id: str | None
|
||||
ui_team_id: str
|
||||
user_id: str | None
|
||||
project_id: str | None
|
||||
end_user_id: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Team:
|
||||
team_id: str | None
|
||||
ui_team_id: str
|
||||
failed: bool
|
||||
missing: bool
|
||||
recorded_models: tuple[str, ...]
|
||||
allow_unavailable: bool | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Admin:
|
||||
role: str | None
|
||||
user_id: str | None
|
||||
admin_id: str
|
||||
user_row: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FetchPlan:
|
||||
team: bool
|
||||
user: bool
|
||||
project: bool
|
||||
end_user: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TeamPlan:
|
||||
source: Literal["None", "Token", "Loaded", "ReadUnavailablePolicy", "Failure"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AdminPlan:
|
||||
force_admin: bool
|
||||
preserve_budget: bool
|
||||
|
||||
|
||||
_FETCH: Final = TypeAdapter(FetchPlan)
|
||||
_TEAM: Final = TypeAdapter(TeamPlan)
|
||||
_ADMIN: Final = TypeAdapter(AdminPlan)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def fetch(facts: Fetch) -> FetchPlan:
|
||||
return _FETCH.validate_python(_native.request_auth_context(encode_facts(facts)))
|
||||
|
||||
|
||||
@native_boundary
|
||||
def team(facts: Team) -> TeamPlan:
|
||||
return _TEAM.validate_python(_native.request_auth_context(encode_facts(facts)))
|
||||
|
||||
|
||||
@native_boundary
|
||||
def admin(facts: Admin) -> AdminPlan:
|
||||
return _ADMIN.validate_python(_native.request_auth_context(encode_facts(facts)))
|
||||
69
litellm/_auth/budget_alerts.py
Normal file
69
litellm/_auth/budget_alerts.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Failure:
|
||||
kind: str
|
||||
message: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ThresholdFacts:
|
||||
key: str
|
||||
digit: bool
|
||||
digits: str | None = None
|
||||
ratio: float | None = None
|
||||
integer_failure: Failure | None = None
|
||||
ratio_failure: Failure | None = None
|
||||
text: str | None = None
|
||||
items: tuple[str | None, ...] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigFacts:
|
||||
global_entries: tuple[ThresholdFacts, ...] = ()
|
||||
local_entries: tuple[ThresholdFacts, ...] = ()
|
||||
global_failure: Failure | None = None
|
||||
local_failure: Failure | None = None
|
||||
order: tuple[str, ...] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Facts:
|
||||
member: bool
|
||||
spend: float | None
|
||||
limit: float | None
|
||||
default_fraction: float
|
||||
config: ConfigFacts | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Threshold:
|
||||
key: str
|
||||
recipients: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Decision:
|
||||
read_config: bool = False
|
||||
read_order: bool = False
|
||||
global_keys: tuple[str, ...] = ()
|
||||
local_keys: tuple[str, ...] = ()
|
||||
emit: bool = False
|
||||
thresholds: tuple[Threshold, ...] | None = None
|
||||
failure: Failure | None = None
|
||||
|
||||
|
||||
_FACTS: Final = TypeAdapter(Facts)
|
||||
_DECISION: Final = TypeAdapter(Decision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def plan(facts: Facts) -> Decision:
|
||||
return _DECISION.validate_python(_native.request_auth_budget_alert_plan(_FACTS.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes frozen facts
|
||||
174
litellm/_auth/budget_context.py
Normal file
174
litellm/_auth/budget_context.py
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Final, Literal, TypeAlias
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelCheckFacts:
|
||||
scope: Literal["Team", "EndUser", "CustomEndUser"]
|
||||
entity_id: str | None
|
||||
budget_count: int
|
||||
model_count: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BudgetFields:
|
||||
max_budget: float | None = None
|
||||
tpm_limit: int | None = None
|
||||
rpm_limit: int | None = None
|
||||
tpd_limit: int | None = None
|
||||
model_budget_count: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EndUserFacts:
|
||||
mode: Literal["Models", "Token", "Default"]
|
||||
object_present: bool
|
||||
end_user_id: str | None
|
||||
prisma_present: bool
|
||||
key_budget_id: str | None
|
||||
key_read: bool = False
|
||||
key_budget: BudgetFields | None = None
|
||||
global_config_read: bool = False
|
||||
global_budget_id: str | None = None
|
||||
global_read: bool = False
|
||||
global_budget: BudgetFields | None = None
|
||||
|
||||
|
||||
EndUserSource: TypeAlias = Literal["Own", "ReadKey", "Key", "ReadGlobalConfig", "ReadGlobal", "Global", "None"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TokenDefaultsFacts:
|
||||
current: BudgetFields
|
||||
keep_token_limits: bool
|
||||
default_present: bool
|
||||
|
||||
|
||||
TokenField: TypeAlias = Literal["MaxBudget", "Tpm", "Rpm", "Tpd", "Models"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MembershipSnapshot:
|
||||
budget_id: str | None
|
||||
model_budget_count: int | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MemberFacts:
|
||||
user_id: str | None
|
||||
team_id: str | None
|
||||
prisma_present: bool
|
||||
default_id: str | None
|
||||
skip_budget_checks: bool
|
||||
membership_read: bool = False
|
||||
membership: MembershipSnapshot | None = None
|
||||
default_read: bool = False
|
||||
default_budget: BudgetFields | None = None
|
||||
lookup_failure: Literal["Membership", "Default"] | None = None
|
||||
|
||||
|
||||
MemberSource: TypeAlias = Literal[
|
||||
"Done", "ReadMembership", "ReadDefault", "Member", "Default", "Clear", "RaiseLookup", "SkipLookup"
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IncreaseFacts:
|
||||
number: float | None
|
||||
truthy: bool
|
||||
type_name: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TemporaryFacts:
|
||||
max_budget: float | None
|
||||
metadata_read: bool = False
|
||||
has_increase: bool = False
|
||||
has_expiry: bool = False
|
||||
expires_at: int | None = None
|
||||
now: int | None = None
|
||||
increase: IncreaseFacts | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TemporaryDecision:
|
||||
read: Literal["Metadata", "Expiry", "Now", "Increase"] | None = None
|
||||
copy: bool = False
|
||||
max_budget: float | None = None
|
||||
invalid_increase_type: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NumericFacts:
|
||||
number: float | None
|
||||
boolean: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ThrottleFacts:
|
||||
opt_in: bool
|
||||
tpm_limit: int | None
|
||||
rpm_limit: int | None
|
||||
percentage: NumericFacts | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ThrottleDecision:
|
||||
read_percentage: bool = False
|
||||
percentage: float | None = None
|
||||
|
||||
|
||||
_MODELS: Final = TypeAdapter(ModelCheckFacts)
|
||||
_INDICES: Final = TypeAdapter(tuple[int, ...])
|
||||
_END: Final = TypeAdapter(EndUserFacts)
|
||||
_END_SOURCE: Final = TypeAdapter[EndUserSource](EndUserSource)
|
||||
_TOKEN: Final = TypeAdapter(TokenDefaultsFacts)
|
||||
_FIELDS: Final = TypeAdapter(tuple[TokenField, ...])
|
||||
_MEMBER: Final = TypeAdapter(MemberFacts)
|
||||
_MEMBER_SOURCE: Final = TypeAdapter[MemberSource](MemberSource)
|
||||
_TEMP: Final = TypeAdapter(TemporaryFacts)
|
||||
_TEMP_DECISION: Final = TypeAdapter(TemporaryDecision)
|
||||
_THROTTLE: Final = TypeAdapter(ThrottleFacts)
|
||||
_THROTTLE_DECISION: Final = TypeAdapter(ThrottleDecision)
|
||||
_NUMBER: Final = TypeAdapter(NumericFacts)
|
||||
_PERCENTAGE: Final = TypeAdapter[float | None](float | None)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def model_checks(facts: ModelCheckFacts) -> tuple[int, ...]:
|
||||
return _INDICES.validate_python(_native.request_auth_context_models(_MODELS.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes frozen facts
|
||||
|
||||
|
||||
@native_boundary
|
||||
def end_user_source(facts: EndUserFacts) -> EndUserSource:
|
||||
return _END_SOURCE.validate_python(_native.request_auth_context_end_user(_END.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes frozen facts
|
||||
|
||||
|
||||
@native_boundary
|
||||
def token_assignments(facts: TokenDefaultsFacts) -> tuple[TokenField, ...]:
|
||||
return _FIELDS.validate_python(_native.request_auth_context_token(_TOKEN.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes frozen facts
|
||||
|
||||
|
||||
@native_boundary
|
||||
def member_source(facts: MemberFacts) -> MemberSource:
|
||||
return _MEMBER_SOURCE.validate_python(_native.request_auth_context_member(_MEMBER.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes frozen facts
|
||||
|
||||
|
||||
@native_boundary
|
||||
def temporary(facts: TemporaryFacts) -> TemporaryDecision:
|
||||
return _TEMP_DECISION.validate_python(_native.request_auth_context_temporary(_TEMP.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes frozen facts
|
||||
|
||||
|
||||
@native_boundary
|
||||
def percentage(facts: NumericFacts) -> float | None:
|
||||
return _PERCENTAGE.validate_python(_native.request_auth_context_percentage(_NUMBER.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes frozen facts
|
||||
|
||||
|
||||
@native_boundary
|
||||
def throttle(facts: ThrottleFacts) -> ThrottleDecision:
|
||||
return _THROTTLE_DECISION.validate_python(_native.request_auth_context_throttle(_THROTTLE.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes frozen facts
|
||||
90
litellm/_auth/budget_lookup.py
Normal file
90
litellm/_auth/budget_lookup.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Final, Literal, TypeAlias
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizationFacts:
|
||||
token_present: bool
|
||||
client_present: bool
|
||||
team_present: bool
|
||||
token_id: bool | None = None
|
||||
team_id: bool | None = None
|
||||
row_present: bool | None = None
|
||||
lookup_failed: bool = False
|
||||
|
||||
|
||||
OrganizationAction: TypeAlias = Literal["Skip", "ReadTokenId", "ReadTeamId", "ReadOrganization", "UseOrganization"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MemberFacts:
|
||||
team_present: bool
|
||||
token_present: bool
|
||||
membership_loaded: bool
|
||||
team_id: bool | None = None
|
||||
user_id: bool | None = None
|
||||
limits_read: bool = False
|
||||
maximum_present: bool = False
|
||||
default_id_string: bool = False
|
||||
default_read: bool = False
|
||||
|
||||
|
||||
MemberAction: TypeAlias = Literal[
|
||||
"Skip", "ReadTeamId", "ReadUserId", "ReadMembership", "ReadLimits", "ReadDefault", "UseLimits"
|
||||
]
|
||||
_ORGANIZATION: Final = TypeAdapter[OrganizationAction](OrganizationAction)
|
||||
_MEMBER: Final = TypeAdapter[MemberAction](MemberAction)
|
||||
_ORGANIZATION_FACTS: Final = TypeAdapter(OrganizationFacts)
|
||||
_MEMBER_FACTS: Final = TypeAdapter(MemberFacts)
|
||||
_ITEMS: Final = TypeAdapter(tuple[object, ...])
|
||||
_EMAILS: Final = TypeAdapter(tuple[str, ...])
|
||||
_METADATA: Final = TypeAdapter(Mapping[object, object])
|
||||
|
||||
|
||||
def soft_email_value(raw: object) -> object:
|
||||
return _METADATA.validate_python(raw).get("soft_budget_alerting_emails") if isinstance(raw, dict) else None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SoftFacts:
|
||||
limit: float | None
|
||||
spend: float | None
|
||||
token_present: bool
|
||||
|
||||
|
||||
_SOFT: Final = TypeAdapter(SoftFacts)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def read_soft_emails(facts: SoftFacts) -> bool:
|
||||
return _native.request_auth_budget_lookup_soft(_SOFT.dump_python(facts)) # pyright: ignore[reportAny] # Pydantic serializes frozen facts
|
||||
|
||||
|
||||
@native_boundary
|
||||
def organization(facts: OrganizationFacts) -> OrganizationAction:
|
||||
return _ORGANIZATION.validate_python(
|
||||
_native.request_auth_budget_lookup_organization(_ORGANIZATION_FACTS.dump_python(facts)) # pyright: ignore[reportAny] # Pydantic serializes frozen facts
|
||||
)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def member(facts: MemberFacts) -> MemberAction:
|
||||
return _MEMBER.validate_python(_native.request_auth_budget_lookup_member(_MEMBER_FACTS.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes frozen facts
|
||||
|
||||
|
||||
@native_boundary
|
||||
def emails(raw: object) -> tuple[str, ...]:
|
||||
facts: Final = (
|
||||
{"Text": raw}
|
||||
if isinstance(raw, str)
|
||||
else {"List": tuple(value if isinstance(value, str) else None for value in _ITEMS.validate_python(raw))}
|
||||
if isinstance(raw, list)
|
||||
else "Other"
|
||||
)
|
||||
return _EMAILS.validate_python(_native.request_auth_budget_lookup_emails(facts))
|
||||
90
litellm/_auth/budget_pricing.py
Normal file
90
litellm/_auth/budget_pricing.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Final, Literal, TypeAlias
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeploymentPrice:
|
||||
has_input_price: bool = False
|
||||
has_output_price: bool = False
|
||||
input_price: float | None = None
|
||||
output_price: float | None = None
|
||||
query_price: float | None = None
|
||||
ptu_count: float | None = None
|
||||
cost_per_ptu_per_hour: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelPriceFacts:
|
||||
cached: bool | None = None
|
||||
lookup_failed: bool = False
|
||||
group_found: bool = False
|
||||
input_price: float | None = None
|
||||
output_price: float | None = None
|
||||
deployments: tuple[DeploymentPrice, ...] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PriceDecision:
|
||||
zero_cost: bool
|
||||
cache_value: bool | None
|
||||
read_deployments: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RouteBudgetFacts:
|
||||
side_effect_route: bool = False
|
||||
discovery_route: bool = False
|
||||
llm_route: bool = False
|
||||
registered_pass_through: bool | None = None
|
||||
route: str = ""
|
||||
method: str | None = None
|
||||
body_present: bool = False
|
||||
|
||||
|
||||
RouteBudgetDecision: TypeAlias = Literal["ReadPassThrough", "ReadPricing", "Skip"]
|
||||
_PRICE: Final = TypeAdapter(ModelPriceFacts)
|
||||
_PRICE_DECISION: Final = TypeAdapter(PriceDecision)
|
||||
_ROUTE: Final = TypeAdapter(RouteBudgetFacts)
|
||||
_ROUTE_DECISION: Final = TypeAdapter[RouteBudgetDecision](RouteBudgetDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def price(facts: ModelPriceFacts) -> PriceDecision:
|
||||
return _PRICE_DECISION.validate_python(_native.request_auth_budget_price(_PRICE.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes the validated frozen facts for the native boundary
|
||||
|
||||
|
||||
@native_boundary
|
||||
def route(facts: RouteBudgetFacts) -> RouteBudgetDecision:
|
||||
return _ROUTE_DECISION.validate_python(_native.request_auth_budget_route(_ROUTE.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes the validated frozen facts for the native boundary
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExemptionFacts:
|
||||
route: RouteBudgetFacts
|
||||
model_input_present: bool
|
||||
router_present: bool
|
||||
model_count: int
|
||||
prices: tuple[ModelPriceFacts, ...] = ()
|
||||
cache_read: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExemptionDecision:
|
||||
read_pass_through: bool
|
||||
read_price_cache: bool
|
||||
read_model: int | None
|
||||
exempt: bool
|
||||
|
||||
|
||||
_EXEMPTION: Final = TypeAdapter(ExemptionFacts)
|
||||
_EXEMPTION_DECISION: Final = TypeAdapter(ExemptionDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def exemption(facts: ExemptionFacts) -> ExemptionDecision:
|
||||
return _EXEMPTION_DECISION.validate_python(_native.request_auth_budget_exemption(_EXEMPTION.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes frozen facts
|
||||
115
litellm/_auth/common_budgets.py
Normal file
115
litellm/_auth/common_budgets.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Final, Literal, TypeAlias
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
BudgetScope: TypeAlias = Literal[
|
||||
"Team",
|
||||
"Key",
|
||||
"TeamWindow",
|
||||
"KeyWindow",
|
||||
"Project",
|
||||
"Organization",
|
||||
"Tag",
|
||||
"AccessGroup",
|
||||
"User",
|
||||
"TeamMember",
|
||||
"EndUser",
|
||||
"Proxy",
|
||||
"KeySoft",
|
||||
"TeamSoft",
|
||||
"ProjectSoft",
|
||||
]
|
||||
BudgetAlert: TypeAlias = Literal[
|
||||
"token_budget",
|
||||
"user_budget",
|
||||
"team_budget",
|
||||
"project_budget",
|
||||
"organization_budget",
|
||||
"team_member_threshold",
|
||||
"soft_budget",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BudgetFacts:
|
||||
scope: BudgetScope
|
||||
limit: float | None
|
||||
spend: float | None = None
|
||||
token_present: bool = False
|
||||
team_key: bool = False
|
||||
apply_user_budget_to_team_keys: bool = False
|
||||
info_route: bool = False
|
||||
llm_route: bool = True
|
||||
model_list_route: bool = False
|
||||
skip: bool = False
|
||||
alert_recipients: tuple[str, ...] = ()
|
||||
throttle_percentage: float | None = None
|
||||
throttle_opt_in: bool = False
|
||||
tpm_limit: int | None = None
|
||||
rpm_limit: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BudgetDecision:
|
||||
read_spend: bool = False
|
||||
denied: bool = False
|
||||
alert: BudgetAlert | None = None
|
||||
throttle_percentage: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AlertFacts:
|
||||
spend: float | None
|
||||
max_budget: float | None
|
||||
configured: bool = False
|
||||
minimum_percent: float | None = None
|
||||
default_ratio: float = 0.8
|
||||
team_member: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MemberLimitFacts:
|
||||
member_limit: float | None
|
||||
default_limit: float | None
|
||||
temporary_increase: float | None
|
||||
expires_at: float | None
|
||||
now: float
|
||||
now_naive: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WindowFacts:
|
||||
duration_seconds: float | None
|
||||
reset_at: float | None
|
||||
now: float
|
||||
|
||||
|
||||
_FACTS: Final = TypeAdapter(BudgetFacts)
|
||||
_DECISION: Final = TypeAdapter(BudgetDecision)
|
||||
_ALERT: Final = TypeAdapter(AlertFacts)
|
||||
_MEMBER: Final = TypeAdapter(MemberLimitFacts)
|
||||
_WINDOW: Final = TypeAdapter(WindowFacts)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def check(facts: BudgetFacts) -> BudgetDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_common_budget(_FACTS.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes the validated frozen facts for the native boundary
|
||||
|
||||
|
||||
@native_boundary
|
||||
def alert(facts: AlertFacts) -> bool:
|
||||
return _native.request_auth_budget_alert(_ALERT.dump_python(facts)) # pyright: ignore[reportAny] # Pydantic serializes the validated frozen facts for the native boundary
|
||||
|
||||
|
||||
@native_boundary
|
||||
def member_limit(facts: MemberLimitFacts) -> float | None:
|
||||
return _native.request_auth_member_limit(_MEMBER.dump_python(facts)) # pyright: ignore[reportAny] # Pydantic serializes the validated frozen facts for the native boundary
|
||||
|
||||
|
||||
@native_boundary
|
||||
def window_start(facts: WindowFacts) -> float | None:
|
||||
return _native.request_auth_budget_window_start(_WINDOW.dump_python(facts)) # pyright: ignore[reportAny] # Pydantic serializes the validated frozen facts for the native boundary
|
||||
148
litellm/_auth/common_flow.py
Normal file
148
litellm/_auth/common_flow.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
from dataclasses import asdict, dataclass
|
||||
from typing import Final, Literal, TypeAlias
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm._auth.rust_core import encode_facts
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BudgetSkip:
|
||||
skip: bool
|
||||
exempt: bool | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Pricing:
|
||||
enabled: bool
|
||||
llm_route: bool | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Unpriced:
|
||||
count: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MemberFallback:
|
||||
router: bool
|
||||
multipart: bool | None = None
|
||||
resource_bound: bool | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelBudgets:
|
||||
scope: Literal["Key", "User", "Member"]
|
||||
entity_present: bool | None = None
|
||||
budget_present: bool | None = None
|
||||
mapping: bool | None = None
|
||||
team_present: bool | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FallbackAllowed:
|
||||
router: bool
|
||||
disabled: bool | None = None
|
||||
|
||||
|
||||
ReadPlan: TypeAlias = Literal[
|
||||
"ReadExemption",
|
||||
"SkipBudgets",
|
||||
"CheckBudgets",
|
||||
"ReadRoute",
|
||||
"ReadUnpriced",
|
||||
"RejectUnpriced",
|
||||
"ReadContentType",
|
||||
"ReadResourceBinding",
|
||||
"ReadFallbackSettings",
|
||||
"CheckModelBudgets",
|
||||
"ReadModelBudgetPresence",
|
||||
"ReadModelBudgetShape",
|
||||
"ReadModelBudgetToken",
|
||||
"ReadModelBudgetUser",
|
||||
"ReadModelBudgetTeam",
|
||||
"AllowFallback",
|
||||
"ReadFallbackDisabled",
|
||||
"Continue",
|
||||
]
|
||||
_READ: Final = TypeAdapter(ReadPlan)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def read(facts: BudgetSkip | Pricing | Unpriced | MemberFallback | ModelBudgets | FallbackAllowed) -> ReadPlan:
|
||||
return _READ.validate_python(_native.request_auth_common_read(encode_facts(facts)))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Facts:
|
||||
model_present: bool
|
||||
skip_budgets: bool
|
||||
team_present: bool
|
||||
user_present: bool
|
||||
token_present: bool
|
||||
user_id: str | None
|
||||
agent_id: str | None
|
||||
customer_present: bool
|
||||
customer_models: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Plan:
|
||||
membership: bool
|
||||
team_models: bool
|
||||
member_models: bool
|
||||
user_models: bool
|
||||
customer_models: bool
|
||||
agent_trace: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BudgetFacts:
|
||||
token_present: bool
|
||||
group_count: int
|
||||
customer_present: bool
|
||||
customer_budget_present: bool
|
||||
|
||||
|
||||
BudgetScope: TypeAlias = Literal[
|
||||
"Team", "TeamWindows", "KeyWindows", "TeamSoft", "Organization", "Tags", "Groups", "User", "Member", "Customer"
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CustomFacts:
|
||||
end_user_id: str | None
|
||||
end_user_present: bool
|
||||
common_checks: bool
|
||||
model_present: bool = False
|
||||
router_present: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CustomPlan:
|
||||
read_end_user: bool
|
||||
check_end_user: bool
|
||||
check_model_access: bool
|
||||
read_pricing: bool
|
||||
|
||||
|
||||
_PLAN: Final = TypeAdapter(Plan)
|
||||
_BUDGETS: Final = TypeAdapter(tuple[BudgetScope, ...])
|
||||
_CUSTOM: Final = TypeAdapter(CustomPlan)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def plan(facts: Facts) -> Plan:
|
||||
return _PLAN.validate_python(_native.request_auth_common_flow(asdict(facts)))
|
||||
|
||||
|
||||
@native_boundary
|
||||
def budgets(facts: BudgetFacts) -> tuple[BudgetScope, ...]:
|
||||
return _BUDGETS.validate_python(_native.request_auth_common_budget_scopes(asdict(facts)))
|
||||
|
||||
|
||||
@native_boundary
|
||||
def custom(facts: CustomFacts) -> CustomPlan:
|
||||
return _CUSTOM.validate_python(_native.request_auth_custom_flow(asdict(facts)))
|
||||
|
|
@ -3,6 +3,13 @@ from dataclasses import dataclass
|
|||
from datetime import datetime
|
||||
from typing import Protocol, TypeAlias
|
||||
|
||||
from litellm._auth.budget_pricing import ExemptionFacts
|
||||
from litellm._auth.fallback_access import FallbackAuthorizationFacts
|
||||
from litellm._auth.fallback_candidate import FallbackCandidateFacts
|
||||
from litellm._auth.key_model_access import KeyAndFallbackModelAccessFacts
|
||||
from litellm._auth.model_access import ModelAccessDecision
|
||||
from litellm._auth.model_budget_data import ModelBudgetSnapshot
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RequestFacts:
|
||||
|
|
@ -128,8 +135,22 @@ class ModelBudgetExceeded:
|
|||
entity_id: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelBudgetRead:
|
||||
snapshot: ModelBudgetSnapshot
|
||||
message: str = ""
|
||||
entity_type: str | None = None
|
||||
entity_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelAccessDenied:
|
||||
decision: ModelAccessDecision
|
||||
|
||||
|
||||
Denial: TypeAlias = (
|
||||
MissingKey
|
||||
ModelAccessDenied
|
||||
| MissingKey
|
||||
| MalformedKey
|
||||
| UserAuthDisabled
|
||||
| ExpiredKey
|
||||
|
|
@ -257,6 +278,11 @@ class InspectKey:
|
|||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EnforceModelAccess:
|
||||
resume: "Callable[[KeyAndFallbackModelAccessFacts], Step]"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PrefetchAuthObjects:
|
||||
resume: "Callable[[], Step]"
|
||||
|
||||
|
||||
|
|
@ -268,7 +294,7 @@ class LoadKeyOwner:
|
|||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CheckBudgetExemption:
|
||||
resume: "Callable[[bool], Step]"
|
||||
resume: "Callable[[ExemptionFacts], Step]"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -332,13 +358,13 @@ class ReadModelBudgetScope:
|
|||
@dataclass(frozen=True, slots=True)
|
||||
class CheckKeyModelBudget:
|
||||
model: str
|
||||
resume: "Callable[[ModelBudgetExceeded | None], Step]"
|
||||
resume: "Callable[[ModelBudgetRead], Step]"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthorizeBudgetFallback:
|
||||
model: str
|
||||
resume: "Callable[[bool], Step]"
|
||||
resume: "Callable[[FallbackAuthorizationFacts], Step]"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -350,7 +376,7 @@ class RewriteRequestModel:
|
|||
@dataclass(frozen=True, slots=True)
|
||||
class CheckUserModelBudget:
|
||||
model: str
|
||||
resume: "Callable[[ModelBudgetExceeded | None], Step]"
|
||||
resume: "Callable[[ModelBudgetRead], Step]"
|
||||
|
||||
|
||||
IdentityEffect: TypeAlias = (
|
||||
|
|
@ -361,6 +387,7 @@ IdentityEffect: TypeAlias = (
|
|||
| PrepareKey
|
||||
| InspectKey
|
||||
| EnforceModelAccess
|
||||
| PrefetchAuthObjects
|
||||
| LoadKeyOwner
|
||||
| CheckBudgetExemption
|
||||
| LoadKeyContext
|
||||
|
|
@ -390,7 +417,7 @@ class ReadTeamMemberModelBudgetScope:
|
|||
@dataclass(frozen=True, slots=True)
|
||||
class CheckTeamMemberModelBudget:
|
||||
model: str
|
||||
resume: "Callable[[ModelBudgetExceeded | None], TeamMemberStep]"
|
||||
resume: "Callable[[ModelBudgetRead], TeamMemberStep]"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -402,7 +429,7 @@ class ReadTeamMemberFallbacks:
|
|||
@dataclass(frozen=True, slots=True)
|
||||
class VetTeamMemberFallback:
|
||||
target: FallbackTarget
|
||||
resume: "Callable[[bool], TeamMemberStep]"
|
||||
resume: "Callable[[FallbackCandidateFacts], TeamMemberStep]"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from typing import Protocol, assert_never
|
||||
|
||||
from litellm._auth.budget_pricing import ExemptionFacts
|
||||
from litellm._auth.decision import (
|
||||
AlertKeyBudget,
|
||||
AlertKeySoftBudget,
|
||||
|
|
@ -24,10 +25,11 @@ from litellm._auth.decision import (
|
|||
LoadKeyContext,
|
||||
LoadKeyOwner,
|
||||
ModelBudgetEffect,
|
||||
ModelBudgetExceeded,
|
||||
ModelBudgetRead,
|
||||
ModelBudgetScope,
|
||||
Outcome,
|
||||
OwnerFacts,
|
||||
PrefetchAuthObjects,
|
||||
PrepareKey,
|
||||
PresentedKey,
|
||||
ReadKeySpend,
|
||||
|
|
@ -48,28 +50,31 @@ from litellm._auth.decision import (
|
|||
TeamMemberStep,
|
||||
VetTeamMemberFallback,
|
||||
)
|
||||
from litellm._auth.fallback_access import FallbackAuthorizationFacts
|
||||
from litellm._auth.fallback_candidate import FallbackCandidateFacts
|
||||
from litellm._auth.key_model_access import KeyAndFallbackModelAccessFacts
|
||||
|
||||
|
||||
class ModelBudgetHandler(Protocol):
|
||||
async def read_model_budget_scope(self) -> ModelBudgetScope: ...
|
||||
|
||||
async def check_key_model_budget(self, model: str) -> ModelBudgetExceeded | None: ...
|
||||
async def check_key_model_budget(self, model: str) -> ModelBudgetRead: ...
|
||||
|
||||
async def authorize_budget_fallback(self, model: str) -> bool: ...
|
||||
async def authorize_budget_fallback(self, model: str) -> FallbackAuthorizationFacts: ...
|
||||
|
||||
async def rewrite_request_model(self, model: str) -> None: ...
|
||||
|
||||
async def check_user_model_budget(self, model: str) -> ModelBudgetExceeded | None: ...
|
||||
async def check_user_model_budget(self, model: str) -> ModelBudgetRead: ...
|
||||
|
||||
|
||||
class TeamMemberModelBudgetHandler(Protocol):
|
||||
async def read_team_member_model_budget_scope(self) -> TeamMemberModelBudgetScope: ...
|
||||
|
||||
async def check_team_member_model_budget(self, model: str) -> ModelBudgetExceeded | None: ...
|
||||
async def check_team_member_model_budget(self, model: str) -> ModelBudgetRead: ...
|
||||
|
||||
async def read_team_member_fallbacks(self, model: str) -> tuple[FallbackTarget, ...]: ...
|
||||
|
||||
async def vet_team_member_fallback(self, target: FallbackTarget) -> bool: ...
|
||||
async def vet_team_member_fallback(self, target: FallbackTarget) -> FallbackCandidateFacts: ...
|
||||
|
||||
async def reroute_team_member_request(self, model: str) -> None: ...
|
||||
|
||||
|
|
@ -87,11 +92,13 @@ class EffectHandler(ModelBudgetHandler, Protocol):
|
|||
|
||||
async def inspect_key(self) -> KeyFacts: ...
|
||||
|
||||
async def enforce_model_access(self) -> None: ...
|
||||
async def enforce_model_access(self) -> KeyAndFallbackModelAccessFacts: ...
|
||||
|
||||
async def prefetch_auth_objects(self) -> None: ...
|
||||
|
||||
async def load_key_owner(self, user_id: str) -> OwnerFacts | None: ...
|
||||
|
||||
async def check_budget_exemption(self) -> bool: ...
|
||||
async def check_budget_exemption(self) -> ExemptionFacts: ...
|
||||
|
||||
async def read_team_member_budget(self, user_id: str, team_id: str) -> TeamMemberBudget | None: ...
|
||||
|
||||
|
|
@ -122,6 +129,7 @@ async def _answer(step: Effect, handler: EffectHandler) -> Step:
|
|||
| PrepareKey()
|
||||
| InspectKey()
|
||||
| EnforceModelAccess()
|
||||
| PrefetchAuthObjects()
|
||||
| LoadKeyOwner()
|
||||
| CheckBudgetExemption()
|
||||
| LoadKeyContext()
|
||||
|
|
@ -158,7 +166,9 @@ async def _answer_identity(step: IdentityEffect, handler: EffectHandler) -> Step
|
|||
case InspectKey(resume=resume):
|
||||
return resume(await handler.inspect_key())
|
||||
case EnforceModelAccess(resume=resume):
|
||||
await handler.enforce_model_access()
|
||||
return resume(await handler.enforce_model_access())
|
||||
case PrefetchAuthObjects(resume=resume):
|
||||
await handler.prefetch_auth_objects()
|
||||
return resume()
|
||||
case LoadKeyOwner(user_id=user_id, resume=resume):
|
||||
return resume(await handler.load_key_owner(user_id))
|
||||
|
|
|
|||
171
litellm/_auth/end_user_context.py
Normal file
171
litellm/_auth/end_user_context.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Final, Literal, TypeAlias
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IdFacts:
|
||||
value: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DefaultFacts:
|
||||
prisma_present: bool
|
||||
explicit_id: str | None
|
||||
global_id: str | None
|
||||
global_read: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DefaultDecision:
|
||||
read_global: bool
|
||||
budget_id: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplyFacts:
|
||||
own_budget_id: str | None
|
||||
own_budget_present: bool
|
||||
key_id: str | None
|
||||
global_read: bool = False
|
||||
global_id: str | None = None
|
||||
default_read: bool = False
|
||||
default_present: bool = False
|
||||
|
||||
|
||||
ApplyDecision: TypeAlias = Literal["Keep", "ReadGlobal", "ReadDefault", "Copy"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RegistryFacts:
|
||||
end_user_id: str
|
||||
global_id: str | None
|
||||
key_id: str | None
|
||||
validate_id: bool
|
||||
token_max_present: bool
|
||||
registry_read: bool = False
|
||||
registry: tuple[str, ...] | None = None
|
||||
|
||||
|
||||
RegistryDecision: TypeAlias = Literal["Read", "Restricted", "Unrestricted"]
|
||||
Field: TypeAlias = Literal["EndUserId", "Tpm", "Rpm", "Tpd", "MaxBudget", "Models", "Region"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Fields:
|
||||
tpm: bool = False
|
||||
rpm: bool = False
|
||||
tpd: bool = False
|
||||
max_budget: bool = False
|
||||
models: bool = False
|
||||
region: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AssignmentFacts:
|
||||
mode: Literal["Params", "Token"]
|
||||
fields: Fields
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LookupFacts:
|
||||
key_id: str | None
|
||||
own_budget_present: bool = False
|
||||
end_user_read: bool = False
|
||||
end_user_present: bool = False
|
||||
global_read: bool = False
|
||||
global_id: str | None = None
|
||||
default_read: bool = False
|
||||
default_present: bool = False
|
||||
token_max_present: bool = False
|
||||
applied: bool = False
|
||||
read_failed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LookupDecision:
|
||||
action: Literal["ReadEndUser", "ReadGlobal", "ReadDefault", "ApplyOwn", "ApplyDefault", "Done"]
|
||||
set_max_budget: bool
|
||||
project_budget: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ValidationFacts:
|
||||
mode: Literal["Resolve", "Exists"]
|
||||
end_user_id: str | None
|
||||
enabled: bool = False
|
||||
prisma_present: bool = False
|
||||
global_id: str | None = None
|
||||
key_id: str | None = None
|
||||
cache_read: bool = False
|
||||
cached: str | None = None
|
||||
end_user_read: bool = False
|
||||
end_user_present: bool = False
|
||||
user_read: bool = False
|
||||
user_present: bool = False
|
||||
cache_written: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ValidationDecision:
|
||||
read: Literal["Cache", "EndUser", "User", "WriteCache"] | None
|
||||
end_user_id: str | None
|
||||
valid: bool
|
||||
cache_value: str | None
|
||||
cache_ttl: int
|
||||
user_email: str | None
|
||||
|
||||
|
||||
_ID: Final = TypeAdapter(IdFacts)
|
||||
_STRING: Final = TypeAdapter[str | None](str | None)
|
||||
_DEFAULT: Final = TypeAdapter(DefaultFacts)
|
||||
_DEFAULT_RESULT: Final = TypeAdapter(DefaultDecision)
|
||||
_APPLY: Final = TypeAdapter(ApplyFacts)
|
||||
_APPLY_RESULT: Final = TypeAdapter[ApplyDecision](ApplyDecision)
|
||||
_REGISTRY: Final = TypeAdapter(RegistryFacts)
|
||||
_REGISTRY_RESULT: Final = TypeAdapter[RegistryDecision](RegistryDecision)
|
||||
_ASSIGNMENTS: Final = TypeAdapter(AssignmentFacts)
|
||||
_FIELDS: Final = TypeAdapter(tuple[Field, ...])
|
||||
_LOOKUP: Final = TypeAdapter(LookupFacts)
|
||||
_LOOKUP_RESULT: Final = TypeAdapter(LookupDecision)
|
||||
_VALIDATION: Final = TypeAdapter(ValidationFacts)
|
||||
_VALIDATION_RESULT: Final = TypeAdapter(ValidationDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def normalize_id(facts: IdFacts) -> str | None:
|
||||
return _STRING.validate_python(_native.request_auth_end_user_normalize_id(_ID.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes frozen typed facts
|
||||
|
||||
|
||||
@native_boundary
|
||||
def default_id(facts: DefaultFacts) -> DefaultDecision:
|
||||
return _DEFAULT_RESULT.validate_python(_native.request_auth_end_user_default_id(_DEFAULT.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes frozen typed facts
|
||||
|
||||
|
||||
@native_boundary
|
||||
def apply_default(facts: ApplyFacts) -> ApplyDecision:
|
||||
return _APPLY_RESULT.validate_python(_native.request_auth_end_user_apply(_APPLY.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes frozen typed facts
|
||||
|
||||
|
||||
@native_boundary
|
||||
def registry(facts: RegistryFacts) -> RegistryDecision:
|
||||
return _REGISTRY_RESULT.validate_python(_native.request_auth_end_user_registry(_REGISTRY.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes frozen typed facts
|
||||
|
||||
|
||||
@native_boundary
|
||||
def assignments(facts: AssignmentFacts) -> tuple[Field, ...]:
|
||||
return _FIELDS.validate_python(_native.request_auth_end_user_assignments(_ASSIGNMENTS.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes frozen typed facts
|
||||
|
||||
|
||||
@native_boundary
|
||||
def lookup(facts: LookupFacts) -> LookupDecision:
|
||||
return _LOOKUP_RESULT.validate_python(_native.request_auth_end_user_lookup(_LOOKUP.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes frozen typed facts
|
||||
|
||||
|
||||
@native_boundary
|
||||
def validation(facts: ValidationFacts) -> ValidationDecision:
|
||||
return _VALIDATION_RESULT.validate_python(_native.request_auth_end_user_validation(_VALIDATION.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes frozen typed facts
|
||||
39
litellm/_auth/failure.py
Normal file
39
litellm/_auth/failure.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import ParamSpec, TypeVar
|
||||
|
||||
_P = ParamSpec("_P") # rebind-ok: ParamSpec declarations cannot be annotated Final
|
||||
_R = TypeVar("_R") # rebind-ok: TypeVar declarations cannot be annotated Final
|
||||
|
||||
|
||||
class NativeAuthFailure(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NativeAuthTypeError(NativeAuthFailure, TypeError):
|
||||
pass
|
||||
|
||||
|
||||
class NativeAuthValueError(NativeAuthFailure, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class NativeAuthRuntimeError(NativeAuthFailure, RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def native_boundary(function: Callable[_P, _R]) -> Callable[_P, _R]:
|
||||
@wraps(function)
|
||||
def call(*args: _P.args, **kwargs: _P.kwargs) -> _R: # kwargs-ok: ParamSpec preserves the wrapped signature
|
||||
try:
|
||||
return function(*args, **kwargs)
|
||||
except NativeAuthFailure:
|
||||
raise
|
||||
except TypeError as error:
|
||||
raise NativeAuthTypeError(str(error)) from error
|
||||
except ValueError as error:
|
||||
raise NativeAuthValueError(str(error)) from error
|
||||
except Exception as error: # noqa: BLE001 # native faults must bypass legacy IO fail-open catches
|
||||
raise NativeAuthRuntimeError(str(error)) from error
|
||||
|
||||
return call
|
||||
58
litellm/_auth/fallback_access.py
Normal file
58
litellm/_auth/fallback_access.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
from dataclasses import asdict, dataclass
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm._auth.model_access import ModelAccessDecision, ModelAccessFacts
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GroupedModelScope:
|
||||
facts: ModelAccessFacts
|
||||
group_ids: tuple[str, ...] = ()
|
||||
group_models: tuple[str, ...] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FallbackAuthorizationFacts:
|
||||
key: GroupedModelScope | None = None
|
||||
refuse_agent_keys: bool = False
|
||||
agent_id: str | None = None
|
||||
team: GroupedModelScope | None = None
|
||||
embedded_team: ModelAccessFacts | None = None
|
||||
assigned_models: tuple[str, ...] | None = None
|
||||
member: ModelAccessFacts | None = None
|
||||
project: ModelAccessFacts | None = None
|
||||
team_present: bool = False
|
||||
embedded_team_grants: tuple[str, ...] = ()
|
||||
member_grants: tuple[str, ...] = ()
|
||||
project_grants: tuple[str, ...] = ()
|
||||
assigned_scope: ModelAccessFacts | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FallbackAuthorizationDecision:
|
||||
kind: Literal[
|
||||
"Allow",
|
||||
"Deny",
|
||||
"ReadKey",
|
||||
"ReadTeam",
|
||||
"ReadEmbeddedTeam",
|
||||
"ReadMember",
|
||||
"ReadProject",
|
||||
"ReadKeyGroups",
|
||||
"ReadTeamGroups",
|
||||
"ReadAssignedGroups",
|
||||
"ReadAssignedScope",
|
||||
]
|
||||
denial: ModelAccessDecision | None = None
|
||||
|
||||
|
||||
_DECISION: Final = TypeAdapter(FallbackAuthorizationDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def fallback_authorization(facts: FallbackAuthorizationFacts) -> FallbackAuthorizationDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_fallback_access(asdict(facts)))
|
||||
88
litellm/_auth/fallback_candidate.py
Normal file
88
litellm/_auth/fallback_candidate.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
from dataclasses import asdict, dataclass
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.budget_pricing import ModelPriceFacts
|
||||
from litellm._auth.common_budgets import BudgetFacts
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm._auth.fallback_access import FallbackAuthorizationFacts
|
||||
from litellm._auth.model_access import ModelAccessFacts
|
||||
from litellm._auth.model_attribution import ModelGroupAttributionFacts
|
||||
from litellm._auth.model_budget_data import ModelBudgetEntity, ModelBudgetSnapshot
|
||||
from litellm._auth.routes import RouteContext
|
||||
from litellm._auth.unpriced_models import UnpricedModelFacts
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CandidateBudgetScope:
|
||||
entity: ModelBudgetEntity
|
||||
identity_present: bool
|
||||
configured: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CandidateBudgetRead:
|
||||
model_index: int
|
||||
entity: ModelBudgetEntity
|
||||
snapshot: ModelBudgetSnapshot
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FallbackCandidateFacts:
|
||||
router_present: bool
|
||||
user_present: bool
|
||||
team_present: bool
|
||||
membership: Literal["Unread", "Loaded", "Failed"] = "Unread"
|
||||
block_unpriced: bool = False
|
||||
route: RouteContext | None = None
|
||||
unpriced: UnpricedModelFacts | None = None
|
||||
customer_present: bool = False
|
||||
customer_grants: tuple[str, ...] | None = None
|
||||
customer: ModelAccessFacts | None = None
|
||||
price: ModelPriceFacts | None = None
|
||||
budget_models: tuple[str, ...] | None = None
|
||||
budget_scopes: tuple[CandidateBudgetScope, ...] | None = None
|
||||
budgets: tuple[CandidateBudgetRead, ...] = ()
|
||||
authorization: FallbackAuthorizationFacts | None = None
|
||||
attribution_reset: bool = False
|
||||
attribution: ModelGroupAttributionFacts | None = None
|
||||
attribution_stamped: bool = False
|
||||
group_budgets_read: bool = False
|
||||
group_budgets: tuple[BudgetFacts, ...] = ()
|
||||
io_failed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FallbackCandidateDecision:
|
||||
kind: Literal[
|
||||
"Complete",
|
||||
"ReadMembership",
|
||||
"ReadRoute",
|
||||
"ReadUnpriced",
|
||||
"ReadCustomerGrants",
|
||||
"ReadCustomer",
|
||||
"ReadPrice",
|
||||
"ReadBudgetModels",
|
||||
"ReadBudgetScopes",
|
||||
"ReadBudget",
|
||||
"ReadAuthorization",
|
||||
"ResetAttribution",
|
||||
"ReadAttribution",
|
||||
"StampAttribution",
|
||||
"ReadGroupBudgets",
|
||||
]
|
||||
usable: bool = False
|
||||
log_failure: bool = False
|
||||
model_index: int = 0
|
||||
entity: ModelBudgetEntity = "Key"
|
||||
groups: tuple[str, ...] = ()
|
||||
|
||||
|
||||
_DECISION: Final = TypeAdapter(FallbackCandidateDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def fallback_candidate(facts: FallbackCandidateFacts) -> FallbackCandidateDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_fallback_candidate(asdict(facts)))
|
||||
45
litellm/_auth/key_model_access.py
Normal file
45
litellm/_auth/key_model_access.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
from dataclasses import asdict, dataclass
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm._auth.fallback_access import GroupedModelScope
|
||||
from litellm._auth.model_access import ModelAccessDecision
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class KeyAndFallbackModelAccessFacts:
|
||||
config_nonempty: bool
|
||||
key_models: tuple[str, ...]
|
||||
primary_loaded: bool = False
|
||||
primary: GroupedModelScope | None = None
|
||||
fallback_names: tuple[str, ...] | None = None
|
||||
fallback_scopes: tuple[GroupedModelScope, ...] = ()
|
||||
routing: tuple[Literal["Routed", "Failed"], ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class KeyAndFallbackModelAccessDecision:
|
||||
kind: Literal[
|
||||
"Allow",
|
||||
"Deny",
|
||||
"ReadPrimary",
|
||||
"ReadPrimaryGroups",
|
||||
"ReadFallbackNames",
|
||||
"ReadFallbackScope",
|
||||
"ReadFallbackGroups",
|
||||
"RouteFallback",
|
||||
"RoutingFailure",
|
||||
]
|
||||
index: int = 0
|
||||
denial: ModelAccessDecision | None = None
|
||||
|
||||
|
||||
_DECISION: Final = TypeAdapter(KeyAndFallbackModelAccessDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def key_and_fallback_model_access(facts: KeyAndFallbackModelAccessFacts) -> KeyAndFallbackModelAccessDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_key_model_access(asdict(facts)))
|
||||
129
litellm/_auth/managed.py
Normal file
129
litellm/_auth/managed.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Final, Literal, TypeAlias
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm._auth.rust_core import encode_facts
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BoundIdentity:
|
||||
active: bool
|
||||
revision: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Agent:
|
||||
agent_id: str
|
||||
enabled: bool
|
||||
identity_managed: bool
|
||||
identity: BoundIdentity | None
|
||||
execution_mode: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Context:
|
||||
agent_id: str
|
||||
binding_revision: str | None
|
||||
mode: str
|
||||
user_id: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Actor:
|
||||
agent: Agent
|
||||
context: Context | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ActorLookup:
|
||||
agent_id: str | None
|
||||
has_database: bool
|
||||
registry_loaded: bool = False
|
||||
registry_managed: bool = False
|
||||
agent_loaded: bool = False
|
||||
agent: Agent | None = None
|
||||
retired: bool | None = None
|
||||
context: Context | None = None
|
||||
jwt_claims: bool = False
|
||||
delegation_verified: bool = False
|
||||
human_grants: tuple[str, ...] | None = None
|
||||
policy_assigned: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UnionGrants:
|
||||
grants: tuple[tuple[str, ...] | None, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IntersectGrants:
|
||||
left: tuple[str, ...] | None
|
||||
right: tuple[str, ...] | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeclaredGrants:
|
||||
direct: tuple[str, ...]
|
||||
declared_groups: tuple[str, ...]
|
||||
unified_groups: tuple[str, ...]
|
||||
group_agents: tuple[str, ...] | None = None
|
||||
unified_agents: tuple[str, ...] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ManagedAgentGrants:
|
||||
permission_present: bool
|
||||
mode: str | None
|
||||
user_present: bool
|
||||
own: tuple[str, ...] | None = None
|
||||
ceilings: tuple[tuple[str, ...], ...] | None = None
|
||||
caller_loaded: bool = False
|
||||
caller: tuple[str, ...] | None = None
|
||||
human: tuple[str, ...] | None = None
|
||||
|
||||
|
||||
ManagedDenial: TypeAlias = Literal[
|
||||
"Disabled",
|
||||
"MissingContext",
|
||||
"ChangedIdentity",
|
||||
"ExecutionMode",
|
||||
"MissingHuman",
|
||||
"DatabaseRequired",
|
||||
"Removed",
|
||||
"VerifiedIdentityRequired",
|
||||
"HumanNotPermitted",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ManagedDecision:
|
||||
action: Literal[
|
||||
"Allow",
|
||||
"Deny",
|
||||
"ReadRegistry",
|
||||
"ReadAgent",
|
||||
"ReadRetired",
|
||||
"AssignPolicy",
|
||||
"ReadHumanGrants",
|
||||
"Grants",
|
||||
"ReadDeclaredGroups",
|
||||
"ReadUnifiedGroups",
|
||||
"ReadOwn",
|
||||
"ReadCeilings",
|
||||
"ReadCaller",
|
||||
]
|
||||
denial: ManagedDenial | None
|
||||
grants: tuple[str, ...] | None
|
||||
|
||||
|
||||
_DECISION: Final = TypeAdapter(ManagedDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def decide(
|
||||
facts: Actor | ActorLookup | UnionGrants | IntersectGrants | DeclaredGrants | ManagedAgentGrants,
|
||||
) -> ManagedDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_managed(encode_facts(facts)))
|
||||
95
litellm/_auth/managed_models.py
Normal file
95
litellm/_auth/managed_models.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
from dataclasses import asdict, dataclass
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm._auth.fallback_access import GroupedModelScope
|
||||
from litellm._auth.member_models import MemberModelFacts
|
||||
from litellm._auth.model_access import ModelAccessDecision, ModelAccessFacts
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ManagedModelFacts:
|
||||
purpose: Literal["All", "Ceilings", "Caller"]
|
||||
model_present: bool
|
||||
token_present: bool
|
||||
managed_loaded: bool = False
|
||||
managed_present: bool = False
|
||||
managed_models_valid: bool = False
|
||||
managed_models_count: int = 0
|
||||
managed: ModelAccessFacts | None = None
|
||||
ceilings: tuple[tuple[str, ...], ...] | None = None
|
||||
aliases_loaded: bool = False
|
||||
scopes: tuple[ModelAccessFacts, ...] = ()
|
||||
caller_loaded: bool = False
|
||||
caller_present: bool = False
|
||||
team_loaded: bool = False
|
||||
team_present: bool = False
|
||||
team: GroupedModelScope | None = None
|
||||
member: MemberModelFacts | None = None
|
||||
user_loaded: bool = False
|
||||
user_present: bool = False
|
||||
user_grants: tuple[str, ...] = ()
|
||||
user: ModelAccessFacts | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ManagedModelDecision:
|
||||
kind: Literal[
|
||||
"Allow",
|
||||
"Deny",
|
||||
"ReadManaged",
|
||||
"ReadManagedScope",
|
||||
"ReadCeilings",
|
||||
"ReadAliases",
|
||||
"ReadCeilingScope",
|
||||
"ReadCaller",
|
||||
"ReadTeam",
|
||||
"ReadTeamScope",
|
||||
"ReadTeamGroups",
|
||||
"ReadMember",
|
||||
"ReadUser",
|
||||
"ReadUserScope",
|
||||
]
|
||||
denial: ModelAccessDecision | None = None
|
||||
member_models: tuple[str, ...] | None = None
|
||||
index: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentCeilingSourceFacts:
|
||||
agent_present: bool
|
||||
managed_loaded: bool = False
|
||||
managed_present: bool = False
|
||||
loaded: bool = False
|
||||
count: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentCeilingSourceDecision:
|
||||
kind: Literal["ReadManaged", "ReadManagedCeilings", "ReadUnmanagedCeiling", "Complete"]
|
||||
indices: tuple[int, ...] = ()
|
||||
|
||||
|
||||
_DECISION: Final = TypeAdapter(ManagedModelDecision)
|
||||
_CEILINGS: Final = TypeAdapter(AgentCeilingSourceDecision)
|
||||
_GRANTS: Final = TypeAdapter(frozenset[str])
|
||||
|
||||
|
||||
@native_boundary
|
||||
def managed_models(facts: ManagedModelFacts) -> ManagedModelDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_managed_models(asdict(facts)))
|
||||
|
||||
|
||||
@native_boundary
|
||||
def agent_ceiling_source(facts: AgentCeilingSourceFacts) -> AgentCeilingSourceDecision:
|
||||
return _CEILINGS.validate_python(_native.request_auth_agent_ceiling_source(asdict(facts)))
|
||||
|
||||
|
||||
@native_boundary
|
||||
def managed_model_grants(
|
||||
models: tuple[str | None, ...] | None, ceilings: tuple[tuple[str, ...], ...]
|
||||
) -> frozenset[str]:
|
||||
return _GRANTS.validate_python(_native.request_auth_managed_model_grants({"models": models, "ceilings": ceilings}))
|
||||
32
litellm/_auth/member_models.py
Normal file
32
litellm/_auth/member_models.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
from dataclasses import asdict, dataclass
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm._auth.model_access import ModelAccessDecision, ModelAccessFacts
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MemberModelFacts:
|
||||
user_present: bool
|
||||
team_present: bool
|
||||
membership_loaded: bool = False
|
||||
grants: tuple[str, ...] | None = None
|
||||
scope: ModelAccessFacts | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MemberModelDecision:
|
||||
kind: Literal["Allow", "Deny", "ReadMembership", "ReadGrants", "ReadScope"]
|
||||
denial: ModelAccessDecision | None = None
|
||||
member_models: tuple[str, ...] | None = None
|
||||
|
||||
|
||||
_DECISION: Final = TypeAdapter(MemberModelDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def member_models(facts: MemberModelFacts) -> MemberModelDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_member_models(asdict(facts)))
|
||||
261
litellm/_auth/model_access.py
Normal file
261
litellm/_auth/model_access.py
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Final, Literal, TypeAlias
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
ModelObject: TypeAlias = Literal["user", "customer", "team", "key", "org", "project", "agent"]
|
||||
ModelReadKind: TypeAlias = Literal["Compaction", "Aliases", "AliasTarget", "Deployment", "Groups", "Names", "Provider"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelProvider:
|
||||
stripped_model: str
|
||||
provider: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelRecord:
|
||||
access_groups: tuple[str, ...] = ()
|
||||
provider: ModelProvider | None = None
|
||||
deployment_team: str | None = None
|
||||
deployment_public_name: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelReading:
|
||||
kind: ModelReadKind
|
||||
model: str
|
||||
values: tuple[str, ...] = ()
|
||||
value: str | None = None
|
||||
team: str | None = None
|
||||
provider: ModelProvider | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelEvaluation:
|
||||
grants: tuple[str, ...]
|
||||
reads: tuple[ModelReading, ...]
|
||||
aliases: ModelAliases | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelRegistry:
|
||||
present: bool = False
|
||||
names: tuple[str, ...] = ()
|
||||
aliases: Mapping[str, str] = field(default_factory=dict[str, str])
|
||||
records: Mapping[str, ModelRecord] = field(default_factory=dict[str, ModelRecord])
|
||||
compaction_parents: Mapping[str, str] = field(default_factory=dict[str, str])
|
||||
helper_present: bool | None = None
|
||||
evaluations: tuple[ModelEvaluation, ...] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelAliases:
|
||||
team: Mapping[str, str] = field(default_factory=dict[str, str])
|
||||
key: Mapping[str, str] = field(default_factory=dict[str, str])
|
||||
global_: Mapping[str, str] = field(default_factory=dict[str, str])
|
||||
team_order: tuple[str, ...] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelAccessFacts:
|
||||
requested: tuple[str, ...]
|
||||
grants: tuple[str, ...] = ()
|
||||
team_id: str | None = None
|
||||
aliases: ModelAliases = field(default_factory=ModelAliases)
|
||||
registry: ModelRegistry = field(default_factory=ModelRegistry)
|
||||
object_type: ModelObject = "user"
|
||||
fallback_depth: int = 0
|
||||
max_depth: int = 5
|
||||
is_list: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelAccessDecision:
|
||||
kind: Literal[
|
||||
"Allow",
|
||||
"Deny",
|
||||
"Depth",
|
||||
"NoDefaultModels",
|
||||
"NoAgentModels",
|
||||
"EmptyAgentCeiling",
|
||||
"PersonalTeam",
|
||||
"ReadGroups",
|
||||
"ReadScope",
|
||||
"ReadGroupScope",
|
||||
]
|
||||
model: str | None = None
|
||||
grants: tuple[str, ...] = ()
|
||||
object_type: ModelObject = "user"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AssignedModelGroup:
|
||||
assigned_team_ids: tuple[str, ...]
|
||||
assigned_key_ids: tuple[str, ...]
|
||||
models: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PersonalModelTeam:
|
||||
blocked: bool
|
||||
available: bool
|
||||
team: ModelAccessFacts
|
||||
member: ModelAccessFacts | None = None
|
||||
|
||||
|
||||
_DECISION: Final = TypeAdapter(ModelAccessDecision)
|
||||
_MODELS: Final = TypeAdapter(tuple[str, ...])
|
||||
_BOOL: Final = TypeAdapter(bool)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def model_access(facts: ModelAccessFacts, *, kind: Literal["Object", "Customer"] = "Object") -> ModelAccessDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_model_access({kind: asdict(facts)}))
|
||||
|
||||
|
||||
@native_boundary
|
||||
def grouped_model_access(
|
||||
facts: ModelAccessFacts,
|
||||
group_ids: tuple[str, ...],
|
||||
group_models: tuple[str, ...] | None = None,
|
||||
*,
|
||||
merge: bool = False,
|
||||
) -> ModelAccessDecision:
|
||||
return _DECISION.validate_python(
|
||||
_native.request_auth_model_access(
|
||||
{
|
||||
"Grouped": {
|
||||
"facts": asdict(facts),
|
||||
"group_ids": group_ids,
|
||||
"group_models": group_models,
|
||||
"merge": merge,
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def user_model_access(facts: ModelAccessFacts, *, present: bool) -> ModelAccessDecision:
|
||||
return _DECISION.validate_python(
|
||||
_native.request_auth_model_access({"User": {"facts": asdict(facts), "present": present}})
|
||||
)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def user_model_precheck(*, present: bool, grants: tuple[str, ...]) -> ModelAccessDecision:
|
||||
return _DECISION.validate_python(
|
||||
_native.request_auth_model_access({"UserPrecheck": {"present": present, "grants": grants}})
|
||||
)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def agent_model_access(
|
||||
facts: ModelAccessFacts, managed_grants: tuple[str, ...] | None, ceilings: tuple[tuple[str, ...], ...]
|
||||
) -> ModelAccessDecision:
|
||||
return _DECISION.validate_python(
|
||||
_native.request_auth_model_access(
|
||||
{
|
||||
"Agent": {
|
||||
"facts": asdict(facts),
|
||||
"managed_grants": managed_grants,
|
||||
"ceilings": ceilings,
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def personal_model_access(requested: tuple[str, ...], teams: tuple[PersonalModelTeam, ...]) -> ModelAccessDecision:
|
||||
return _DECISION.validate_python(
|
||||
_native.request_auth_model_access(
|
||||
{
|
||||
"Personal": {
|
||||
"requested": requested,
|
||||
"teams": tuple(asdict(team) for team in teams),
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def model_candidates(facts: ModelAccessFacts) -> tuple[str, ...]:
|
||||
return _MODELS.validate_python(_native.request_auth_model_access({"Candidates": asdict(facts)}))
|
||||
|
||||
|
||||
@native_boundary
|
||||
def model_access_helper(model: str, facts: ModelAccessFacts) -> bool:
|
||||
return _BOOL.validate_python(
|
||||
_native.request_auth_model_access({"Helper": {"model": model, "facts": asdict(facts)}})
|
||||
)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def key_model_grants(grants: tuple[str, ...], team_id: str | None, team_grants: tuple[str, ...]) -> tuple[str, ...]:
|
||||
return _MODELS.validate_python(
|
||||
_native.request_auth_model_access(
|
||||
{
|
||||
"KeyGrants": {
|
||||
"grants": grants,
|
||||
"team_id": team_id,
|
||||
"team_grants": team_grants,
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def assigned_model_grants(
|
||||
groups: tuple[AssignedModelGroup, ...], team_id: str | None, key_id: str | None
|
||||
) -> tuple[str, ...]:
|
||||
return _MODELS.validate_python(
|
||||
_native.request_auth_model_access(
|
||||
{
|
||||
"AssignedGroups": {
|
||||
"groups": tuple(asdict(group) for group in groups),
|
||||
"team_id": team_id,
|
||||
"key_id": key_id,
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def personal_model_access_applies(
|
||||
*,
|
||||
enabled: bool,
|
||||
token_present: bool,
|
||||
team_id: str | None,
|
||||
user_id: str | None,
|
||||
master: bool,
|
||||
role: str | None,
|
||||
api_key_present: bool,
|
||||
inheritance: bool = False,
|
||||
) -> bool:
|
||||
return _BOOL.validate_python(
|
||||
_native.request_auth_model_access(
|
||||
{
|
||||
"PersonalApplies": {
|
||||
"enabled": enabled,
|
||||
"token_present": token_present,
|
||||
"team_id": team_id,
|
||||
"user_id": user_id,
|
||||
"master": master,
|
||||
"role": role,
|
||||
"api_key_present": api_key_present,
|
||||
"inheritance": inheritance,
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
46
litellm/_auth/model_attribution.py
Normal file
46
litellm/_auth/model_attribution.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
from dataclasses import asdict, dataclass
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelGroupAttributionFacts:
|
||||
model_present: bool = False
|
||||
token_present: bool = False
|
||||
router_present: bool = False
|
||||
database_present: bool = False
|
||||
team_present: bool = False
|
||||
user_present: bool = False
|
||||
membership_loaded: bool = False
|
||||
token_org: str | None = None
|
||||
team_org: str | None = None
|
||||
registry_loaded: bool = False
|
||||
registry: tuple[str, ...] | None = None
|
||||
covering: tuple[str, ...] | None = None
|
||||
grants: tuple[tuple[str, ...], ...] = ()
|
||||
org_failed: bool = False
|
||||
io_failed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelGroupAttributionDecision:
|
||||
kind: Literal[
|
||||
"Complete", "ReadRegistry", "ReadCovering", "ReadKey", "ReadTeam", "ReadMember", "ReadProject", "ReadOrg"
|
||||
]
|
||||
groups: tuple[str, ...] = ()
|
||||
stamp: bool = False
|
||||
lookup: bool = False
|
||||
include: bool = False
|
||||
id: str | None = None
|
||||
|
||||
|
||||
_DECISION: Final = TypeAdapter(ModelGroupAttributionDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def model_group_attribution(facts: ModelGroupAttributionFacts) -> ModelGroupAttributionDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_model_attribution(asdict(facts)))
|
||||
86
litellm/_auth/model_budget_data.py
Normal file
86
litellm/_auth/model_budget_data.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Final, Literal, TypeAlias
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
ModelBudgetEntity: TypeAlias = Literal["Key", "User", "Team", "TeamMember", "EndUser"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelBudgetEntry:
|
||||
name: str
|
||||
max_budget: float | None
|
||||
duration: str | None
|
||||
duration_seconds: float | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelBudgetResolveFacts:
|
||||
model: str
|
||||
bedrock_base: str
|
||||
bedrock_provider: str
|
||||
entries: tuple[ModelBudgetEntry, ...]
|
||||
key_entries: tuple[ModelBudgetEntry, ...] = ()
|
||||
team: bool = False
|
||||
access_groups: tuple[str, ...] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelBudgetResolution:
|
||||
read_groups: bool
|
||||
entry: ModelBudgetEntry | None
|
||||
named_model: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelBudgetSnapshot:
|
||||
max_budget: float | None = None
|
||||
local_spend: float | None = None
|
||||
legacy_spend: float | None = None
|
||||
shared_spend: float | None = None
|
||||
shared_started_at: float | None = None
|
||||
shared_window_seconds: float | None = None
|
||||
now: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelBudgetQueryFacts:
|
||||
entity: ModelBudgetEntity
|
||||
snapshot: ModelBudgetSnapshot
|
||||
duration_seconds: float | None = None
|
||||
process_uptime: float = 0.0
|
||||
same_counter: bool = False
|
||||
redis_present: bool = False
|
||||
shared_read: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelBudgetDecision:
|
||||
read: Literal["Local", "Legacy", "Shared"] | None
|
||||
denied: bool
|
||||
current_spend: float
|
||||
|
||||
|
||||
_RESOLVE: Final = TypeAdapter(ModelBudgetResolveFacts)
|
||||
_RESOLUTION: Final = TypeAdapter(ModelBudgetResolution)
|
||||
_QUERY: Final = TypeAdapter(ModelBudgetQueryFacts)
|
||||
_SNAPSHOT: Final = TypeAdapter(ModelBudgetSnapshot)
|
||||
_DECISION: Final = TypeAdapter(ModelBudgetDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def resolve(facts: ModelBudgetResolveFacts) -> ModelBudgetResolution:
|
||||
return _RESOLUTION.validate_python(_native.request_auth_model_budget_resolve(_RESOLVE.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes the validated frozen facts for the native boundary
|
||||
|
||||
|
||||
@native_boundary
|
||||
def query(facts: ModelBudgetQueryFacts) -> ModelBudgetDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_model_budget_query(_QUERY.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes the validated frozen facts for the native boundary
|
||||
|
||||
|
||||
@native_boundary
|
||||
def check(facts: ModelBudgetSnapshot) -> ModelBudgetDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_model_budget_check(_SNAPSHOT.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes the validated frozen facts for the native boundary
|
||||
48
litellm/_auth/model_groups.py
Normal file
48
litellm/_auth/model_groups.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
from dataclasses import asdict, dataclass
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GroupResources:
|
||||
assigned_team_ids: tuple[str, ...]
|
||||
assigned_key_ids: tuple[str, ...]
|
||||
resources: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GroupLookup:
|
||||
kind: Literal["Found", "Missing", "Failed"]
|
||||
group: GroupResources | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResourceGroupFacts:
|
||||
subject_present: bool
|
||||
database_present: bool
|
||||
cache_present: bool
|
||||
group_ids: tuple[str, ...]
|
||||
team_id: str | None
|
||||
key_id: str | None
|
||||
check_db_only: bool = False
|
||||
strict_errors: bool = False
|
||||
loaded: tuple[GroupLookup, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResourceGroupDecision:
|
||||
kind: Literal["Read", "Raise", "Complete"]
|
||||
index: int = 0
|
||||
resources: tuple[str, ...] = ()
|
||||
|
||||
|
||||
_DECISION: Final = TypeAdapter(ResourceGroupDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def resource_group_decision(facts: ResourceGroupFacts) -> ResourceGroupDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_model_groups(asdict(facts)))
|
||||
37
litellm/_auth/model_helpers.py
Normal file
37
litellm/_auth/model_helpers.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
from dataclasses import asdict, dataclass
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm._auth.model_access import ModelProvider
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelHelperFacts:
|
||||
kind: Literal["Group", "Helper"]
|
||||
model: str
|
||||
grants: tuple[str, ...] | None
|
||||
team_id: str | None = None
|
||||
team_aliases: tuple[str, ...] = ()
|
||||
router_present: bool = False
|
||||
groups: tuple[str, ...] | None = None
|
||||
names: tuple[str, ...] | None = None
|
||||
provider_loaded: bool = False
|
||||
provider: ModelProvider | None = None
|
||||
provider_reads: tuple[ModelProvider | None, ...] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelHelperDecision:
|
||||
kind: Literal["Complete", "ReadGroups", "ReadNames", "ReadProvider"]
|
||||
allowed: bool = False
|
||||
|
||||
|
||||
_DECISION: Final = TypeAdapter(ModelHelperDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def model_helper(facts: ModelHelperFacts) -> ModelHelperDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_model_helpers(asdict(facts)))
|
||||
26
litellm/_auth/model_projection.py
Normal file
26
litellm/_auth/model_projection.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
from dataclasses import asdict, dataclass
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm._auth.model_access import ModelAccessDecision, ModelAccessFacts, ModelReading, ModelReadKind
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelProjectionDecision:
|
||||
kind: Literal["Complete", "Read"]
|
||||
read: ModelReadKind | None = None
|
||||
model: str | None = None
|
||||
decision: ModelAccessDecision | None = None
|
||||
|
||||
|
||||
_DECISION: Final = TypeAdapter(ModelProjectionDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def model_projection(facts: ModelAccessFacts, reads: tuple[ModelReading, ...]) -> ModelProjectionDecision:
|
||||
return _DECISION.validate_python(
|
||||
_native.request_auth_model_projection(asdict(facts), tuple(asdict(read) for read in reads))
|
||||
)
|
||||
95
litellm/_auth/org_routes.py
Normal file
95
litellm/_auth/org_routes.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
from dataclasses import asdict, dataclass
|
||||
from typing import Final, Literal, TypeAlias
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizationMembership:
|
||||
organization_id: str | None
|
||||
role: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizationRouteFacts:
|
||||
route: str
|
||||
user_present: bool
|
||||
user_role: str | None
|
||||
memberships: tuple[OrganizationMembership, ...] | None
|
||||
organization_id: str | None
|
||||
org_admin_routes: tuple[str, ...]
|
||||
organization_id_present: bool = False
|
||||
identifier_pending: bool = False
|
||||
hash_failure: str | None = None
|
||||
request_pending: bool = False
|
||||
memberships_pending: bool = False
|
||||
|
||||
|
||||
OrgRouteDenial: TypeAlias = Literal[
|
||||
"CreateOrganization",
|
||||
"NoMemberships",
|
||||
"MissingOrganization",
|
||||
"MissingRole",
|
||||
"WrongRole",
|
||||
"TeamMissingOrganization",
|
||||
"TeamWrongRole",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizationRouteDecision:
|
||||
denial: OrgRouteDenial | None
|
||||
organization_role: str | None
|
||||
organization_ids: tuple[str, ...]
|
||||
read_identifier: bool = False
|
||||
hash_failure: str | None = None
|
||||
read_request: bool = False
|
||||
read_memberships: bool = False
|
||||
|
||||
|
||||
_DECISION: Final = TypeAdapter(OrganizationRouteDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def organization_route(facts: OrganizationRouteFacts) -> OrganizationRouteDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_org_route(asdict(facts)))
|
||||
|
||||
|
||||
@native_boundary
|
||||
def user_is_org_admin(memberships: tuple[OrganizationMembership, ...], organizations: tuple[str, ...]) -> bool:
|
||||
return _native.request_auth_org_admin(tuple(asdict(item) for item in memberships), organizations)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Identifier:
|
||||
text: str | None
|
||||
hash_failure: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AdminFacts:
|
||||
memberships: tuple[OrganizationMembership, ...] | None
|
||||
request_read: bool = False
|
||||
singular_present: bool = False
|
||||
plural_count: int = 0
|
||||
candidate_index: int = 0
|
||||
candidate: Identifier | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AdminDecision:
|
||||
action: Literal["ReadRequest", "ReadSingular", "ReadPlural", "Allow", "Deny", "HashFailure"]
|
||||
candidate_index: int
|
||||
plural_index: int
|
||||
hash_failure: str | None
|
||||
|
||||
|
||||
_ADMIN: Final = TypeAdapter(AdminDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def admin(facts: AdminFacts) -> AdminDecision:
|
||||
return _ADMIN.validate_python(_native.request_auth_org_admin_raw(asdict(facts)))
|
||||
53
litellm/_auth/owner_teams.py
Normal file
53
litellm/_auth/owner_teams.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
from dataclasses import asdict, dataclass
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm._auth.personal_budget import PersonalTeamBudgetFacts
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnerTeamLoad:
|
||||
present: bool
|
||||
failed: bool = False
|
||||
blocked: bool = False
|
||||
org: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnerTeamBudget:
|
||||
index: int
|
||||
facts: PersonalTeamBudgetFacts
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnerTeamFacts:
|
||||
purpose: Literal["Models", "Grants"]
|
||||
ids: tuple[str, ...]
|
||||
ui_team_id: str
|
||||
user_present: bool = True
|
||||
database_present: bool = True
|
||||
org: str | None = None
|
||||
loaded: bool = False
|
||||
loads: tuple[OwnerTeamLoad, ...] = ()
|
||||
budgets: tuple[OwnerTeamBudget, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnerTeamDecision:
|
||||
kind: Literal["ReadTeams", "ReadBudgets", "Complete"]
|
||||
ids: tuple[str, ...] = ()
|
||||
indices: tuple[int, ...] = ()
|
||||
selected: tuple[int, ...] = ()
|
||||
withheld: tuple[int, ...] = ()
|
||||
unreadable: bool = False
|
||||
|
||||
|
||||
_DECISION: Final = TypeAdapter(OwnerTeamDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def owner_teams(facts: OwnerTeamFacts) -> OwnerTeamDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_owner_teams(asdict(facts)))
|
||||
66
litellm/_auth/personal_budget.py
Normal file
66
litellm/_auth/personal_budget.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
from dataclasses import asdict, dataclass
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.common_budgets import BudgetFacts, MemberLimitFacts
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PersonalTeamBudgetFacts:
|
||||
user_present: bool = False
|
||||
team_id_present: bool = True
|
||||
check_db_only: bool = False
|
||||
token_org: str | None = None
|
||||
team_org: str | None = None
|
||||
source_ready: bool = False
|
||||
team: BudgetFacts | None = None
|
||||
team_alerted: bool = False
|
||||
window_count: int | None = None
|
||||
windows: tuple[BudgetFacts, ...] = ()
|
||||
membership_loaded: bool = False
|
||||
member_limit: MemberLimitFacts | None = None
|
||||
default_id: str | None = None
|
||||
default_loaded: bool = False
|
||||
member: BudgetFacts | None = None
|
||||
member_alerted: bool = False
|
||||
org_loaded: bool = False
|
||||
org_failed: bool = False
|
||||
org: BudgetFacts | None = None
|
||||
org_alerted: bool = False
|
||||
io_failed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PersonalTeamBudgetDecision:
|
||||
kind: Literal[
|
||||
"Complete",
|
||||
"Source",
|
||||
"ReadTeam",
|
||||
"AlertTeam",
|
||||
"ReadWindows",
|
||||
"ReadWindow",
|
||||
"ReadMembership",
|
||||
"ReadMemberLimit",
|
||||
"ReadDefault",
|
||||
"ReadMember",
|
||||
"AlertMember",
|
||||
"ReadOrg",
|
||||
"AlertOrg",
|
||||
]
|
||||
allowed: bool = False
|
||||
log_failure: bool = False
|
||||
org_id: str | None = None
|
||||
id: str = ""
|
||||
index: int = 0
|
||||
limit: float | None = None
|
||||
|
||||
|
||||
_DECISION: Final = TypeAdapter(PersonalTeamBudgetDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def personal_team_budget(facts: PersonalTeamBudgetFacts) -> PersonalTeamBudgetDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_personal_budget(asdict(facts)))
|
||||
32
litellm/_auth/personal_model_listing.py
Normal file
32
litellm/_auth/personal_model_listing.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
from dataclasses import asdict, dataclass
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm._auth.personal_models import LoadedTeamModelFacts
|
||||
from litellm._auth.personal_owner import PersonalOwnerFacts
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PersonalModelListingFacts:
|
||||
owner: PersonalOwnerFacts
|
||||
names: tuple[str, ...] | None = None
|
||||
models: tuple[LoadedTeamModelFacts, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PersonalModelListingDecision:
|
||||
kind: Literal["ReadOwner", "ReadNames", "ReadModel", "Complete"]
|
||||
index: int = 0
|
||||
teams: tuple[int, ...] = ()
|
||||
selected: tuple[int, ...] = ()
|
||||
|
||||
|
||||
_DECISION: Final = TypeAdapter(PersonalModelListingDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def personal_model_listing(facts: PersonalModelListingFacts) -> PersonalModelListingDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_personal_model_listing(asdict(facts)))
|
||||
33
litellm/_auth/personal_model_request.py
Normal file
33
litellm/_auth/personal_model_request.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
from dataclasses import asdict, dataclass
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm._auth.owner_teams import OwnerTeamFacts
|
||||
from litellm._auth.personal_models import LoadedTeamModelFacts
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PersonalModelRequestFacts:
|
||||
requested: tuple[str, ...]
|
||||
owner_present: bool
|
||||
teams: OwnerTeamFacts | None = None
|
||||
models: tuple[LoadedTeamModelFacts, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PersonalModelRequestDecision:
|
||||
kind: Literal["Allow", "Deny", "ReadTeams", "ReadModel"]
|
||||
model: str = ""
|
||||
index: int = 0
|
||||
teams: tuple[int, ...] = ()
|
||||
|
||||
|
||||
_DECISION: Final = TypeAdapter(PersonalModelRequestDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def personal_model_request(facts: PersonalModelRequestFacts) -> PersonalModelRequestDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_personal_model_request(asdict(facts)))
|
||||
45
litellm/_auth/personal_models.py
Normal file
45
litellm/_auth/personal_models.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
from dataclasses import asdict, dataclass
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm._auth.fallback_access import GroupedModelScope
|
||||
from litellm._auth.model_access import ModelAccessFacts
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PersonalModelScope:
|
||||
user_present: bool
|
||||
team_id_present: bool
|
||||
team: GroupedModelScope | None = None
|
||||
member_grants: tuple[str, ...] | None = None
|
||||
member: ModelAccessFacts | None = None
|
||||
io_failed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LoadedTeamModelFacts:
|
||||
present: tuple[bool, ...]
|
||||
scopes: tuple[PersonalModelScope, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PersonalModelDecision:
|
||||
kind: Literal["Complete", "ReadTeam", "ReadGroups", "ReadMembership", "ReadMember", "ReadScopes"]
|
||||
allowed: bool = False
|
||||
indices: tuple[int, ...] = ()
|
||||
|
||||
|
||||
_DECISION: Final = TypeAdapter(PersonalModelDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def personal_model_scope(facts: PersonalModelScope) -> PersonalModelDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_personal_models({"Scope": asdict(facts)}))
|
||||
|
||||
|
||||
@native_boundary
|
||||
def loaded_team_models(facts: LoadedTeamModelFacts) -> PersonalModelDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_personal_models({"Loaded": asdict(facts)}))
|
||||
36
litellm/_auth/personal_owner.py
Normal file
36
litellm/_auth/personal_owner.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
from dataclasses import asdict, dataclass
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm._auth.owner_teams import OwnerTeamFacts
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PersonalOwnerFacts:
|
||||
user_present: bool
|
||||
database_present: bool
|
||||
cache_present: bool
|
||||
logging_present: bool
|
||||
owner_loaded: bool = False
|
||||
owner_present: bool = False
|
||||
owner_failed: bool = False
|
||||
teams: OwnerTeamFacts | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PersonalOwnerDecision:
|
||||
kind: Literal["ReadOwner", "ReadTeams", "Complete"]
|
||||
selected: tuple[int, ...] = ()
|
||||
withheld: tuple[int, ...] = ()
|
||||
unreadable: bool = False
|
||||
|
||||
|
||||
_DECISION: Final = TypeAdapter(PersonalOwnerDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def personal_owner(facts: PersonalOwnerFacts) -> PersonalOwnerDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_personal_owner(asdict(facts)))
|
||||
131
litellm/_auth/reservation.py
Normal file
131
litellm/_auth/reservation.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Final, Literal, TypeAlias
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm._auth.rust_core import encode_facts
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Gate:
|
||||
token_present: bool
|
||||
llm_route: bool | None = None
|
||||
unbilled: bool | None = None
|
||||
model_present: bool | None = None
|
||||
counter_count: int | None = None
|
||||
cost_read: bool = False
|
||||
cost: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Sources:
|
||||
key_present: bool
|
||||
team_present: bool
|
||||
team_id: str | None
|
||||
user_present: bool
|
||||
user_id: str | None
|
||||
apply_user_budget_to_team_keys: bool
|
||||
|
||||
|
||||
Scope: TypeAlias = Literal["Key", "Team", "User", "EndUser", "Tag", "AccessGroup", "Member", "Organization", "Project"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Counter:
|
||||
scope: Scope
|
||||
limit: float | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Window:
|
||||
duration_present: bool
|
||||
limit: float | None
|
||||
start_present: bool | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Identity:
|
||||
scope: Literal["EndUser", "Organization", "Project"]
|
||||
primary: str | None
|
||||
fallback: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MemberGate:
|
||||
team_present: bool
|
||||
team_id: str | None
|
||||
user_present: bool
|
||||
user_id: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MemberCap:
|
||||
member_limit: float | None
|
||||
default_read: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IdentityPlan:
|
||||
id: str | None
|
||||
kind: Literal["Identity"] = "Identity"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Action:
|
||||
kind: Literal[
|
||||
"ReadRoute",
|
||||
"ReadUnbilled",
|
||||
"ReadModel",
|
||||
"ReadCounters",
|
||||
"ReadEstimate",
|
||||
"Reserve",
|
||||
"Skip",
|
||||
"ReadStart",
|
||||
"WarnWindow",
|
||||
"AppendWindow",
|
||||
"ReadMember",
|
||||
"ReadDefault",
|
||||
"ReadCap",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SourcePlan:
|
||||
key: bool
|
||||
team: bool
|
||||
user: bool
|
||||
kind: Literal["Sources"] = "Sources"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CounterPlan:
|
||||
include: bool
|
||||
kind: Literal["Counter"] = "Counter"
|
||||
|
||||
|
||||
_ACTION: Final = TypeAdapter(Action)
|
||||
_SOURCES: Final = TypeAdapter(SourcePlan)
|
||||
_COUNTER: Final = TypeAdapter(CounterPlan)
|
||||
_IDENTITY: Final = TypeAdapter(IdentityPlan)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def action(facts: Gate | Window | MemberGate | MemberCap) -> Action:
|
||||
return _ACTION.validate_python(_native.request_auth_reservation(encode_facts(facts)))
|
||||
|
||||
|
||||
@native_boundary
|
||||
def sources(facts: Sources) -> SourcePlan:
|
||||
return _SOURCES.validate_python(_native.request_auth_reservation(encode_facts(facts)))
|
||||
|
||||
|
||||
@native_boundary
|
||||
def counter(scope: Scope, limit: float | None) -> bool:
|
||||
return _COUNTER.validate_python(_native.request_auth_reservation(encode_facts(Counter(scope, limit)))).include
|
||||
|
||||
|
||||
@native_boundary
|
||||
def identity(facts: Identity) -> str | None:
|
||||
return _IDENTITY.validate_python(_native.request_auth_reservation(encode_facts(facts))).id
|
||||
78
litellm/_auth/resolved_models.py
Normal file
78
litellm/_auth/resolved_models.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
from dataclasses import asdict, dataclass
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm._auth.fallback_access import GroupedModelScope
|
||||
from litellm._auth.member_models import MemberModelFacts
|
||||
from litellm._auth.model_access import ModelAccessDecision, ModelAccessFacts
|
||||
from litellm._auth.personal_model_request import PersonalModelRequestFacts
|
||||
from litellm._auth.team_override import TeamOverrideFacts
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResolvedTeamRead:
|
||||
found: bool = False
|
||||
failed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResolvedModelFacts:
|
||||
model: str = ""
|
||||
config_nonempty: bool = False
|
||||
key_models: tuple[str, ...] = ()
|
||||
team_id: str | None = None
|
||||
user_id: str | None = None
|
||||
project_present: bool = False
|
||||
customer_present: bool = False
|
||||
database_present: bool = False
|
||||
personal_enabled: bool = False
|
||||
master: bool = False
|
||||
role: str | None = None
|
||||
key: GroupedModelScope | None = None
|
||||
team_read: ResolvedTeamRead | None = None
|
||||
embedded_ready: bool = False
|
||||
team: TeamOverrideFacts | None = None
|
||||
member: MemberModelFacts | None = None
|
||||
owner_loaded: bool = False
|
||||
owner_present: bool = False
|
||||
owner_failed: bool = False
|
||||
personal: PersonalModelRequestFacts | None = None
|
||||
project_loaded: bool = False
|
||||
project_grants: tuple[str, ...] = ()
|
||||
project: ModelAccessFacts | None = None
|
||||
customer_loaded: bool = False
|
||||
customer_grants: tuple[str, ...] = ()
|
||||
customer: ModelAccessFacts | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResolvedModelDecision:
|
||||
kind: Literal[
|
||||
"Allow",
|
||||
"Deny",
|
||||
"ReadKey",
|
||||
"ReadKeyGroups",
|
||||
"ReadTeamObject",
|
||||
"ReadEmbeddedTeam",
|
||||
"ReadTeamScope",
|
||||
"ReadMember",
|
||||
"ReadOwner",
|
||||
"ReadPersonal",
|
||||
"ReadProjectObject",
|
||||
"ReadProjectScope",
|
||||
"ReadCustomerObject",
|
||||
"ReadCustomerScope",
|
||||
]
|
||||
denial: ModelAccessDecision | None = None
|
||||
member_models: tuple[str, ...] | None = None
|
||||
|
||||
|
||||
_DECISION: Final = TypeAdapter(ResolvedModelDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def resolved_models(facts: ResolvedModelFacts) -> ResolvedModelDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_resolved_models(asdict(facts)))
|
||||
52
litellm/_auth/route_enable.py
Normal file
52
litellm/_auth/route_enable.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
from dataclasses import asdict, dataclass
|
||||
from typing import Final, Literal, TypeAlias
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm._auth.routes import RouteContext
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
RouteFeature: TypeAlias = Literal["Admin", "Llm"]
|
||||
LlmRouteDecision: TypeAlias = Literal["Allow", "Deny", "ReadEuJev"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RouteSetting:
|
||||
environment_present: bool
|
||||
premium: bool
|
||||
secret: bool | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RouteEnableFacts:
|
||||
context: RouteContext
|
||||
imported: bool = False
|
||||
failed: Literal["Http", "Other"] | None = None
|
||||
admin: RouteSetting | None = None
|
||||
llm: RouteSetting | None = None
|
||||
exemptions: tuple[str, ...] | None = None
|
||||
eu_jev_configured: bool | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RouteEnableDecision:
|
||||
kind: Literal[
|
||||
"Allow", "PropagateHttp", "ReadImport", "ReadExemptions", "ReadEuJev", "ReadSettings", "ReadSecret", "Deny"
|
||||
]
|
||||
feature: RouteFeature | None = None
|
||||
reason: Literal["License", "Disabled"] | None = None
|
||||
|
||||
|
||||
_DECISION: Final = TypeAdapter(RouteEnableDecision)
|
||||
_LLM: Final = TypeAdapter[LlmRouteDecision](LlmRouteDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def route_enable(facts: RouteEnableFacts) -> RouteEnableDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_route_enable(asdict(facts)))
|
||||
|
||||
|
||||
@native_boundary
|
||||
def llm_route(context: RouteContext, configured: bool | None = None) -> LlmRouteDecision:
|
||||
return _LLM.validate_python(_native.request_auth_llm_route(asdict(context), configured))
|
||||
120
litellm/_auth/route_roles.py
Normal file
120
litellm/_auth/route_roles.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Final, Literal, TypeAlias
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RoleFacts:
|
||||
user_present: bool
|
||||
role: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RoleDecision:
|
||||
effective: str
|
||||
user_role: str | None
|
||||
admin: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApiFacts:
|
||||
token_present: bool
|
||||
admin_read: bool
|
||||
user: RoleFacts
|
||||
|
||||
|
||||
ApiDecision: TypeAlias = Literal["InvalidToken", "ReadAdmin", "NonAdmin", "Allow"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class JwtFacts:
|
||||
role: str | None
|
||||
config_read: bool = False
|
||||
config_missing: bool = False
|
||||
|
||||
|
||||
JwtDecision: TypeAlias = Literal["ReadAdmin", "ReadTeam", "DefaultTeam", "Check", "Deny"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InsideFacts:
|
||||
role: str | None
|
||||
user_id: str | None
|
||||
requested_user_id: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Entry:
|
||||
text: str | None
|
||||
python_type: str
|
||||
hash_failure: str | None = None
|
||||
templates: tuple[str, ...] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ListedFacts:
|
||||
route: str
|
||||
entry: Entry | None = None
|
||||
exhausted: bool = False
|
||||
template_index: int = 0
|
||||
compiled: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Failure:
|
||||
kind: Literal["TypeError", "AttributeError"]
|
||||
message: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EntryDecision:
|
||||
action: Literal["ReadEntry", "ReadTemplate", "Allow", "Deny", "Keep", "Expand", "Failure"]
|
||||
template_index: int
|
||||
failure: Failure | None
|
||||
|
||||
|
||||
_ROLE: Final = TypeAdapter(RoleFacts)
|
||||
_ROLE_RESULT: Final = TypeAdapter(RoleDecision)
|
||||
_API: Final = TypeAdapter(ApiFacts)
|
||||
_API_RESULT: Final = TypeAdapter[ApiDecision](ApiDecision)
|
||||
_JWT: Final = TypeAdapter(JwtFacts)
|
||||
_JWT_RESULT: Final = TypeAdapter[JwtDecision](JwtDecision)
|
||||
_INSIDE: Final = TypeAdapter(InsideFacts)
|
||||
_BOOL: Final = TypeAdapter(bool)
|
||||
_ENTRY: Final = TypeAdapter(Entry)
|
||||
_LISTED: Final = TypeAdapter(ListedFacts)
|
||||
_ENTRY_RESULT: Final = TypeAdapter(EntryDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def role(facts: RoleFacts) -> RoleDecision:
|
||||
return _ROLE_RESULT.validate_python(_native.request_auth_route_role(_ROLE.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes frozen facts
|
||||
|
||||
|
||||
@native_boundary
|
||||
def api(facts: ApiFacts) -> ApiDecision:
|
||||
return _API_RESULT.validate_python(_native.request_auth_route_role_api(_API.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes frozen facts
|
||||
|
||||
|
||||
@native_boundary
|
||||
def jwt(facts: JwtFacts) -> JwtDecision:
|
||||
return _JWT_RESULT.validate_python(_native.request_auth_route_role_jwt(_JWT.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes frozen facts
|
||||
|
||||
|
||||
@native_boundary
|
||||
def inside(facts: InsideFacts) -> bool:
|
||||
return _BOOL.validate_python(_native.request_auth_route_role_inside(_INSIDE.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes frozen facts
|
||||
|
||||
|
||||
@native_boundary
|
||||
def listed(facts: ListedFacts) -> EntryDecision:
|
||||
return _ENTRY_RESULT.validate_python(_native.request_auth_route_role_listed(_LISTED.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes frozen facts
|
||||
|
||||
|
||||
@native_boundary
|
||||
def expand(facts: Entry) -> EntryDecision:
|
||||
return _ENTRY_RESULT.validate_python(_native.request_auth_route_role_expand(_ENTRY.dump_python(facts))) # pyright: ignore[reportAny] # Pydantic serializes frozen facts
|
||||
146
litellm/_auth/routes.py
Normal file
146
litellm/_auth/routes.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
from collections.abc import Mapping
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Final, Literal, TypeAlias
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RouteCatalog:
|
||||
groups: Mapping[str, tuple[str, ...]] = field(default_factory=dict[str, tuple[str, ...]])
|
||||
viewer_blocked: tuple[str, ...] = ()
|
||||
viewer_write_blocked: tuple[str, ...] = ()
|
||||
self_credentials: tuple[tuple[str, str], ...] = ()
|
||||
managed_models: tuple[str, ...] = ()
|
||||
managed_paths: tuple[str, ...] = ()
|
||||
managed_realtime: tuple[str, ...] = ()
|
||||
managed_live: tuple[str, ...] = ()
|
||||
managed_mcp: tuple[str, ...] = ()
|
||||
unbilled: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RouteContext:
|
||||
route: str
|
||||
method: str | None = None
|
||||
catalog: RouteCatalog = field(default_factory=RouteCatalog)
|
||||
registered: bool = False
|
||||
registered_auth: bool | None = None
|
||||
configured_eu_jev: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PassThroughFacts:
|
||||
key_allowed: tuple[str, ...] = ()
|
||||
team_allowed: tuple[str, ...] = ()
|
||||
denied: tuple[str, ...] = ()
|
||||
forwarded: tuple[str, ...] = ()
|
||||
jwt_team_routes: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VirtualRouteFacts:
|
||||
context: RouteContext
|
||||
allowed_routes: tuple[str, ...] | None
|
||||
passthrough: PassThroughFacts = field(default_factory=PassThroughFacts)
|
||||
password_reset_required: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ViewerFacts:
|
||||
context: RouteContext
|
||||
request_keys: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NonAdminFacts:
|
||||
viewer: ViewerFacts
|
||||
role: str | None = None
|
||||
custom_admin_routes: tuple[str, ...] = ()
|
||||
premium: bool = False
|
||||
query_user_id: str | None = None
|
||||
token_user_id: str | None = None
|
||||
permissions: tuple[str, ...] = ()
|
||||
organization_ids: tuple[str, ...] = ()
|
||||
admin_organization_ids: tuple[str, ...] = ()
|
||||
team_service_account: bool = False
|
||||
allowed_routes: tuple[str, ...] | None = None
|
||||
passthrough: PassThroughFacts = field(default_factory=PassThroughFacts)
|
||||
|
||||
|
||||
RouteDenial: TypeAlias = Literal[
|
||||
"VirtualRoute",
|
||||
"PasswordReset",
|
||||
"PassThrough",
|
||||
"DeniedPassThrough",
|
||||
"CustomAdmin",
|
||||
"UserInfo",
|
||||
"AdminOnly",
|
||||
"ViewerInference",
|
||||
"ViewerWrite",
|
||||
"ViewerParameter",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RouteDecision:
|
||||
allowed: bool
|
||||
denial: RouteDenial | None = None
|
||||
detail: str | None = None
|
||||
|
||||
|
||||
_DECISION: Final = TypeAdapter(RouteDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def match_route(
|
||||
route: str,
|
||||
patterns: tuple[str, ...],
|
||||
mode: Literal["Access", "Template", "Prefix", "Wildcard", "Denied", "Jwt"] = "Access",
|
||||
) -> bool:
|
||||
return _DECISION.validate_python(
|
||||
_native.request_auth_route_access({"Match": {"route": route, "patterns": patterns, "mode": mode}})
|
||||
).allowed
|
||||
|
||||
|
||||
@native_boundary
|
||||
def classify_route(
|
||||
context: RouteContext, kind: Literal["Llm", "Unbilled", "Discovery", "SelfCredential", "Azure", "Managed"]
|
||||
) -> bool:
|
||||
return _DECISION.validate_python(
|
||||
_native.request_auth_route_access({"Classify": {"context": asdict(context), "kind": kind}})
|
||||
).allowed
|
||||
|
||||
|
||||
@native_boundary
|
||||
def virtual_route(facts: VirtualRouteFacts) -> RouteDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_route_access({"Virtual": asdict(facts)}))
|
||||
|
||||
|
||||
@native_boundary
|
||||
def non_admin_route(facts: NonAdminFacts) -> RouteDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_route_access({"NonAdmin": asdict(facts)}))
|
||||
|
||||
|
||||
@native_boundary
|
||||
def viewer_route(facts: ViewerFacts) -> RouteDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_route_access({"Viewer": asdict(facts)}))
|
||||
|
||||
|
||||
@native_boundary
|
||||
def passthrough_route(context: RouteContext, facts: PassThroughFacts, *, required: bool = False) -> RouteDecision:
|
||||
return _DECISION.validate_python(
|
||||
_native.request_auth_route_access(
|
||||
{"PassThrough": {"context": asdict(context), "facts": asdict(facts), "required": required}}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def denied_passthrough_route(route: str, facts: PassThroughFacts) -> RouteDecision:
|
||||
return _DECISION.validate_python(
|
||||
_native.request_auth_route_access({"DeniedPassThrough": {"route": route, "facts": asdict(facts)}})
|
||||
)
|
||||
|
|
@ -1,88 +1,23 @@
|
|||
import os
|
||||
from typing import Final, Literal, TypeAlias
|
||||
|
||||
from litellm._auth import credential
|
||||
from litellm._auth import rust_core
|
||||
from litellm._auth.credential import PresentedKeys, SelectedKey, WebSocketDenial, WebSocketKeys
|
||||
from litellm._auth.decision import AuthCore
|
||||
from litellm._auth.python_core import PythonCore
|
||||
|
||||
Backend: TypeAlias = Literal["python", "rust", "shadow"]
|
||||
|
||||
|
||||
def backend() -> Backend:
|
||||
selected: Final = os.environ.get("LITELLM_AUTH_CORE", "rust")
|
||||
match selected:
|
||||
case "python" | "rust" | "shadow":
|
||||
return selected
|
||||
case _:
|
||||
raise ValueError("LITELLM_AUTH_CORE must be python, rust or shadow")
|
||||
|
||||
|
||||
def get_auth_core(selected: Backend | None = None) -> AuthCore:
|
||||
match selected if selected is not None else backend():
|
||||
case "python":
|
||||
return PythonCore()
|
||||
case "rust":
|
||||
from litellm._auth.rust_core import RustCore
|
||||
|
||||
return RustCore()
|
||||
case "shadow":
|
||||
from litellm._auth.shadow import ShadowCore
|
||||
|
||||
return ShadowCore()
|
||||
def get_auth_core() -> AuthCore:
|
||||
return rust_core.RustCore()
|
||||
|
||||
|
||||
def scheme_credential(value: str) -> str | None:
|
||||
selected: Final = backend()
|
||||
if selected == "python":
|
||||
return credential.scheme_credential(value)
|
||||
from litellm._auth import rust_core
|
||||
|
||||
result: Final = rust_core.scheme_credential(value)
|
||||
if selected == "shadow":
|
||||
from litellm._auth.shadow import compare
|
||||
|
||||
compare(result, credential.scheme_credential(value))
|
||||
return result
|
||||
return rust_core.scheme_credential(value)
|
||||
|
||||
|
||||
def litellm_header_token(value: str) -> str:
|
||||
selected: Final = backend()
|
||||
if selected == "python":
|
||||
return credential.litellm_header_token(value)
|
||||
from litellm._auth import rust_core
|
||||
|
||||
result: Final = rust_core.litellm_header_token(value)
|
||||
if selected == "shadow":
|
||||
from litellm._auth.shadow import compare
|
||||
|
||||
compare(result, credential.litellm_header_token(value))
|
||||
return result
|
||||
return rust_core.litellm_header_token(value)
|
||||
|
||||
|
||||
def select_api_key(keys: PresentedKeys) -> SelectedKey | None:
|
||||
selected: Final = backend()
|
||||
if selected == "python":
|
||||
return credential.select_api_key(keys)
|
||||
from litellm._auth import rust_core
|
||||
|
||||
result: Final = rust_core.select_api_key(keys)
|
||||
if selected == "shadow":
|
||||
from litellm._auth.shadow import compare
|
||||
|
||||
compare(result, credential.select_api_key(keys))
|
||||
return result
|
||||
return rust_core.select_api_key(keys)
|
||||
|
||||
|
||||
def select_websocket_api_key(keys: WebSocketKeys) -> str | WebSocketDenial:
|
||||
selected: Final = backend()
|
||||
if selected == "python":
|
||||
return credential.select_websocket_api_key(keys)
|
||||
from litellm._auth import rust_core
|
||||
|
||||
result: Final = rust_core.select_websocket_api_key(keys)
|
||||
if selected == "shadow":
|
||||
from litellm._auth.shadow import compare
|
||||
|
||||
compare(result, credential.select_websocket_api_key(keys))
|
||||
return result
|
||||
return rust_core.select_websocket_api_key(keys)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from pydantic import TypeAdapter
|
|||
|
||||
from litellm._auth import decision
|
||||
from litellm._auth.credential import KeySource, PresentedKeys, SelectedKey, WebSocketDenial, WebSocketKeys
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
Wire: TypeAlias = None | bool | int | float | str | list["Wire"] | dict[str, "Wire"]
|
||||
|
|
@ -26,6 +27,7 @@ TEAM_BUDGET: Final = TypeAdapter(decision.TeamMemberBudget)
|
|||
FALLBACK_TARGET: Final = TypeAdapter(decision.FallbackTarget)
|
||||
TEAM_ALERT: Final = TypeAdapter(decision.AlertTeamMemberBudget)
|
||||
MEMBER_FALLBACK: Final = TypeAdapter(decision.VetTeamMemberFallback)
|
||||
MODEL_DENIAL: Final = TypeAdapter(decision.ModelAccessDenied)
|
||||
|
||||
_PUBLIC_TYPES: Final[Mapping[str, Callable[..., object]]] = {
|
||||
cls.__name__: cls
|
||||
|
|
@ -49,6 +51,7 @@ _PUBLIC_TYPES: Final[Mapping[str, Callable[..., object]]] = {
|
|||
decision.KeyBudgetExceeded,
|
||||
decision.KeyWithoutToken,
|
||||
decision.ModelBudgetExceeded,
|
||||
decision.ModelAccessDenied,
|
||||
decision.Allow,
|
||||
decision.Deny,
|
||||
decision.ResolveEndUser,
|
||||
|
|
@ -58,6 +61,7 @@ _PUBLIC_TYPES: Final[Mapping[str, Callable[..., object]]] = {
|
|||
decision.PrepareKey,
|
||||
decision.InspectKey,
|
||||
decision.EnforceModelAccess,
|
||||
decision.PrefetchAuthObjects,
|
||||
decision.LoadKeyOwner,
|
||||
decision.CheckBudgetExemption,
|
||||
decision.ReadTeamMemberBudget,
|
||||
|
|
@ -82,21 +86,22 @@ _PUBLIC_TYPES: Final[Mapping[str, Callable[..., object]]] = {
|
|||
)
|
||||
}
|
||||
_ANSWER_TYPES: Final = {
|
||||
"EnforceModelAccess": "ModelAccess",
|
||||
"ResolvePresentedKey": "Presented",
|
||||
"InspectKey": "Key",
|
||||
"LoadKeyOwner": "Owner",
|
||||
"CheckBudgetExemption": "Bool",
|
||||
"CheckBudgetExemption": "Exemption",
|
||||
"ReadTeamMemberBudget": "TeamBudget",
|
||||
"ReadKeySpend": "Spend",
|
||||
"ReadProxySpend": "ProxySpend",
|
||||
"ReadModelBudgetScope": "ModelScope",
|
||||
"CheckKeyModelBudget": "Exceeded",
|
||||
"AuthorizeBudgetFallback": "Bool",
|
||||
"CheckUserModelBudget": "Exceeded",
|
||||
"CheckKeyModelBudget": "ModelBudgetData",
|
||||
"AuthorizeBudgetFallback": "FallbackAccess",
|
||||
"CheckUserModelBudget": "ModelBudgetData",
|
||||
"ReadTeamMemberModelBudgetScope": "TeamScope",
|
||||
"CheckTeamMemberModelBudget": "Exceeded",
|
||||
"CheckTeamMemberModelBudget": "ModelBudgetData",
|
||||
"ReadTeamMemberFallbacks": "Targets",
|
||||
"VetTeamMemberFallback": "Bool",
|
||||
"VetTeamMemberFallback": "Candidate",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -104,6 +109,7 @@ class _Projection:
|
|||
def __init__(self) -> None:
|
||||
self.datetimes: tuple[datetime, ...] = ()
|
||||
|
||||
@native_boundary
|
||||
def encode(self, value: object) -> Wire:
|
||||
if isinstance(value, datetime):
|
||||
offset: Final = value.utcoffset()
|
||||
|
|
@ -145,6 +151,8 @@ class _Projection:
|
|||
raise TypeError("invalid auth datetime reference")
|
||||
return self.datetimes[reference]
|
||||
kind: Final = data.get("kind")
|
||||
if kind == "ModelAccessDenied":
|
||||
return MODEL_DENIAL.validate_python({"decision": data["decision"]})
|
||||
if kind is None:
|
||||
return {key: self.decode(item) for key, item in data.items()}
|
||||
if not isinstance(kind, str):
|
||||
|
|
@ -152,6 +160,7 @@ class _Projection:
|
|||
constructor: Final = public_type(kind)
|
||||
return constructor(**{key: self.decode(item) for key, item in data.items() if key != "kind"})
|
||||
|
||||
@native_boundary
|
||||
def step(self, result: tuple[Mapping[str, object], _native.NativeAuthContinuation | None]) -> CoreStep:
|
||||
data, continuation = result
|
||||
kind: Final = data["kind"]
|
||||
|
|
@ -160,6 +169,7 @@ class _Projection:
|
|||
if continuation is None:
|
||||
return CORE_STEP.validate_python(self.decode(data))
|
||||
|
||||
@native_boundary
|
||||
def resume(*answers: object) -> CoreStep:
|
||||
answer: Final = {
|
||||
"kind": _ANSWER_TYPES.get(kind, "Unit"),
|
||||
|
|
@ -184,11 +194,22 @@ def field_value(value: object, name: str) -> object:
|
|||
return cast(object, getattr(value, name)) # cast-ok: dataclass fields are discovered dynamically
|
||||
|
||||
|
||||
@native_boundary
|
||||
def encode_facts(value: object) -> Wire:
|
||||
return _Projection().encode(value)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def expired(expires: datetime, now: datetime) -> bool:
|
||||
return _native.request_auth_expired(encode_facts(expires), encode_facts(now))
|
||||
|
||||
|
||||
def public_type(kind: str) -> Callable[..., object]:
|
||||
return _PUBLIC_TYPES[kind]
|
||||
|
||||
|
||||
class RustCore:
|
||||
@native_boundary
|
||||
def start(self, facts: decision.RequestFacts, settings: decision.Settings, now: datetime) -> decision.Step:
|
||||
projection: Final = _Projection()
|
||||
return AUTH_STEP.validate_python(
|
||||
|
|
@ -199,6 +220,7 @@ class RustCore:
|
|||
),
|
||||
)
|
||||
|
||||
@native_boundary
|
||||
def start_resolved_key(
|
||||
self, facts: decision.RequestFacts, settings: decision.Settings, now: datetime
|
||||
) -> decision.Step:
|
||||
|
|
@ -211,16 +233,20 @@ class RustCore:
|
|||
),
|
||||
)
|
||||
|
||||
@native_boundary
|
||||
def check_model_budgets(self) -> decision.Step:
|
||||
return AUTH_STEP.validate_python(_Projection().step(_native.request_auth_budgets("Key")))
|
||||
|
||||
@native_boundary
|
||||
def check_user_model_budgets(self) -> decision.Step:
|
||||
return AUTH_STEP.validate_python(_Projection().step(_native.request_auth_budgets("User")))
|
||||
|
||||
@native_boundary
|
||||
def check_team_member_model_budgets(self) -> decision.TeamMemberStep:
|
||||
return MEMBER_STEP.validate_python(_Projection().step(_native.request_auth_budgets("TeamMember")))
|
||||
|
||||
|
||||
@native_boundary
|
||||
def select_api_key(keys: PresentedKeys) -> SelectedKey | None:
|
||||
selected: Final = _native.request_auth_select_api_key(_Projection().encode(keys))
|
||||
if selected is None:
|
||||
|
|
@ -228,14 +254,17 @@ def select_api_key(keys: PresentedKeys) -> SelectedKey | None:
|
|||
return SelectedKey(source=KeySource(selected["source"]), presented=selected["presented"], token=selected["token"])
|
||||
|
||||
|
||||
@native_boundary
|
||||
def select_websocket_api_key(keys: WebSocketKeys) -> str | WebSocketDenial:
|
||||
selected: Final = _native.request_auth_select_websocket_api_key(_Projection().encode(keys))
|
||||
return WebSocketDenial(selected["value"]) if selected["kind"] == "Denial" else selected["value"]
|
||||
|
||||
|
||||
@native_boundary
|
||||
def scheme_credential(value: str) -> str | None:
|
||||
return _native.request_auth_scheme_credential(value)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def litellm_header_token(value: str) -> str:
|
||||
return _native.request_auth_litellm_header_token(value)
|
||||
|
|
|
|||
129
litellm/_auth/spend_counter_policy.py
Normal file
129
litellm/_auth/spend_counter_policy.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Annotated, Final, Literal, TypeAlias
|
||||
|
||||
from pydantic import Field, TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm._auth.rust_core import encode_facts
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Spend:
|
||||
current: float
|
||||
verified: bool
|
||||
fallback: float
|
||||
limit: float | None
|
||||
window: bool
|
||||
fallback_authoritative: bool
|
||||
strict_before: bool | None = None
|
||||
authoritative_read: bool = False
|
||||
authoritative: float | None = None
|
||||
strict_after: bool | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ThrottleKey:
|
||||
counter_key: str
|
||||
token_present: bool
|
||||
token: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Overflow:
|
||||
current: float
|
||||
limit: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OverBudget:
|
||||
throttled: bool
|
||||
current: float
|
||||
limit: float
|
||||
reservation: float
|
||||
strict: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReservationFailure:
|
||||
strict: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReservationGroups:
|
||||
currents: tuple[float, ...]
|
||||
limits: tuple[float, ...]
|
||||
reservation: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SpendValue:
|
||||
kind: Literal["Value", "Repair"]
|
||||
spend: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Action:
|
||||
kind: Literal[
|
||||
"ReadStrictBefore",
|
||||
"ReadStrictAfter",
|
||||
"ReadFloor",
|
||||
"Unverifiable",
|
||||
"ReadThrottle",
|
||||
"Skip",
|
||||
"Release",
|
||||
"CheckOverflow",
|
||||
"Unavailable",
|
||||
"Compatible",
|
||||
"Inconsistent",
|
||||
"RejectResize",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Rejected:
|
||||
kind: Literal["Reject"]
|
||||
current: float
|
||||
estimate: float | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Resize:
|
||||
kind: Literal["Resize"]
|
||||
cost: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Groups:
|
||||
kind: Literal["ReservationGroups"]
|
||||
groups: tuple[tuple[int, ...], ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Adjustment:
|
||||
current: float | None
|
||||
adjustment: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResizeConsistency:
|
||||
inconsistent: bool
|
||||
reseed: bool
|
||||
|
||||
|
||||
Decision: TypeAlias = Annotated[SpendValue | Action | Rejected | Resize | Groups, Field(discriminator="kind")]
|
||||
_DECISION: Final = TypeAdapter[Decision](Decision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def decide(
|
||||
facts: Spend
|
||||
| ThrottleKey
|
||||
| Overflow
|
||||
| OverBudget
|
||||
| ReservationFailure
|
||||
| ReservationGroups
|
||||
| Adjustment
|
||||
| ResizeConsistency,
|
||||
) -> Decision:
|
||||
return _DECISION.validate_python(_native.request_auth_spend_counter_policy(encode_facts(facts)))
|
||||
34
litellm/_auth/team_aliases.py
Normal file
34
litellm/_auth/team_aliases.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from collections.abc import Mapping
|
||||
from dataclasses import asdict, dataclass, replace
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TeamAliasFacts:
|
||||
requested: tuple[str, ...]
|
||||
aliases: Mapping[str, str]
|
||||
team_id: str | None
|
||||
router_present: bool
|
||||
names: tuple[tuple[str, ...], ...] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TeamAliasDecision:
|
||||
kind: Literal["ReadNames", "Complete"]
|
||||
count: int = 0
|
||||
models: tuple[str, ...] = ()
|
||||
|
||||
|
||||
_DECISION: Final = TypeAdapter(TeamAliasDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def team_aliases(facts: TeamAliasFacts) -> TeamAliasDecision:
|
||||
return _DECISION.validate_python(
|
||||
_native.request_auth_team_aliases(asdict(replace(facts, aliases=dict(facts.aliases))))
|
||||
)
|
||||
30
litellm/_auth/team_override.py
Normal file
30
litellm/_auth/team_override.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from dataclasses import asdict, dataclass
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm._auth.fallback_access import GroupedModelScope
|
||||
from litellm._auth.model_access import ModelAccessDecision, ModelAccessFacts
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TeamOverrideFacts:
|
||||
team: GroupedModelScope | None = None
|
||||
assigned_models: tuple[str, ...] | None = None
|
||||
assigned_scope: ModelAccessFacts | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TeamOverrideDecision:
|
||||
kind: Literal["Allow", "Deny", "ReadTeam", "ReadTeamGroups", "ReadAssignedGroups", "ReadAssignedScope"]
|
||||
denial: ModelAccessDecision | None = None
|
||||
|
||||
|
||||
_DECISION: Final = TypeAdapter(TeamOverrideDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def team_override(facts: TeamOverrideFacts) -> TeamOverrideDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_team_override(asdict(facts)))
|
||||
53
litellm/_auth/unpriced_models.py
Normal file
53
litellm/_auth/unpriced_models.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
from dataclasses import asdict, dataclass
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._auth.failure import native_boundary
|
||||
from litellm.rust_bridge import _native
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PriceMetric:
|
||||
name: str
|
||||
scalar: float | None = None
|
||||
nested: tuple[float | None, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PriceEntry:
|
||||
tiered_present: bool
|
||||
metrics: tuple[PriceMetric, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeploymentPricing:
|
||||
parameter_keys: tuple[str, ...]
|
||||
has_id: bool
|
||||
info_loaded: bool = False
|
||||
info: PriceEntry | None = None
|
||||
cost_keys: tuple[str, ...] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UnpricedModelFacts:
|
||||
model: str | None
|
||||
router_present: bool
|
||||
group_found: bool | None = None
|
||||
deployments: tuple[DeploymentPricing, ...] | None = None
|
||||
cost_deployments: tuple[DeploymentPricing, ...] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UnpricedModelDecision:
|
||||
kind: Literal["Complete", "ReadGroup", "ReadDeployments", "ReadInfo", "ReadCostDeployments", "ReadCost"]
|
||||
unpriced: bool = False
|
||||
index: int = 0
|
||||
|
||||
|
||||
_DECISION: Final = TypeAdapter(UnpricedModelDecision)
|
||||
|
||||
|
||||
@native_boundary
|
||||
def unpriced_model(facts: UnpricedModelFacts) -> UnpricedModelDecision:
|
||||
return _DECISION.validate_python(_native.request_auth_unpriced_model(asdict(facts)))
|
||||
|
|
@ -1,11 +1,7 @@
|
|||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Final, TypeAlias
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._types import LiteLLM_AccessGroupTable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -31,13 +27,6 @@ class AgentAccessGroupCeiling:
|
|||
CeilingResolver: TypeAlias = Callable[[str], Awaitable[AgentAccessGroupCeiling | None]]
|
||||
|
||||
|
||||
class _MissingGroup:
|
||||
__slots__ = ()
|
||||
|
||||
|
||||
_MISSING: Final = _MissingGroup()
|
||||
|
||||
|
||||
async def _registry_access_group_ids(agent_id: str) -> AccessGroupIds:
|
||||
from litellm.proxy.common_utils.registry_read_through import get_agent_with_read_through
|
||||
|
||||
|
|
@ -46,29 +35,9 @@ async def _registry_access_group_ids(agent_id: str) -> AccessGroupIds:
|
|||
|
||||
|
||||
async def _load_access_group(access_group_id: str, *, check_db_only: bool = False) -> LoadedAccessGroup:
|
||||
from litellm.proxy.auth.auth_checks import AccessGroupNotFoundError, get_access_object
|
||||
from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache
|
||||
from litellm.proxy.auth.agent_ceiling_adapter import load_agent_access_group
|
||||
|
||||
if prisma_client is None:
|
||||
verbose_proxy_logger.warning("Agent access group %s cannot be loaded without a DB", access_group_id)
|
||||
return None
|
||||
try:
|
||||
return await get_access_object(
|
||||
access_group_id=access_group_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=check_db_only,
|
||||
)
|
||||
except AccessGroupNotFoundError:
|
||||
raise
|
||||
except HTTPException as e:
|
||||
if check_db_only:
|
||||
raise
|
||||
verbose_proxy_logger.warning(
|
||||
"Agent access group %s could not be loaded, treating it as empty: %s", access_group_id, e.detail
|
||||
)
|
||||
return None
|
||||
return await load_agent_access_group(access_group_id, check_db_only=check_db_only)
|
||||
|
||||
|
||||
async def resolve_agent_access_group_ceiling(
|
||||
|
|
@ -82,31 +51,10 @@ async def resolve_agent_access_group_ceiling(
|
|||
provably does not exist is dropped from the ceiling, it grants and bounds nothing, while a group
|
||||
that exists but could not be read stays named and loads as ``None``. The ceiling itself stays once
|
||||
any group is named, so an agent whose every group is missing is still capped to nothing."""
|
||||
named_ids: Final = await load_access_group_ids(agent_id)
|
||||
if not named_ids:
|
||||
return None
|
||||
from litellm.proxy.auth.agent_ceiling_adapter import project_agent_ceiling, read_agent_ceiling_facts
|
||||
|
||||
from litellm.proxy.auth.auth_checks import AccessGroupNotFoundError
|
||||
|
||||
async def load(group_id: str) -> LoadedAccessGroup | _MissingGroup:
|
||||
try:
|
||||
return await (
|
||||
_load_access_group(group_id, check_db_only=True) if check_db_only else load_access_group(group_id)
|
||||
)
|
||||
except AccessGroupNotFoundError:
|
||||
return _MISSING
|
||||
|
||||
results: Final = await asyncio.gather(*(load(group_id) for group_id in named_ids))
|
||||
access_group_ids: Final = tuple(
|
||||
group_id for group_id, result in zip(named_ids, results, strict=True) if not isinstance(result, _MissingGroup)
|
||||
)
|
||||
groups: Final = tuple(result for result in results if isinstance(result, LiteLLM_AccessGroupTable))
|
||||
return AgentAccessGroupCeiling(
|
||||
access_group_ids=access_group_ids,
|
||||
models=frozenset(model for group in groups for model in group.access_model_names),
|
||||
mcp_server_ids=frozenset(server_id for group in groups for server_id in group.access_mcp_server_ids),
|
||||
agent_ids=frozenset(target_id for group in groups for target_id in group.access_agent_ids),
|
||||
access_groups=groups,
|
||||
return project_agent_ceiling(
|
||||
await read_agent_ceiling_facts(agent_id, load_access_group_ids, load_access_group, check_db_only=check_db_only)
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,14 +7,15 @@ Follows the same pattern as MCP permission handling.
|
|||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from itertools import chain
|
||||
from dataclasses import dataclass, replace
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Protocol, TypeAlias
|
||||
from typing import Final, Protocol, TypeAlias, assert_never
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from litellm._auth import agent_access, agent_grants, managed
|
||||
from litellm._auth.failure import NativeAuthFailure
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.proxy._types import (
|
||||
UI_TEAM_ID,
|
||||
|
|
@ -30,6 +31,7 @@ from litellm.proxy.agent_endpoints.auth.agent_access_groups import (
|
|||
from litellm.proxy.agent_endpoints.auth.agent_caller import agent_caller_auth
|
||||
from litellm.proxy.agent_endpoints.auth.managed_authorization import managed_agent_policy
|
||||
from litellm.proxy.mcp_server.ui_session_utils import build_effective_auth_contexts
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
from litellm.repositories.table_repositories import AgentsRepository
|
||||
from litellm.types.agents import AgentResponse
|
||||
|
||||
|
|
@ -58,6 +60,8 @@ class AgentAccessLookup(Protocol):
|
|||
def __call__(self, user_api_key_auth: UserAPIKeyAuth | None, /, *, strict: bool) -> Awaitable[AgentAccess]: ...
|
||||
|
||||
|
||||
_AGENT_GROUPS: Final = TypeAdapter(tuple[str, ...])
|
||||
|
||||
_DEFAULT_DENY_FLAG: Final = TypeAdapter(bool)
|
||||
|
||||
|
||||
|
|
@ -70,13 +74,6 @@ def agent_access_default_deny() -> bool:
|
|||
return True
|
||||
|
||||
|
||||
def _is_proxy_admin(user_api_key_auth: UserAPIKeyAuth | None) -> bool:
|
||||
return user_api_key_auth is not None and user_api_key_auth.user_role in (
|
||||
LitellmUserRoles.PROXY_ADMIN,
|
||||
LitellmUserRoles.PROXY_ADMIN.value,
|
||||
)
|
||||
|
||||
|
||||
def _to_stable_ids(agent_ids: frozenset[str]) -> frozenset[str]:
|
||||
from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry
|
||||
|
||||
|
|
@ -89,24 +86,20 @@ def _restricted_ids(access: AgentAccess) -> frozenset[str] | None:
|
|||
return _to_stable_ids(access.agent_ids)
|
||||
|
||||
|
||||
def _union_agent_access(accesses: Sequence[AgentAccess]) -> AgentAccess:
|
||||
if accesses and all(isinstance(access, UnrestrictedAgentAccess) for access in accesses):
|
||||
return UnrestrictedAgentAccess()
|
||||
return RestrictedAgentAccess(
|
||||
frozenset(
|
||||
chain.from_iterable(access.agent_ids for access in accesses if isinstance(access, RestrictedAgentAccess))
|
||||
)
|
||||
)
|
||||
def _project_agent_grants(decision: managed.ManagedDecision | agent_grants.Decision) -> AgentAccess:
|
||||
return UnrestrictedAgentAccess() if decision.grants is None else RestrictedAgentAccess(frozenset(decision.grants))
|
||||
|
||||
|
||||
def _intersect_agent_access(key_access: AgentAccess, team_access: AgentAccess) -> AgentAccess:
|
||||
key_ids: Final = _restricted_ids(key_access)
|
||||
team_ids: Final = _restricted_ids(team_access)
|
||||
if key_ids is None:
|
||||
return UnrestrictedAgentAccess() if team_ids is None else RestrictedAgentAccess(team_ids)
|
||||
if team_ids is None:
|
||||
return RestrictedAgentAccess(key_ids)
|
||||
return RestrictedAgentAccess(key_ids & team_ids)
|
||||
return _project_agent_grants(
|
||||
managed.decide(
|
||||
managed.IntersectGrants(
|
||||
tuple(key_ids) if key_ids is not None else None, tuple(team_ids) if team_ids is not None else None
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class AgentRequestHandler:
|
||||
|
|
@ -135,27 +128,18 @@ class AgentRequestHandler:
|
|||
) -> AgentAccess:
|
||||
"""Agents the key may reach: key and team grants, intersected with the agent's access group ceiling
|
||||
and, for an agent key acting on behalf of an invoking user, with that user's team grants."""
|
||||
if managed_agent_policy(user_api_key_auth) is not None:
|
||||
return await _managed_actor_agent_access(user_api_key_auth)
|
||||
key_team_access: Final = await AgentRequestHandler.resolve_key_team_agent_access(
|
||||
return await _read_resolved_agent_grants(
|
||||
user_api_key_auth,
|
||||
strict=strict,
|
||||
lookup_key_access=lookup_key_access,
|
||||
lookup_team_access=lookup_team_access,
|
||||
resolve_ceiling,
|
||||
default_deny,
|
||||
lookup_key_access,
|
||||
lookup_team_access,
|
||||
agent_grants.Resolve(
|
||||
managed=managed_agent_policy(user_api_key_auth) is not None,
|
||||
strict=strict,
|
||||
role=user_api_key_auth.user_role if user_api_key_auth is not None else None,
|
||||
),
|
||||
)
|
||||
deny_undefined: Final = strict or (not _is_proxy_admin(user_api_key_auth) and default_deny())
|
||||
if deny_undefined and isinstance(key_team_access, UnrestrictedAgentAccess):
|
||||
return RestrictedAgentAccess(frozenset())
|
||||
caller_access: Final = await AgentRequestHandler.agent_caller_access(user_api_key_auth, strict=strict)
|
||||
own_access: Final = _intersect_agent_access(key_team_access, caller_access)
|
||||
agent_ceiling: Final = await AgentRequestHandler._agent_access_group_ceiling(
|
||||
user_api_key_auth, resolve_ceiling, strict=strict
|
||||
)
|
||||
if agent_ceiling is None:
|
||||
return own_access
|
||||
if isinstance(own_access, UnrestrictedAgentAccess):
|
||||
return RestrictedAgentAccess(agent_ceiling)
|
||||
return RestrictedAgentAccess(own_access.agent_ids & agent_ceiling)
|
||||
|
||||
@staticmethod
|
||||
async def agent_caller_access(user_api_key_auth: UserAPIKeyAuth | None, *, strict: bool = False) -> AgentAccess:
|
||||
|
|
@ -181,11 +165,14 @@ class AgentRequestHandler:
|
|||
)
|
||||
key_access: Final = await key_lookup(user_api_key_auth, strict=strict)
|
||||
team_access: Final = await team_lookup(user_api_key_auth, strict=strict)
|
||||
except NativeAuthFailure:
|
||||
raise
|
||||
except Exception as e:
|
||||
if strict:
|
||||
failure: Final = agent_grants.decide(agent_grants.Failure(strict, closed=False))
|
||||
if failure.action == "Unavailable":
|
||||
raise HTTPException(503, "Agent invocation policy is unavailable") from e
|
||||
verbose_logger.warning("Failed to get allowed agents: %s", e)
|
||||
return UnrestrictedAgentAccess()
|
||||
return _project_agent_grants(failure)
|
||||
return _intersect_agent_access(key_access, team_access)
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -212,71 +199,8 @@ class AgentRequestHandler:
|
|||
user_api_key_auth: UserAPIKeyAuth | None = None,
|
||||
resolve_ceiling: CeilingResolver = resolve_agent_access_group_ceiling,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a specific agent is allowed for the given user/key.
|
||||
|
||||
Args:
|
||||
agent_id: The agent ID to check
|
||||
user_api_key_auth: User authentication info
|
||||
|
||||
Returns:
|
||||
bool: True if agent is allowed, False otherwise
|
||||
"""
|
||||
from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry
|
||||
from litellm.proxy.agent_endpoints.identity_store import AgentIdentityStore
|
||||
from litellm.proxy.agent_endpoints.managed_identity import raise_identity_failure
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
from litellm.types.proxy.agent_identity import AgentIdentityFailure
|
||||
|
||||
registered: Final = global_agent_registry.get_agent_by_id(agent_id)
|
||||
registry_managed: Final = isinstance(registered, AgentResponse) and registered.identity_managed
|
||||
if registry_managed or prisma_client is not None:
|
||||
target: Final = await AgentIdentityStore.from_client(prisma_client).agent(agent_id)
|
||||
if isinstance(target, AgentIdentityFailure):
|
||||
raise_identity_failure(target)
|
||||
elif target is None and registry_managed:
|
||||
return False
|
||||
elif isinstance(target, AgentResponse) and target.identity_managed:
|
||||
if (
|
||||
not target.enabled
|
||||
or target.identity is None
|
||||
or not target.identity.active
|
||||
or user_api_key_auth is None
|
||||
):
|
||||
return False
|
||||
from litellm.proxy.mcp_server.auth.user_api_key_auth_mcp import MCPRequestHandler
|
||||
|
||||
key_hash: Final = user_api_key_auth.api_key or user_api_key_auth.token
|
||||
authority: Final = (
|
||||
await MCPRequestHandler._reload_admitted_key(key_hash, check_db_only=True) # pyright: ignore[reportPrivateUsage] # the authoritative key reload has no public seam
|
||||
if key_hash
|
||||
and managed_agent_policy(user_api_key_auth) is None
|
||||
and not user_api_key_auth.is_session_token
|
||||
and not user_api_key_auth.authenticated_by_custom_auth
|
||||
else user_api_key_auth
|
||||
)
|
||||
fresh_auth: Final = authority.model_copy(
|
||||
update=MappingProxyType(
|
||||
{"requires_fresh_policy": True, "agent_caller": user_api_key_auth.agent_caller}
|
||||
)
|
||||
)
|
||||
explicit: Final = await _granted_agent_ids(
|
||||
fresh_auth,
|
||||
_strict_agent_access,
|
||||
build_effective_auth_contexts,
|
||||
)
|
||||
return target.agent_id in explicit
|
||||
|
||||
match await AgentRequestHandler.resolve_agent_access(
|
||||
user_api_key_auth,
|
||||
resolve_ceiling,
|
||||
strict=bool(user_api_key_auth is not None and user_api_key_auth.requires_fresh_policy),
|
||||
):
|
||||
case UnrestrictedAgentAccess():
|
||||
return True
|
||||
case RestrictedAgentAccess(allowed_agent_ids):
|
||||
stable_id: Final = global_agent_registry.stable_agent_id(agent_id)
|
||||
return not global_agent_registry.ids_for_agent(stable_id).isdisjoint(allowed_agent_ids)
|
||||
facts: Final = await read_agent_access_facts(agent_id, user_api_key_auth, resolve_ceiling)
|
||||
return agent_access.decide(facts) == "Allow"
|
||||
|
||||
@staticmethod
|
||||
def _get_key_object_permission(
|
||||
|
|
@ -361,29 +285,11 @@ class AgentRequestHandler:
|
|||
# 2. Fallback: get agent IDs from key's access_group_ids (unified access groups)
|
||||
key_access_group_ids: Final = tuple(user_api_key_auth.access_group_ids or ())
|
||||
|
||||
if not direct_agents and not declared_access_groups and not key_access_group_ids:
|
||||
return UnrestrictedAgentAccess()
|
||||
|
||||
access_group_agents: Final = (
|
||||
tuple(
|
||||
await AgentRequestHandler._get_agents_from_access_groups(
|
||||
declared_access_groups, check_db_only=strict
|
||||
)
|
||||
)
|
||||
if declared_access_groups
|
||||
else ()
|
||||
return await _read_declared_grants(
|
||||
managed.DeclaredGrants(direct_agents, declared_access_groups, key_access_group_ids), strict
|
||||
)
|
||||
unified_agents: Final = (
|
||||
tuple(
|
||||
await AgentRequestHandler._get_unified_access_group_agents(
|
||||
key_access_group_ids, check_db_only=strict
|
||||
)
|
||||
)
|
||||
if key_access_group_ids
|
||||
else ()
|
||||
)
|
||||
|
||||
return RestrictedAgentAccess(frozenset(direct_agents + access_group_agents + unified_agents))
|
||||
except NativeAuthFailure:
|
||||
raise
|
||||
except Exception as e:
|
||||
if strict:
|
||||
raise HTTPException(503, "Agent invocation policy is unavailable") from e
|
||||
|
|
@ -408,6 +314,8 @@ class AgentRequestHandler:
|
|||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=strict,
|
||||
)
|
||||
except NativeAuthFailure:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001 # fail closed: an owner whose teams cannot be read grants nothing
|
||||
if strict:
|
||||
raise HTTPException(503, "Agent invocation policy is unavailable") from e
|
||||
|
|
@ -419,22 +327,16 @@ class AgentRequestHandler:
|
|||
withheld_results: Final = await asyncio.gather(
|
||||
*(AgentRequestHandler._owner_team_agent_grants(team, strict=strict) for team in owner_teams.withheld)
|
||||
)
|
||||
if any(isinstance(access, AgentPolicyUnavailable) for access in team_results):
|
||||
decision: Final = agent_grants.decide(
|
||||
agent_grants.OwnerUnion(
|
||||
tuple(_agent_grant_source(None, access) for access in team_results),
|
||||
tuple(_agent_grant_source(None, access) for access in withheld_results),
|
||||
owner_teams.unreadable,
|
||||
)
|
||||
)
|
||||
if decision.action == "Unavailable":
|
||||
raise HTTPException(503, "Agent invocation policy is unavailable")
|
||||
team_accesses: Final = tuple(
|
||||
access for access in team_results if not isinstance(access, AgentPolicyUnavailable)
|
||||
)
|
||||
withheld_restrictions: Final = tuple(
|
||||
RestrictedAgentAccess(frozenset())
|
||||
for access in withheld_results
|
||||
if isinstance(access, (AgentPolicyUnavailable, RestrictedAgentAccess))
|
||||
)
|
||||
accesses: Final = (
|
||||
(*team_accesses, *withheld_restrictions, RestrictedAgentAccess(frozenset()))
|
||||
if owner_teams.unreadable
|
||||
else (*team_accesses, *withheld_restrictions)
|
||||
)
|
||||
return _union_agent_access(accesses)
|
||||
return _project_agent_grants(decision)
|
||||
|
||||
@staticmethod
|
||||
async def personal_key_agent_billing_auth(
|
||||
|
|
@ -469,23 +371,27 @@ class AgentRequestHandler:
|
|||
)
|
||||
stable_agent_id: Final = global_agent_registry.stable_agent_id(agent_id)
|
||||
|
||||
restricted_sources: Final = tuple(
|
||||
(team, access)
|
||||
for team, access in zip(teams, accesses, strict=True)
|
||||
if isinstance(access, RestrictedAgentAccess) and stable_agent_id in _to_stable_ids(access.agent_ids)
|
||||
selection: Final = agent_grants.decide(
|
||||
agent_grants.BillingTeam(
|
||||
tuple(
|
||||
agent_grants.Source(
|
||||
team.team_id,
|
||||
tuple(_to_stable_ids(access.agent_ids))
|
||||
if isinstance(access, RestrictedAgentAccess)
|
||||
else None,
|
||||
isinstance(access, AgentPolicyUnavailable),
|
||||
)
|
||||
for team, access in zip(teams, accesses, strict=True)
|
||||
),
|
||||
stable_agent_id,
|
||||
)
|
||||
)
|
||||
unrestricted_sources: Final = (
|
||||
tuple(zip(teams, accesses, strict=True))
|
||||
if not restricted_sources
|
||||
and teams
|
||||
and all(isinstance(access, UnrestrictedAgentAccess) for access in accesses)
|
||||
else ()
|
||||
)
|
||||
sources: Final = restricted_sources or unrestricted_sources
|
||||
if not sources:
|
||||
if selection.team_id is None:
|
||||
verbose_logger.warning("No owner team grants agent %s; billing against the personal key", agent_id)
|
||||
return auth
|
||||
team: Final = min((source[0] for source in sources), key=lambda source: source.team_id)
|
||||
team: Final = next(team for team in teams if team.team_id == selection.team_id)
|
||||
except NativeAuthFailure:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001 # attribution must never fail an authorized call
|
||||
verbose_logger.warning("Failed to attribute agent %s spend to an owner team: %s", agent_id, e)
|
||||
return auth
|
||||
|
|
@ -522,6 +428,8 @@ class AgentRequestHandler:
|
|||
else None
|
||||
)
|
||||
team_auth_update: Final = await team_grant_billing_auth_update(auth, team, membership)
|
||||
except NativeAuthFailure:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001 # a chosen team's controls must survive billing lookup failures
|
||||
verbose_logger.warning(
|
||||
"Failed to load billing controls for agent %s from owner team %s: %s",
|
||||
|
|
@ -538,18 +446,27 @@ class AgentRequestHandler:
|
|||
*,
|
||||
strict: bool,
|
||||
) -> AgentAccess | AgentPolicyUnavailable:
|
||||
if team.object_permission_id is not None and team.object_permission is None:
|
||||
if strict:
|
||||
return AgentPolicyUnavailable(team_id=team.team_id)
|
||||
verbose_logger.warning("Owner team %s has an unreadable object_permission, granting nothing", team.team_id)
|
||||
return RestrictedAgentAccess(frozenset())
|
||||
decision: Final = agent_grants.decide(
|
||||
agent_grants.OwnerTeam(
|
||||
strict,
|
||||
team.object_permission_id is not None,
|
||||
team.object_permission is not None,
|
||||
)
|
||||
)
|
||||
if decision.action == "Unavailable":
|
||||
return AgentPolicyUnavailable(team_id=team.team_id)
|
||||
if decision.action == "Complete":
|
||||
return _project_agent_grants(decision)
|
||||
try:
|
||||
return await AgentRequestHandler._team_agent_grants(team, strict=strict)
|
||||
except NativeAuthFailure:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001 # per-team isolation: a team whose grants cannot be read grants nothing
|
||||
if strict:
|
||||
failure: Final = agent_grants.decide(agent_grants.Failure(strict, closed=True))
|
||||
if failure.action == "Unavailable":
|
||||
return AgentPolicyUnavailable(team_id=team.team_id)
|
||||
verbose_logger.warning("Failed to get allowed agents for owner team %s: %s", team.team_id, e)
|
||||
return RestrictedAgentAccess(frozenset())
|
||||
return _project_agent_grants(failure)
|
||||
|
||||
@staticmethod
|
||||
async def _team_agent_grants(team: LiteLLM_TeamTable, *, strict: bool) -> AgentAccess:
|
||||
|
|
@ -564,25 +481,9 @@ class AgentRequestHandler:
|
|||
# 2. Also include agents from team's access_group_ids (unified access groups)
|
||||
team_access_group_ids: Final = tuple(team.access_group_ids or ())
|
||||
|
||||
if not direct_agents and not declared_access_groups and not team_access_group_ids:
|
||||
return UnrestrictedAgentAccess()
|
||||
|
||||
access_group_agents: Final = (
|
||||
tuple(
|
||||
await AgentRequestHandler._get_agents_from_access_groups(declared_access_groups, check_db_only=strict)
|
||||
)
|
||||
if declared_access_groups
|
||||
else ()
|
||||
return await _read_declared_grants(
|
||||
managed.DeclaredGrants(direct_agents, declared_access_groups, team_access_group_ids), strict
|
||||
)
|
||||
unified_agents: Final = (
|
||||
tuple(
|
||||
await AgentRequestHandler._get_unified_access_group_agents(team_access_group_ids, check_db_only=strict)
|
||||
)
|
||||
if team_access_group_ids
|
||||
else ()
|
||||
)
|
||||
|
||||
return RestrictedAgentAccess(frozenset(direct_agents + access_group_agents + unified_agents))
|
||||
|
||||
@staticmethod
|
||||
async def _get_allowed_agents_for_team(
|
||||
|
|
@ -599,14 +500,27 @@ class AgentRequestHandler:
|
|||
Fetches the team object once and reuses it for both permission sources.
|
||||
Declared-but-empty grants stay restricted; see `get_allowed_agents_for_key`.
|
||||
"""
|
||||
if user_api_key_auth is None:
|
||||
return UnrestrictedAgentAccess()
|
||||
|
||||
if user_api_key_auth.team_id is None:
|
||||
facts: Final = agent_grants.TeamLookup(
|
||||
subject=user_api_key_auth is not None,
|
||||
team_id=user_api_key_auth.team_id if user_api_key_auth is not None else None,
|
||||
database=True,
|
||||
strict=strict,
|
||||
)
|
||||
initial: Final = agent_grants.decide(facts)
|
||||
if initial.action == "Complete":
|
||||
return _project_agent_grants(initial)
|
||||
assert user_api_key_auth is not None
|
||||
if initial.action == "ReadPersonalEligibility":
|
||||
from litellm.proxy.auth.auth_checks import personal_key_inherits_team_grants
|
||||
|
||||
if not personal_key_inherits_team_grants(user_api_key_auth):
|
||||
return UnrestrictedAgentAccess()
|
||||
personal: Final = agent_grants.decide(
|
||||
replace(
|
||||
facts,
|
||||
personal=personal_key_inherits_team_grants(user_api_key_auth),
|
||||
)
|
||||
)
|
||||
if personal.action == "Complete":
|
||||
return _project_agent_grants(personal)
|
||||
return await AgentRequestHandler._personal_key_owner_team_agent_access(user_api_key_auth, strict=strict)
|
||||
|
||||
try:
|
||||
|
|
@ -617,11 +531,13 @@ class AgentRequestHandler:
|
|||
user_api_key_cache,
|
||||
)
|
||||
|
||||
if not prisma_client:
|
||||
return RestrictedAgentAccess(frozenset()) if strict else UnrestrictedAgentAccess()
|
||||
database: Final = agent_grants.decide(replace(facts, database=bool(prisma_client)))
|
||||
if database.action == "Complete":
|
||||
return _project_agent_grants(database)
|
||||
|
||||
# Fetch the team object once for both permission sources
|
||||
team_obj: Final = await get_team_object(
|
||||
assert user_api_key_auth.team_id is not None
|
||||
team_obj: Final[LiteLLM_TeamTable | None] = await get_team_object(
|
||||
team_id=user_api_key_auth.team_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
|
|
@ -630,18 +546,23 @@ class AgentRequestHandler:
|
|||
check_db_only=strict,
|
||||
)
|
||||
|
||||
if team_obj is None:
|
||||
return RestrictedAgentAccess(frozenset()) if strict else UnrestrictedAgentAccess()
|
||||
loaded: Final = agent_grants.decide(replace(facts, loaded=True, row_present=team_obj is not None)) # pyright: ignore[reportUnnecessaryComparison] # legacy adapters may return no row
|
||||
if loaded.action == "Complete":
|
||||
return _project_agent_grants(loaded)
|
||||
assert team_obj is not None
|
||||
|
||||
return await AgentRequestHandler._team_agent_grants(team_obj, strict=strict)
|
||||
except NativeAuthFailure:
|
||||
raise
|
||||
except Exception as e:
|
||||
if strict:
|
||||
failure: Final = agent_grants.decide(agent_grants.Failure(strict, closed=False))
|
||||
if failure.action == "Unavailable":
|
||||
raise HTTPException(503, "Agent invocation policy is unavailable") from e
|
||||
# litellm-dashboard is the default UI team and will never have agents;
|
||||
# skip noisy warnings for it.
|
||||
if user_api_key_auth.team_id != UI_TEAM_ID:
|
||||
verbose_logger.warning("Failed to get allowed agents for team: %s", e)
|
||||
return UnrestrictedAgentAccess()
|
||||
return _project_agent_grants(failure)
|
||||
|
||||
@staticmethod
|
||||
def _get_config_agent_ids_for_access_groups(
|
||||
|
|
@ -650,17 +571,22 @@ class AgentRequestHandler:
|
|||
"""
|
||||
Helper to get agent_ids from config-loaded agents that match any of the given access groups.
|
||||
"""
|
||||
server_ids: Final[set[str]] = set()
|
||||
for agent in config_agents:
|
||||
agent_access_groups = getattr(agent, "agent_access_groups", None)
|
||||
if agent_access_groups:
|
||||
if any(group in agent_access_groups for group in access_groups):
|
||||
server_ids.add(agent.agent_id)
|
||||
return server_ids
|
||||
decision: Final = agent_grants.decide(
|
||||
agent_grants.GroupMatches(
|
||||
tuple(
|
||||
agent_grants.ConfiguredAgent(
|
||||
agent.agent_id, _AGENT_GROUPS.validate_python(getattr(agent, "agent_access_groups", None) or ())
|
||||
)
|
||||
for agent in config_agents
|
||||
),
|
||||
tuple(access_groups),
|
||||
)
|
||||
)
|
||||
return set(decision.grants or ())
|
||||
|
||||
@staticmethod
|
||||
async def _get_db_agent_ids_for_access_groups(
|
||||
prisma_client, access_groups: Sequence[str], *, check_db_only: bool = False
|
||||
prisma_client: PrismaClient | None, access_groups: Sequence[str], *, check_db_only: bool = False
|
||||
) -> set[str]:
|
||||
"""
|
||||
Helper to get agent_ids from DB agents that match any of the given access groups.
|
||||
|
|
@ -685,7 +611,9 @@ class AgentRequestHandler:
|
|||
"""
|
||||
from litellm.proxy.auth.auth_checks import get_agent_ids_from_access_groups
|
||||
|
||||
return await get_agent_ids_from_access_groups(access_group_ids=access_group_ids, check_db_only=check_db_only)
|
||||
return await get_agent_ids_from_access_groups(
|
||||
access_group_ids=list(access_group_ids), check_db_only=check_db_only
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _get_agents_from_access_groups(
|
||||
|
|
@ -765,6 +693,8 @@ class AgentRequestHandler:
|
|||
return []
|
||||
|
||||
return key_object_permission.agent_access_groups or []
|
||||
except NativeAuthFailure:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_logger.warning("Failed to get agent access groups for key: %s", e)
|
||||
return []
|
||||
|
|
@ -799,7 +729,7 @@ class AgentRequestHandler:
|
|||
parent_otel_span=user_api_key_auth.parent_otel_span,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
if team_obj is None:
|
||||
if team_obj is None: # pyright: ignore[reportUnnecessaryComparison] # legacy adapters may return no row
|
||||
verbose_logger.debug("team_obj is None")
|
||||
return []
|
||||
|
||||
|
|
@ -808,6 +738,8 @@ class AgentRequestHandler:
|
|||
return []
|
||||
|
||||
return object_permissions.agent_access_groups or []
|
||||
except NativeAuthFailure:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_logger.warning("Failed to get agent access groups for team: %s", e)
|
||||
return []
|
||||
|
|
@ -836,7 +768,7 @@ async def _granted_agent_ids(
|
|||
accesses: Final = await asyncio.gather(
|
||||
*(resolve_access(auth_context) for auth_context in await effective_contexts(user_api_key_auth))
|
||||
)
|
||||
return frozenset().union(*(_granted_ids(access) for access in accesses))
|
||||
return frozenset[str]().union(*(_granted_ids(access) for access in accesses))
|
||||
|
||||
|
||||
async def accessible_agents(
|
||||
|
|
@ -881,25 +813,76 @@ async def _strict_agent_access(auth: UserAPIKeyAuth) -> AgentAccess:
|
|||
|
||||
async def _managed_actor_agent_access(auth: UserAPIKeyAuth) -> AgentAccess:
|
||||
agent: Final = managed_agent_policy(auth)
|
||||
if agent is None or not agent.object_permission:
|
||||
return RestrictedAgentAccess(frozenset())
|
||||
permission: Final = LiteLLM_ObjectPermissionTable.model_validate(agent.object_permission or MappingProxyType({}))
|
||||
own_auth: Final = UserAPIKeyAuth(object_permission=permission)
|
||||
own: Final = _granted_ids(await AgentRequestHandler.get_allowed_agents_for_key(own_auth, strict=True))
|
||||
|
||||
from litellm.proxy.agent_endpoints.auth.agent_access_groups import resolve_managed_agent_ceilings
|
||||
|
||||
ceilings: Final = await resolve_managed_agent_ceilings(agent)
|
||||
grouped: Final = frozenset(target for target in own if all(target in ceiling.agent_ids for ceiling in ceilings))
|
||||
caller: Final = await AgentRequestHandler.agent_caller_access(auth, strict=True)
|
||||
capped: Final = grouped if isinstance(caller, UnrestrictedAgentAccess) else grouped & caller.agent_ids
|
||||
context: Final = auth.managed_agent_context
|
||||
if context is None or context.mode == "autonomous":
|
||||
return RestrictedAgentAccess(capped)
|
||||
if context.user_id is None:
|
||||
return RestrictedAgentAccess(frozenset())
|
||||
human_ids: Final = await verified_human_agent_grants(context.user_id, auth.team_id)
|
||||
return RestrictedAgentAccess(capped.intersection(human_ids))
|
||||
return await _read_managed_agent_grants(
|
||||
auth,
|
||||
managed.ManagedAgentGrants(
|
||||
permission_present=bool(agent is not None and agent.object_permission),
|
||||
mode=context.mode if context is not None else None,
|
||||
user_present=context is not None and context.user_id is not None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _read_managed_agent_grants(auth: UserAPIKeyAuth, facts: managed.ManagedAgentGrants) -> AgentAccess:
|
||||
decision: Final = managed.decide(facts)
|
||||
agent: Final = managed_agent_policy(auth)
|
||||
match decision.action:
|
||||
case "Grants":
|
||||
return _project_agent_grants(decision)
|
||||
case "ReadOwn":
|
||||
permission: Final = LiteLLM_ObjectPermissionTable.model_validate(
|
||||
agent.object_permission if agent is not None else MappingProxyType({})
|
||||
)
|
||||
own_auth: Final = UserAPIKeyAuth(object_permission=permission)
|
||||
own: Final = await AgentRequestHandler.get_allowed_agents_for_key(own_auth, strict=True)
|
||||
return await _read_managed_agent_grants(auth, replace(facts, own=tuple(_granted_ids(own))))
|
||||
case "ReadCeilings":
|
||||
from litellm.proxy.agent_endpoints.auth.agent_access_groups import resolve_managed_agent_ceilings
|
||||
|
||||
if agent is None:
|
||||
raise RuntimeError("native requested absent managed agent")
|
||||
ceilings: Final = await resolve_managed_agent_ceilings(agent)
|
||||
return await _read_managed_agent_grants(
|
||||
auth, replace(facts, ceilings=tuple(tuple(ceiling.agent_ids) for ceiling in ceilings))
|
||||
)
|
||||
case "ReadCaller":
|
||||
caller: Final = await AgentRequestHandler.agent_caller_access(auth, strict=True)
|
||||
return await _read_managed_agent_grants(
|
||||
auth,
|
||||
replace(
|
||||
facts,
|
||||
caller_loaded=True,
|
||||
caller=tuple(caller.agent_ids) if isinstance(caller, RestrictedAgentAccess) else None,
|
||||
),
|
||||
)
|
||||
case "ReadHumanGrants":
|
||||
context: Final = auth.managed_agent_context
|
||||
human: Final = await verified_human_agent_grants(
|
||||
context.user_id if context is not None else None, auth.team_id
|
||||
)
|
||||
return await _read_managed_agent_grants(auth, replace(facts, human=tuple(human)))
|
||||
case _:
|
||||
raise RuntimeError("invalid native managed grants transition")
|
||||
|
||||
|
||||
async def _read_declared_grants(facts: managed.DeclaredGrants, strict: bool) -> AgentAccess:
|
||||
decision: Final = managed.decide(facts)
|
||||
match decision.action:
|
||||
case "Grants":
|
||||
return _project_agent_grants(decision)
|
||||
case "ReadDeclaredGroups":
|
||||
groups: Final = await AgentRequestHandler._get_agents_from_access_groups( # pyright: ignore[reportPrivateUsage] # module-owned staged reader
|
||||
facts.declared_groups, check_db_only=strict
|
||||
)
|
||||
return await _read_declared_grants(replace(facts, group_agents=tuple(groups)), strict)
|
||||
case "ReadUnifiedGroups":
|
||||
unified: Final = await AgentRequestHandler._get_unified_access_group_agents( # pyright: ignore[reportPrivateUsage] # module-owned staged reader
|
||||
facts.unified_groups, check_db_only=strict
|
||||
)
|
||||
return await _read_declared_grants(replace(facts, unified_agents=tuple(unified)), strict)
|
||||
case _:
|
||||
raise RuntimeError("invalid native declared grants transition")
|
||||
|
||||
|
||||
async def _verified_human_agent_sources(
|
||||
|
|
@ -919,7 +902,13 @@ async def verified_human_agent_grants(user_id: str | None, team_id: str | None =
|
|||
sources: Final = await _verified_human_agent_sources(
|
||||
user_id, allowed_team_ids=frozenset((team_id,)) if team_id else frozenset()
|
||||
)
|
||||
return frozenset().union(*(grants for source, grants in sources if source is None or source == team_id))
|
||||
decision: Final = agent_grants.decide(
|
||||
agent_grants.Human(
|
||||
tuple(agent_grants.Source(source, tuple(grants)) for source, grants in sources),
|
||||
team_id,
|
||||
)
|
||||
)
|
||||
return frozenset(decision.grants or ())
|
||||
|
||||
|
||||
async def resolve_delegated_agent_team(
|
||||
|
|
@ -931,15 +920,202 @@ async def resolve_delegated_agent_team(
|
|||
allowed_team_ids: frozenset[str] | None = None,
|
||||
) -> str | None:
|
||||
sources: Final = await _verified_human_agent_sources(user_id)
|
||||
if any(source is None and agent_id in grants for source, grants in sources):
|
||||
return team_id
|
||||
granting_teams: Final = frozenset(
|
||||
source
|
||||
for source, grants in sources
|
||||
if source is not None and agent_id in grants and (allowed_team_ids is None or source in allowed_team_ids)
|
||||
decision: Final = agent_grants.decide(
|
||||
agent_grants.Delegated(
|
||||
tuple(agent_grants.Source(source, tuple(grants)) for source, grants in sources),
|
||||
agent_id,
|
||||
team_id,
|
||||
explicit_team,
|
||||
tuple(allowed_team_ids) if allowed_team_ids is not None else None,
|
||||
)
|
||||
)
|
||||
if team_id in granting_teams:
|
||||
return team_id
|
||||
if not explicit_team and granting_teams:
|
||||
return min(granting_teams)
|
||||
raise HTTPException(403, "Select a team that grants access to this agent using x-litellm-team-id")
|
||||
if decision.action == "SelectTeam":
|
||||
raise HTTPException(403, "Select a team that grants access to this agent using x-litellm-team-id")
|
||||
return decision.team_id
|
||||
|
||||
|
||||
def _agent_grant_source(team_id: str | None, access: AgentAccess | AgentPolicyUnavailable) -> agent_grants.Source:
|
||||
return agent_grants.Source(
|
||||
team_id,
|
||||
tuple(access.agent_ids) if isinstance(access, RestrictedAgentAccess) else None,
|
||||
isinstance(access, AgentPolicyUnavailable),
|
||||
)
|
||||
|
||||
|
||||
async def _read_resolved_agent_grants(
|
||||
auth: UserAPIKeyAuth | None,
|
||||
ceiling: CeilingResolver,
|
||||
default_deny: Callable[[], bool],
|
||||
key_lookup: AgentAccessLookup | None,
|
||||
team_lookup: AgentAccessLookup | None,
|
||||
facts: agent_grants.Resolve,
|
||||
) -> AgentAccess:
|
||||
decision: Final = agent_grants.decide(facts)
|
||||
match decision.action:
|
||||
case "Complete":
|
||||
return _project_agent_grants(decision)
|
||||
case "ReadManaged":
|
||||
assert auth is not None
|
||||
return await _managed_actor_agent_access(auth)
|
||||
case "ReadKeyTeam":
|
||||
access: Final = await AgentRequestHandler.resolve_key_team_agent_access(
|
||||
auth,
|
||||
strict=facts.strict,
|
||||
lookup_key_access=key_lookup,
|
||||
lookup_team_access=team_lookup,
|
||||
)
|
||||
return await _read_resolved_agent_grants(
|
||||
auth,
|
||||
ceiling,
|
||||
default_deny,
|
||||
key_lookup,
|
||||
team_lookup,
|
||||
replace(
|
||||
facts,
|
||||
key_team_loaded=True,
|
||||
key_team=_agent_grant_source(None, access).grants,
|
||||
),
|
||||
)
|
||||
case "ReadDefault":
|
||||
return await _read_resolved_agent_grants(
|
||||
auth,
|
||||
ceiling,
|
||||
default_deny,
|
||||
key_lookup,
|
||||
team_lookup,
|
||||
replace(
|
||||
facts,
|
||||
default_deny=default_deny(),
|
||||
),
|
||||
)
|
||||
case "ReadCaller":
|
||||
caller: Final = await AgentRequestHandler.agent_caller_access(auth, strict=facts.strict)
|
||||
own: Final = managed.decide(
|
||||
managed.IntersectGrants(
|
||||
facts.key_team,
|
||||
_agent_grant_source(None, caller).grants,
|
||||
)
|
||||
)
|
||||
return await _read_resolved_agent_grants(
|
||||
auth,
|
||||
ceiling,
|
||||
default_deny,
|
||||
key_lookup,
|
||||
team_lookup,
|
||||
replace(
|
||||
facts,
|
||||
caller_loaded=True,
|
||||
own=own.grants,
|
||||
),
|
||||
)
|
||||
case "ReadCeiling":
|
||||
ids: Final = await AgentRequestHandler._agent_access_group_ceiling(auth, ceiling, strict=facts.strict) # pyright: ignore[reportPrivateUsage] # module-owned staged reader
|
||||
return await _read_resolved_agent_grants(
|
||||
auth,
|
||||
ceiling,
|
||||
default_deny,
|
||||
key_lookup,
|
||||
team_lookup,
|
||||
replace(
|
||||
facts,
|
||||
ceiling_loaded=True,
|
||||
ceiling=tuple(ids) if ids is not None else None,
|
||||
),
|
||||
)
|
||||
case _:
|
||||
raise NativeAuthFailure(f"unexpected agent grant directive {decision.action}")
|
||||
|
||||
|
||||
async def read_agent_access_facts(
|
||||
agent_id: str,
|
||||
auth: UserAPIKeyAuth | None,
|
||||
resolve_ceiling: CeilingResolver = resolve_agent_access_group_ceiling,
|
||||
) -> agent_access.AgentAccessFacts:
|
||||
from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
registered: Final = global_agent_registry.get_agent_by_id(agent_id)
|
||||
facts: Final = agent_access.AgentAccessFacts(
|
||||
registry_managed=isinstance(registered, AgentResponse) and registered.identity_managed,
|
||||
database_present=prisma_client is not None,
|
||||
subject_present=auth is not None,
|
||||
key_hash_present=bool(auth.api_key or auth.token) if auth is not None else False,
|
||||
subject_managed=managed_agent_policy(auth) is not None,
|
||||
session=auth.is_session_token if auth is not None else False,
|
||||
custom=auth.authenticated_by_custom_auth if auth is not None else False,
|
||||
)
|
||||
return await _read_agent_access(agent_id, auth, resolve_ceiling, facts)
|
||||
|
||||
|
||||
async def _read_agent_access(
|
||||
agent_id: str,
|
||||
auth: UserAPIKeyAuth | None,
|
||||
resolve_ceiling: CeilingResolver,
|
||||
facts: agent_access.AgentAccessFacts,
|
||||
authority: UserAPIKeyAuth | None = None,
|
||||
) -> agent_access.AgentAccessFacts:
|
||||
from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry
|
||||
from litellm.proxy.agent_endpoints.auth.managed_authorization import project_agent
|
||||
from litellm.proxy.agent_endpoints.identity_store import AgentIdentityStore
|
||||
from litellm.proxy.agent_endpoints.managed_identity import raise_identity_failure
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
from litellm.types.proxy.agent_identity import AgentIdentityFailure
|
||||
|
||||
action: Final = agent_access.decide(facts)
|
||||
match action:
|
||||
case "Allow" | "Deny":
|
||||
return facts
|
||||
case "ReadTarget":
|
||||
target: Final = await AgentIdentityStore.from_client(prisma_client).agent(agent_id)
|
||||
if isinstance(target, AgentIdentityFailure):
|
||||
raise_identity_failure(target)
|
||||
return await _read_agent_access(
|
||||
agent_id,
|
||||
auth,
|
||||
resolve_ceiling,
|
||||
replace(facts, target_loaded=True, target=project_agent(target) if target is not None else None),
|
||||
authority,
|
||||
)
|
||||
case "ReadAuthority":
|
||||
from litellm.proxy.mcp_server.auth.user_api_key_auth_mcp import MCPRequestHandler
|
||||
|
||||
assert auth is not None
|
||||
key_hash: Final = auth.api_key or auth.token
|
||||
assert key_hash is not None
|
||||
reloaded: Final = await MCPRequestHandler._reload_admitted_key(key_hash, check_db_only=True) # pyright: ignore[reportPrivateUsage] # authoritative key reload has no public seam
|
||||
return await _read_agent_access(
|
||||
agent_id, auth, resolve_ceiling, replace(facts, authority_loaded=True), reloaded
|
||||
)
|
||||
case "ReadExplicitGrants":
|
||||
assert auth is not None
|
||||
source: Final = authority if authority is not None else auth
|
||||
fresh: Final = source.model_copy(
|
||||
update=MappingProxyType({"requires_fresh_policy": True, "agent_caller": auth.agent_caller})
|
||||
)
|
||||
explicit: Final = await _granted_agent_ids(fresh, _strict_agent_access, build_effective_auth_contexts)
|
||||
return await _read_agent_access(
|
||||
agent_id, auth, resolve_ceiling, replace(facts, explicit_grants=tuple(explicit)), authority
|
||||
)
|
||||
case "ReadLegacyGrants":
|
||||
grants: Final = await AgentRequestHandler.resolve_agent_access(
|
||||
auth, resolve_ceiling, strict=bool(auth is not None and auth.requires_fresh_policy)
|
||||
)
|
||||
return await _read_agent_access(
|
||||
agent_id,
|
||||
auth,
|
||||
resolve_ceiling,
|
||||
replace(
|
||||
facts,
|
||||
legacy_loaded=True,
|
||||
legacy_grants=tuple(grants.agent_ids) if isinstance(grants, RestrictedAgentAccess) else None,
|
||||
),
|
||||
authority,
|
||||
)
|
||||
case "ReadTargetIds":
|
||||
stable: Final = global_agent_registry.stable_agent_id(agent_id)
|
||||
ids: Final = global_agent_registry.ids_for_agent(stable)
|
||||
return await _read_agent_access(
|
||||
agent_id, auth, resolve_ceiling, replace(facts, target_ids=tuple(ids)), authority
|
||||
)
|
||||
case _:
|
||||
assert_never(action)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
from collections.abc import Mapping
|
||||
from dataclasses import replace
|
||||
from itertools import product
|
||||
from types import MappingProxyType
|
||||
from typing import Annotated, Final, Literal
|
||||
from typing import Final, Literal, assert_never
|
||||
|
||||
from pydantic import Field, TypeAdapter, ValidationError
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from litellm._auth import agent_access, managed
|
||||
from litellm.proxy._types import LiteLLMRoutes, UserAPIKeyAuth
|
||||
from litellm.proxy.agent_endpoints.identity_store import AgentIdentityStore
|
||||
from litellm.proxy.agent_endpoints.managed_identity import raise_identity_failure
|
||||
|
|
@ -94,17 +96,18 @@ _MODEL_ROUTE_KINDS: Final[
|
|||
|
||||
|
||||
def managed_agent_route_allowed(route: str, method: str | None) -> bool:
|
||||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
from litellm.proxy.auth import route_policy_adapter
|
||||
|
||||
if route in ("/agents", "/v1/agents"):
|
||||
return method in (None, "GET", "HEAD")
|
||||
if route in _MANAGED_REALTIME_ROUTES or route in _MANAGED_LIVE_ROUTES:
|
||||
return method in (None, "GET")
|
||||
if route in _MANAGED_MODEL_ROUTES or RouteChecks.check_route_access(route, _MANAGED_MODEL_PATHS):
|
||||
return method in (None, "POST")
|
||||
return RouteChecks.check_route_access(route, _MANAGED_MCP_ROUTES) or RouteChecks.check_route_access(
|
||||
route, LiteLLMRoutes.agent_inference_routes.value
|
||||
)
|
||||
return route_policy_adapter.classify(route, "Managed", method)
|
||||
|
||||
|
||||
def _model_route_kind(
|
||||
route: str,
|
||||
) -> Literal["completion", "image_generation", "image_edit", "moderation", "speech", "body", "path"]:
|
||||
for suffix, kind in _MODEL_ROUTE_KINDS.items():
|
||||
if route.endswith(suffix):
|
||||
return kind
|
||||
return "completion"
|
||||
|
||||
|
||||
def managed_inference_request(
|
||||
|
|
@ -134,7 +137,7 @@ def managed_inference_request(
|
|||
return dict(body)
|
||||
from litellm.proxy.common_utils.http_parsing_utils import resolve_inference_model
|
||||
|
||||
kind: Final = next((kind for suffix, kind in _MODEL_ROUTE_KINDS.items() if route.endswith(suffix)), "completion")
|
||||
kind: Final = _model_route_kind(route)
|
||||
endpoint_model: Final = path_model or (
|
||||
query_model if route.endswith(("/completions", "/embeddings", "/images/generations", "/images/edits")) else None
|
||||
)
|
||||
|
|
@ -159,71 +162,131 @@ def managed_agent_policy(auth: "UserAPIKeyAuth | None") -> AgentResponse | None:
|
|||
async def admit_managed_actor(auth: UserAPIKeyAuth, store: AgentIdentityStore | None) -> None:
|
||||
delegation_verified: Final = auth._managed_delegation_verified # pyright: ignore[reportPrivateUsage] # the one-shot delegation marker is a PrivateAttr by design
|
||||
auth._managed_delegation_verified = False # pyright: ignore[reportPrivateUsage] # consumed here so a replayed token cannot reuse it
|
||||
if auth.agent_id is None:
|
||||
facts: Final = managed.ActorLookup(
|
||||
agent_id=auth.agent_id,
|
||||
has_database=store is not None,
|
||||
context=_managed_context(auth.managed_agent_context),
|
||||
jwt_claims=bool(auth.jwt_claims),
|
||||
delegation_verified=delegation_verified,
|
||||
)
|
||||
await _drive_actor(auth, store, facts)
|
||||
|
||||
|
||||
async def _drive_actor(
|
||||
auth: UserAPIKeyAuth,
|
||||
store: AgentIdentityStore | None,
|
||||
facts: managed.ActorLookup,
|
||||
agent: AgentResponse | None = None,
|
||||
) -> None:
|
||||
decision: Final = managed.decide(facts)
|
||||
if decision.denial is not None:
|
||||
raise_identity_failure(_managed_failure(decision.denial))
|
||||
if decision.action == "Allow":
|
||||
return
|
||||
if store is None:
|
||||
if decision.action == "ReadRegistry":
|
||||
from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry
|
||||
|
||||
assert auth.agent_id is not None
|
||||
registered: Final = global_agent_registry.get_agent_by_id(auth.agent_id)
|
||||
if auth.managed_agent_context is not None or (
|
||||
registered is not None and (registered.identity_managed or registered.identity is not None)
|
||||
):
|
||||
raise_identity_failure(
|
||||
AgentIdentityFailure(code="policy_unavailable", message="Managed agent policy requires a database")
|
||||
)
|
||||
await _drive_actor(
|
||||
auth,
|
||||
store,
|
||||
replace(
|
||||
facts,
|
||||
registry_loaded=True,
|
||||
registry_managed=registered is not None
|
||||
and (registered.identity_managed or registered.identity is not None),
|
||||
),
|
||||
)
|
||||
return
|
||||
agent: Final = await store.agent(auth.agent_id)
|
||||
if isinstance(agent, AgentIdentityFailure):
|
||||
raise_identity_failure(agent)
|
||||
if agent is None:
|
||||
if decision.action == "ReadAgent":
|
||||
assert store is not None and auth.agent_id is not None
|
||||
loaded: Final = await store.agent(auth.agent_id)
|
||||
if isinstance(loaded, AgentIdentityFailure):
|
||||
raise_identity_failure(loaded)
|
||||
await _drive_actor(
|
||||
auth,
|
||||
store,
|
||||
replace(facts, agent_loaded=True, agent=project_agent(loaded) if loaded is not None else None),
|
||||
loaded,
|
||||
)
|
||||
return
|
||||
if decision.action == "ReadRetired":
|
||||
assert store is not None and auth.agent_id is not None
|
||||
retired: Final = await store.retired_agent(auth.agent_id)
|
||||
if isinstance(retired, AgentIdentityFailure):
|
||||
raise_identity_failure(retired)
|
||||
if auth.managed_agent_context is not None or retired:
|
||||
raise_identity_failure(AgentIdentityFailure(message="Agent no longer exists"))
|
||||
await _drive_actor(auth, store, replace(facts, retired=retired), agent)
|
||||
return
|
||||
if not agent.identity_managed:
|
||||
if decision.action == "AssignPolicy":
|
||||
assert agent is not None
|
||||
auth.managed_agent_policy = agent
|
||||
auth.billing_agent_policy = agent
|
||||
auth.requires_fresh_policy = True
|
||||
await _drive_actor(auth, store, replace(facts, policy_assigned=True), agent)
|
||||
return
|
||||
if auth.jwt_claims and auth.managed_agent_context is None:
|
||||
raise_identity_failure(AgentIdentityFailure(message="A managed agent requires a matching verified identity"))
|
||||
failure: Final = actor_admission_failure(agent, auth.managed_agent_context)
|
||||
if failure is not None:
|
||||
raise_identity_failure(failure)
|
||||
auth.managed_agent_policy = agent
|
||||
auth.billing_agent_policy = agent
|
||||
auth.requires_fresh_policy = True
|
||||
if (
|
||||
auth.managed_agent_context is not None
|
||||
and auth.managed_agent_context.mode == "delegated"
|
||||
and not delegation_verified
|
||||
):
|
||||
if decision.action == "ReadHumanGrants":
|
||||
from litellm.proxy.agent_endpoints.auth.agent_permission_handler import verified_human_agent_grants
|
||||
|
||||
assert auth.managed_agent_context is not None
|
||||
grants: Final = await verified_human_agent_grants(auth.managed_agent_context.user_id, auth.team_id)
|
||||
if agent.agent_id not in grants:
|
||||
raise_identity_failure(
|
||||
AgentIdentityFailure(message="The delegated user is not permitted to invoke this agent")
|
||||
)
|
||||
await _drive_actor(auth, store, replace(facts, human_grants=tuple(grants)), agent)
|
||||
return
|
||||
raise RuntimeError("Invalid managed actor decision")
|
||||
|
||||
|
||||
def project_agent(agent: AgentResponse) -> managed.Agent:
|
||||
return managed.Agent(
|
||||
agent_id=agent.agent_id,
|
||||
enabled=agent.enabled,
|
||||
identity_managed=agent.identity_managed,
|
||||
identity=managed.BoundIdentity(active=agent.identity.active, revision=agent.identity.revision)
|
||||
if agent.identity is not None
|
||||
else None,
|
||||
execution_mode=agent.execution_mode,
|
||||
)
|
||||
|
||||
|
||||
def _managed_context(context: ManagedAgentContext | None) -> managed.Context | None:
|
||||
return (
|
||||
managed.Context(
|
||||
agent_id=context.agent_id,
|
||||
binding_revision=context.binding_revision,
|
||||
mode=context.mode,
|
||||
user_id=context.user_id,
|
||||
)
|
||||
if context is not None
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def _managed_failure(denial: managed.ManagedDenial) -> AgentIdentityFailure:
|
||||
if denial == "DatabaseRequired":
|
||||
return AgentIdentityFailure(code="policy_unavailable", message="Managed agent policy requires a database")
|
||||
messages: Final[Mapping[managed.ManagedDenial, str]] = MappingProxyType(
|
||||
{
|
||||
"Disabled": "Agent execution is disabled",
|
||||
"MissingContext": "This agent requires its bound identity provider token",
|
||||
"ChangedIdentity": "Agent identity changed during authentication; retry",
|
||||
"ExecutionMode": "Agent is not enabled for this execution mode",
|
||||
"MissingHuman": "A verified human subject is required",
|
||||
"Removed": "Agent no longer exists",
|
||||
"VerifiedIdentityRequired": "A managed agent requires a matching verified identity",
|
||||
"HumanNotPermitted": "The delegated user is not permitted to invoke this agent",
|
||||
}
|
||||
)
|
||||
return AgentIdentityFailure(message=messages[denial])
|
||||
|
||||
|
||||
def actor_admission_failure(
|
||||
agent: AgentResponse,
|
||||
context: ManagedAgentContext | None,
|
||||
) -> AgentIdentityFailure | None:
|
||||
if not agent.enabled or agent.identity is None or not agent.identity.active:
|
||||
return AgentIdentityFailure(message="Agent execution is disabled")
|
||||
if context is None:
|
||||
return AgentIdentityFailure(message="This agent requires its bound identity provider token")
|
||||
if context.agent_id != agent.agent_id or context.binding_revision != agent.identity.revision:
|
||||
return AgentIdentityFailure(message="Agent identity changed during authentication; retry")
|
||||
if agent.execution_mode not in (context.mode, "both"):
|
||||
return AgentIdentityFailure(message="Agent is not enabled for this execution mode")
|
||||
if context.mode == "delegated" and not context.user_id:
|
||||
return AgentIdentityFailure(message="A verified human subject is required")
|
||||
return None
|
||||
decision: Final = managed.decide(managed.Actor(project_agent(agent), _managed_context(context)))
|
||||
return _managed_failure(decision.denial) if decision.denial is not None else None
|
||||
|
||||
|
||||
_INVOCATION_COST: Final = TypeAdapter(Annotated[float, Field(ge=0, allow_inf_nan=False)])
|
||||
_INVOCATION_COST: Final = TypeAdapter(float)
|
||||
|
||||
|
||||
def invocation_target(route: str, body: Mapping[str, object]) -> str | None:
|
||||
|
|
@ -238,36 +301,93 @@ def invocation_target(route: str, body: Mapping[str, object]) -> str | None:
|
|||
async def prepare_agent_invocation(
|
||||
auth: UserAPIKeyAuth, target_name: str, store: AgentIdentityStore | None, *, billable: bool = True
|
||||
) -> None:
|
||||
from litellm.proxy.agent_endpoints.auth.agent_permission_handler import AgentRequestHandler
|
||||
from litellm.proxy.common_utils.registry_read_through import get_agent_with_read_through
|
||||
|
||||
registered: Final = await get_agent_with_read_through(target_name)
|
||||
if registered is None:
|
||||
return
|
||||
registered_managed: Final = registered.identity_managed or registered.identity is not None
|
||||
if store is None and registered_managed:
|
||||
raise_identity_failure(
|
||||
AgentIdentityFailure(code="policy_unavailable", message="Managed agent policy requires a database")
|
||||
)
|
||||
target: Final = await store.agent(registered.agent_id) if store is not None else None
|
||||
if isinstance(target, AgentIdentityFailure):
|
||||
raise_identity_failure(target)
|
||||
if target is None and registered_managed:
|
||||
raise_identity_failure(AgentIdentityFailure(message="Invoked agent no longer exists"))
|
||||
effective: Final = target if target is not None else registered
|
||||
if not effective.identity_managed and auth.managed_agent_policy is None:
|
||||
return
|
||||
if not await AgentRequestHandler.is_agent_allowed(effective.agent_id, auth):
|
||||
raise_identity_failure(AgentIdentityFailure(message="The caller is not permitted to invoke this agent"))
|
||||
auth.invoked_agent_id = effective.agent_id
|
||||
auth.invoked_agent_policy = effective
|
||||
if auth.agent_id is None and effective.identity_managed:
|
||||
auth.billing_agent_policy = effective
|
||||
raw_fee: Final = (effective.litellm_params or MappingProxyType({})).get("cost_per_query", 0.0) if billable else 0.0
|
||||
facts: Final = agent_access.InvocationFacts(
|
||||
registered=project_agent(registered) if registered is not None else None,
|
||||
has_store=store is not None,
|
||||
actor_managed=auth.managed_agent_policy is not None,
|
||||
actor_id_present=auth.agent_id is not None,
|
||||
)
|
||||
await _drive_invocation(auth, store, registered, None, billable, facts)
|
||||
|
||||
|
||||
async def _drive_invocation(
|
||||
auth: UserAPIKeyAuth,
|
||||
store: AgentIdentityStore | None,
|
||||
registered: AgentResponse | None,
|
||||
target: AgentResponse | None,
|
||||
billable: bool,
|
||||
facts: agent_access.InvocationFacts,
|
||||
) -> None:
|
||||
plan: Final = agent_access.invocation(facts)
|
||||
effective: Final = target if plan.use_target else registered
|
||||
match plan.action:
|
||||
case "Complete":
|
||||
if facts.fee_loaded:
|
||||
assert facts.fee is not None
|
||||
auth.agent_invocation_cost = facts.fee
|
||||
return
|
||||
case "Deny":
|
||||
failures: Final = MappingProxyType(
|
||||
{
|
||||
"DatabaseRequired": AgentIdentityFailure(
|
||||
code="policy_unavailable", message="Managed agent policy requires a database"
|
||||
),
|
||||
"Removed": AgentIdentityFailure(message="Invoked agent no longer exists"),
|
||||
"CallerNotPermitted": AgentIdentityFailure(
|
||||
message="The caller is not permitted to invoke this agent"
|
||||
),
|
||||
"InvalidFee": AgentIdentityFailure(
|
||||
code="policy_unavailable", message="Agent invocation price is invalid"
|
||||
),
|
||||
}
|
||||
)
|
||||
assert plan.denial is not None
|
||||
raise_identity_failure(failures[plan.denial])
|
||||
case "ReadTarget":
|
||||
assert registered is not None
|
||||
loaded: Final = await store.agent(registered.agent_id) if store is not None else None
|
||||
if isinstance(loaded, AgentIdentityFailure):
|
||||
raise_identity_failure(loaded)
|
||||
await _drive_invocation(
|
||||
auth,
|
||||
store,
|
||||
registered,
|
||||
loaded,
|
||||
billable,
|
||||
replace(facts, target_loaded=True, target=project_agent(loaded) if loaded is not None else None),
|
||||
)
|
||||
case "ReadPermissions":
|
||||
from litellm.proxy.agent_endpoints.auth.agent_permission_handler import read_agent_access_facts
|
||||
|
||||
assert effective is not None
|
||||
permissions: Final = await read_agent_access_facts(effective.agent_id, auth)
|
||||
await _drive_invocation(auth, store, registered, target, billable, replace(facts, permissions=permissions))
|
||||
case "Assign":
|
||||
assert effective is not None
|
||||
auth.invoked_agent_id = effective.agent_id
|
||||
auth.invoked_agent_policy = effective
|
||||
if plan.assign_billing:
|
||||
auth.billing_agent_policy = effective
|
||||
await _drive_invocation(auth, store, registered, target, billable, replace(facts, assigned=True))
|
||||
case "ReadFee":
|
||||
assert effective is not None
|
||||
raw: Final = (
|
||||
(effective.litellm_params or MappingProxyType[str, object]({})).get("cost_per_query", 0.0)
|
||||
if billable
|
||||
else 0.0
|
||||
)
|
||||
await _drive_invocation(
|
||||
auth, store, registered, target, billable, replace(facts, fee_loaded=True, fee=_invocation_fee(raw))
|
||||
)
|
||||
case _:
|
||||
assert_never(plan.action)
|
||||
|
||||
|
||||
def _invocation_fee(raw: object) -> float | None:
|
||||
try:
|
||||
fee: Final = _INVOCATION_COST.validate_python(raw_fee)
|
||||
return _INVOCATION_COST.validate_python(raw)
|
||||
except ValidationError:
|
||||
raise_identity_failure(
|
||||
AgentIdentityFailure(code="policy_unavailable", message="Agent invocation price is invalid")
|
||||
)
|
||||
auth.agent_invocation_cost = fee
|
||||
return None
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue