mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-05 02:41:56 +00:00
feat(otel/v2): store logging-exporter assignments in typed columns, not metadata
Move key/team/org logging-exporter assignments out of the untyped metadata JSON blob into a typed logging_exporters String[] column on LiteLLM_VerificationToken, LiteLLM_TeamTable, and LiteLLM_OrganizationTable (plus the Deleted* mirrors), matching the existing policies/access_group_ids column convention rather than burying a named-resource list in generic metadata. The request-time resolver reads each identity level from its own column via get_key_object / get_team_object / get_org_object. The six write paths (key generate/update/regenerate, team new/update, org new/update) accept a typed logging_exporters field, gate it through the shared visibility predicate via a thin validate_logging_exporter_field adapter (so the assignment validator and its tests are unchanged), and persist it to the column. Keys persist it like access_group_ids (a real column), not like policies (which is tucked into metadata). The typed optional field also removes the metadata removal-via-omission attack surface: omitting the field means no change. Round-trip validated against a real Postgres for team and key; regression tests cover the resolver reading columns and the field adapter's gating.
This commit is contained in:
parent
5b866cdeb0
commit
a86e333832
16 changed files with 302 additions and 181 deletions
|
|
@ -93,6 +93,21 @@ FORK C - Y7 fan_out/routing: **C2 chosen**. Merge `TenantFanOutSpanProcessor` in
|
|||
|
||||
- Y6 full carrier removal (request-data -> ContextVar for the gen-AI span): NOT done blind. Evidence against a naive removal: the gen-AI logger binds the tenant tracer from `standard_callback_dynamic_params.otel_destinations` (`logger.py:237`, and the deferred close path `:344/:364`). The tracer is bound at `log_pre_api_call` (request task, ContextVar present) for the mainline, but `streaming_handler.py:1668` closes via `asyncio.run(...)`, a fresh event-loop context where the server-only ContextVar may not survive, whereas the kwargs-borne `dynamic_params` always does. So moving the gen-AI path to the ContextVar risks a streaming tenant-routing regression that cannot be validated without a live streaming proxy. Recommendation: keep `dynamic_params` as the robust carrier, rely on the now-hardened wipe for the trust boundary (Y3 proven), and treat ContextVar unification as a follow-up gated on live streaming validation. Open for the maintainer's call.
|
||||
|
||||
## 7b. A2 progress (as built, uncommitted WIP)
|
||||
|
||||
Environment self-unblocked: installed + generated prisma-client-py 0.11.0 (was an ungenerated namespace stub), stood up a throwaway Postgres (docker `litellm-pg`, port 5599), `prisma db push` applied the schema.
|
||||
|
||||
Done and DB-validated:
|
||||
- `logging_exporters String[] @default([])` added to `LiteLLM_TeamTable`, `LiteLLM_OrganizationTable`, `LiteLLM_VerificationToken`, and the `Deleted*` mirrors, in all three synced `schema.prisma` copies. Round-trip proven against real Postgres: `team.logging_exporters` and `key.logging_exporters` persist and read back through the generated client.
|
||||
- Pydantic mirrors carry the field (`litellm/models/team.py`, `organization.py`, `verification_token.py`); it propagates to `LiteLLM_VerificationTokenView` / `UserAPIKeyAuth`. `get_key_object`/`get_org_object`/`get_team_object` hydrate via `**model_dump()`, so the column flows in automatically.
|
||||
- Resolver read path (`_union_logging_exporter_names`) migrated from `metadata.logging_exporters` to the `.logging_exporters` column, reading each level from its own object (key/team/org), DB-required.
|
||||
|
||||
Remaining for a complete, consistent A2 (this is why the tree currently has 5 red resolver tests, since a half-migration is intentionally not committed):
|
||||
- Write path across 6 endpoint sites (key generate/update/regenerate, team new/update, org new/update), each with bespoke persistence (team `/new` auto-persists via `data.json()` -> mirror; keys map fields explicitly). Recommended low-churn approach: add `logging_exporters: Optional[list[str]]` to the request models, feed the existing `validate_logging_exporter_assignment` a synthesized `{"logging_exporters": data.logging_exporters}` dict (leaving the validator and its 38 tests unchanged), and persist the field to the column.
|
||||
- Update the 5 resolver tests to the column model (mock `get_team_object`; set `prisma_client`).
|
||||
- Add the proxy-extras `migration.sql` (`ADD COLUMN IF NOT EXISTS "logging_exporters" TEXT[] DEFAULT ARRAY[]::TEXT[]` on the four tables, per the `policies` precedent).
|
||||
- UI: send `logging_exporters` as a top-level field, read from the column.
|
||||
|
||||
## 8. Test plan (maps to the 6 required areas)
|
||||
|
||||
1. Visibility: proxy-admin sees per policy; team/org-admin cannot see out-of-scope destinations from `GET /credentials`; global/scoped/auto_enable follow policy.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_TeamTable" ADD COLUMN IF NOT EXISTS "logging_exporters" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_DeletedTeamTable" ADD COLUMN IF NOT EXISTS "logging_exporters" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_VerificationToken" ADD COLUMN IF NOT EXISTS "logging_exporters" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_DeletedVerificationToken" ADD COLUMN IF NOT EXISTS "logging_exporters" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_OrganizationTable" ADD COLUMN IF NOT EXISTS "logging_exporters" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
|
@ -87,6 +87,7 @@ model LiteLLM_OrganizationTable {
|
|||
budget_id String
|
||||
metadata Json @default("{}")
|
||||
models String[]
|
||||
logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this org (credential names)
|
||||
spend Float @default(0.0)
|
||||
model_spend Json @default("{}")
|
||||
object_permission_id String?
|
||||
|
|
@ -142,6 +143,7 @@ model LiteLLM_TeamTable {
|
|||
team_member_permissions String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
policies String[] @default([])
|
||||
logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this team (credential names)
|
||||
default_team_member_models String[] @default([]) // default allowed_models for newly added team members; empty = no per-member restriction
|
||||
budget_limits Json? // per-model budget limits for the team
|
||||
model_id Int? @unique // id for LiteLLM_ModelTable -> stores team-level model aliases
|
||||
|
|
@ -210,6 +212,7 @@ model LiteLLM_DeletedTeamTable {
|
|||
team_member_permissions String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
policies String[] @default([])
|
||||
logging_exporters String[] @default([])
|
||||
model_id Int? // id for LiteLLM_ModelTable -> stores team-level model aliases
|
||||
allow_team_guardrail_config Boolean @default(false)
|
||||
|
||||
|
|
@ -415,6 +418,7 @@ model LiteLLM_VerificationToken {
|
|||
allowed_routes String[] @default([])
|
||||
policies String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this key (credential names)
|
||||
model_spend Json @default("{}")
|
||||
model_max_budget Json @default("{}")
|
||||
budget_id String?
|
||||
|
|
@ -508,6 +512,7 @@ model LiteLLM_DeletedVerificationToken {
|
|||
allowed_routes String[] @default([])
|
||||
policies String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
logging_exporters String[] @default([])
|
||||
model_spend Json @default("{}")
|
||||
model_max_budget Json @default("{}")
|
||||
router_settings Json? @default("{}")
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ class LiteLLM_OrganizationTable(LiteLLMPydanticObjectBase):
|
|||
spend: float = 0.0
|
||||
metadata: Optional[dict] = None
|
||||
models: List[str] = []
|
||||
logging_exporters: Optional[List[str]] = None
|
||||
model_spend: Optional[dict] = {}
|
||||
created_by: str
|
||||
updated_by: str
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ class LiteLLM_TeamTable(TeamBase):
|
|||
model_spend: Optional[dict] = {}
|
||||
model_max_budget: Optional[dict] = {}
|
||||
policies: Optional[List[str]] = None
|
||||
logging_exporters: Optional[List[str]] = None
|
||||
allow_team_guardrail_config: Optional[bool] = False
|
||||
litellm_model_table: Optional[LiteLLM_ModelTable] = None
|
||||
object_permission: Optional[LiteLLM_ObjectPermissionTable] = None
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ class LiteLLM_VerificationToken(LiteLLMPydanticObjectBase):
|
|||
object_permission_id: Optional[str] = None
|
||||
object_permission: Optional[LiteLLM_ObjectPermissionTable] = None
|
||||
access_group_ids: Optional[List[str]] = None
|
||||
logging_exporters: Optional[List[str]] = None
|
||||
rotation_count: Optional[int] = 0
|
||||
auto_rotate: Optional[bool] = False
|
||||
rotation_interval: Optional[str] = None
|
||||
|
|
|
|||
|
|
@ -1048,6 +1048,7 @@ class GenerateRequestBase(LiteLLMPydanticObjectBase):
|
|||
mcp_rpm_limit: Optional[Dict[str, int]] = None
|
||||
guardrails: Optional[List[str]] = None
|
||||
policies: Optional[List[str]] = None
|
||||
logging_exporters: Optional[List[str]] = None
|
||||
prompts: Optional[List[str]] = None
|
||||
blocked: Optional[bool] = None
|
||||
aliases: Optional[dict] = {}
|
||||
|
|
@ -1705,6 +1706,7 @@ class NewTeamRequest(TeamBase):
|
|||
tags: Optional[list] = None
|
||||
guardrails: Optional[List[str]] = None
|
||||
policies: Optional[List[str]] = None
|
||||
logging_exporters: Optional[List[str]] = None
|
||||
prompts: Optional[List[str]] = None
|
||||
object_permission: Optional[LiteLLM_ObjectPermissionBase] = None
|
||||
allowed_passthrough_routes: Optional[list] = None
|
||||
|
|
@ -1771,6 +1773,7 @@ class UpdateTeamRequest(LiteLLMPydanticObjectBase):
|
|||
model_aliases: Optional[dict] = None
|
||||
guardrails: Optional[List[str]] = None
|
||||
policies: Optional[List[str]] = None
|
||||
logging_exporters: Optional[List[str]] = None
|
||||
object_permission: Optional[LiteLLM_ObjectPermissionBase] = None
|
||||
disable_global_guardrails: Optional[bool] = None
|
||||
team_member_budget: Optional[float] = None
|
||||
|
|
@ -1914,6 +1917,7 @@ class NewOrganizationRequest(LiteLLM_BudgetTable):
|
|||
models: List = []
|
||||
budget_id: Optional[str] = None
|
||||
metadata: Optional[dict] = None
|
||||
logging_exporters: Optional[List[str]] = None
|
||||
model_rpm_limit: Optional[Dict[str, int]] = None
|
||||
model_tpm_limit: Optional[Dict[str, int]] = None
|
||||
|
||||
|
|
@ -2598,6 +2602,7 @@ class LiteLLM_OrganizationTableUpdate(LiteLLM_BudgetTable):
|
|||
spend: Optional[float] = None
|
||||
metadata: Optional[dict] = None
|
||||
models: Optional[List[str]] = None
|
||||
logging_exporters: Optional[List[str]] = None
|
||||
updated_by: Optional[str] = None
|
||||
object_permission: Optional[LiteLLM_ObjectPermissionBase] = None
|
||||
model_tpm_limit: Optional[Dict[str, int]] = None
|
||||
|
|
|
|||
|
|
@ -544,57 +544,73 @@ async def _effective_org_id(user_api_key_dict: UserAPIKeyAuth) -> Optional[str]:
|
|||
async def _union_logging_exporter_names(user_api_key_dict: UserAPIKeyAuth, org_id: Optional[str]) -> set:
|
||||
"""The union of admin-assigned exporter names across the request's identity chain.
|
||||
|
||||
Resolves each level from its OWN record: the key's ``metadata`` is shadowed by the
|
||||
team's on the auth object, so it is fetched fresh via ``get_key_object``; the org's
|
||||
metadata is fetched via ``get_org_object`` using the effective ``org_id`` (token org
|
||||
or team fallback); the team's is already its own on ``team_metadata``. Internal-user
|
||||
is intentionally not a routing dimension. The lists are admin-owned; the request
|
||||
never supplies them. Degrades to team-only when no DB is connected (SDK mode).
|
||||
Each level is read from its own ``logging_exporters`` column: the key via
|
||||
``get_key_object`` (the auth object's fields are the team's shadow, so it is
|
||||
fetched fresh), the team via ``get_team_object``, the org via ``get_org_object``
|
||||
on the effective ``org_id`` (token org or team fallback). Internal-user is
|
||||
intentionally not a routing dimension. The assignment is an admin-owned column;
|
||||
the request never supplies it. Needs a DB connection: in SDK mode there is no
|
||||
identity to resolve against, so this is empty (admin-owned destinations do not
|
||||
apply off the proxy).
|
||||
"""
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy.auth.auth_checks import get_key_object, get_org_object
|
||||
|
||||
names: set = set()
|
||||
|
||||
def _add(metadata: Any) -> None:
|
||||
if not isinstance(metadata, dict):
|
||||
return
|
||||
assigned = metadata.get("logging_exporters")
|
||||
if isinstance(assigned, list):
|
||||
names.update(str(name) for name in assigned)
|
||||
from litellm.proxy.auth.auth_checks import (
|
||||
get_key_object,
|
||||
get_org_object,
|
||||
get_team_object,
|
||||
)
|
||||
|
||||
prisma_client = proxy_server.prisma_client
|
||||
if prisma_client is None:
|
||||
return set()
|
||||
cache = proxy_server.user_api_key_cache
|
||||
span = getattr(user_api_key_dict, "parent_otel_span", None)
|
||||
names: set = set()
|
||||
|
||||
# KEY: the key's own metadata (the auth object's .metadata is the team's shadow).
|
||||
if user_api_key_dict.token and prisma_client is not None:
|
||||
def _add(obj: object) -> None:
|
||||
assigned = getattr(obj, "logging_exporters", None)
|
||||
if isinstance(assigned, (list, tuple)):
|
||||
names.update(str(name) for name in assigned)
|
||||
|
||||
if user_api_key_dict.token:
|
||||
try:
|
||||
key_obj = await get_key_object(
|
||||
hashed_token=user_api_key_dict.token,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=cache,
|
||||
parent_otel_span=span,
|
||||
proxy_logging_obj=proxy_server.proxy_logging_obj,
|
||||
_add(
|
||||
await get_key_object(
|
||||
hashed_token=user_api_key_dict.token,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=cache,
|
||||
parent_otel_span=span,
|
||||
proxy_logging_obj=proxy_server.proxy_logging_obj,
|
||||
)
|
||||
)
|
||||
_add(key_obj.metadata)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
# TEAM: team_metadata is already the team's own.
|
||||
_add(user_api_key_dict.team_metadata)
|
||||
|
||||
# ORG: the org's own metadata (central catch-all).
|
||||
if org_id and prisma_client is not None:
|
||||
if user_api_key_dict.team_id:
|
||||
try:
|
||||
org_obj = await get_org_object(
|
||||
org_id=org_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=cache,
|
||||
parent_otel_span=span,
|
||||
proxy_logging_obj=proxy_server.proxy_logging_obj,
|
||||
_add(
|
||||
await get_team_object(
|
||||
team_id=user_api_key_dict.team_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=cache,
|
||||
parent_otel_span=span,
|
||||
proxy_logging_obj=proxy_server.proxy_logging_obj,
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
if org_id:
|
||||
try:
|
||||
_add(
|
||||
await get_org_object(
|
||||
org_id=org_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=cache,
|
||||
parent_otel_span=span,
|
||||
proxy_logging_obj=proxy_server.proxy_logging_obj,
|
||||
)
|
||||
)
|
||||
_add(getattr(org_obj, "metadata", None))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -1405,8 +1405,7 @@ async def generate_key_fn(
|
|||
_is_user_team_admin,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.logging_exporter_validation import (
|
||||
LOGGING_EXPORTERS_KEY,
|
||||
validate_logging_exporter_assignment,
|
||||
validate_logging_exporter_field,
|
||||
)
|
||||
from litellm.proxy.proxy_server import (
|
||||
prisma_client,
|
||||
|
|
@ -1495,14 +1494,13 @@ async def generate_key_fn(
|
|||
route=KeyManagementRoutes.KEY_GENERATE,
|
||||
)
|
||||
|
||||
# Team-admin of the key's team or org-admin of that team's org may
|
||||
# write metadata.logging_exporters on team-owned keys. Personal keys
|
||||
# (no team_table) stay proxy-admin only. Skip the role lookup when
|
||||
# the field isn't in the payload to keep /key/generate cheap for the
|
||||
# common case.
|
||||
if isinstance(data.metadata, dict) and LOGGING_EXPORTERS_KEY in data.metadata:
|
||||
validate_logging_exporter_assignment(
|
||||
data.metadata,
|
||||
# Team-admin of the key's team or org-admin of that team's org may assign
|
||||
# logging_exporters on team-owned keys. Personal keys (no team_table) stay
|
||||
# proxy-admin only. Skip the role lookup when the field isn't in the payload
|
||||
# to keep /key/generate cheap for the common case.
|
||||
if data.logging_exporters is not None:
|
||||
validate_logging_exporter_field(
|
||||
data.logging_exporters,
|
||||
user_api_key_dict,
|
||||
caller_is_team_admin=(
|
||||
team_table is not None
|
||||
|
|
@ -1700,13 +1698,12 @@ async def generate_service_account_key_fn(
|
|||
_is_user_team_admin,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.logging_exporter_validation import (
|
||||
LOGGING_EXPORTERS_KEY,
|
||||
validate_logging_exporter_assignment,
|
||||
validate_logging_exporter_field,
|
||||
)
|
||||
|
||||
if isinstance(data.metadata, dict) and LOGGING_EXPORTERS_KEY in data.metadata:
|
||||
validate_logging_exporter_assignment(
|
||||
data.metadata,
|
||||
if data.logging_exporters is not None:
|
||||
validate_logging_exporter_field(
|
||||
data.logging_exporters,
|
||||
user_api_key_dict,
|
||||
caller_is_team_admin=(
|
||||
team_table is not None and _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_table)
|
||||
|
|
@ -2457,7 +2454,7 @@ async def update_key_fn( # noqa: C901
|
|||
_is_user_team_admin,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.logging_exporter_validation import (
|
||||
validate_logging_exporter_assignment,
|
||||
validate_logging_exporter_field,
|
||||
)
|
||||
from litellm.proxy.proxy_server import (
|
||||
llm_router,
|
||||
|
|
@ -2485,14 +2482,11 @@ async def update_key_fn( # noqa: C901
|
|||
prisma_client=prisma_client,
|
||||
)
|
||||
|
||||
# logging-exporters validation runs once the key's team is known so
|
||||
# a team-admin or org-admin of that team can attach destinations.
|
||||
# The validator no-ops when the effective value doesn't change; pass
|
||||
# the stored metadata so removal-via-omission gates too (Veria F4).
|
||||
if isinstance(data.metadata, dict):
|
||||
_existing_key_metadata = (
|
||||
existing_key_row.metadata if isinstance(getattr(existing_key_row, "metadata", None), dict) else None
|
||||
)
|
||||
# logging-exporters validation runs once the key's team is known so a
|
||||
# team-admin or org-admin of that team can attach destinations. The
|
||||
# validator no-ops when the effective value doesn't change; pass the
|
||||
# stored column value so a non-admin cannot clear an admin-assigned one.
|
||||
if data.logging_exporters is not None:
|
||||
_key_team_id = getattr(existing_key_row, "team_id", None)
|
||||
_key_team = None
|
||||
if _key_team_id is not None:
|
||||
|
|
@ -2506,8 +2500,8 @@ async def update_key_fn( # noqa: C901
|
|||
)
|
||||
except HTTPException:
|
||||
_key_team = None
|
||||
validate_logging_exporter_assignment(
|
||||
data.metadata,
|
||||
validate_logging_exporter_field(
|
||||
data.logging_exporters,
|
||||
user_api_key_dict,
|
||||
caller_is_team_admin=(
|
||||
_key_team is not None
|
||||
|
|
@ -2517,7 +2511,7 @@ async def update_key_fn( # noqa: C901
|
|||
_key_team is not None
|
||||
and await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=_key_team)
|
||||
),
|
||||
existing_metadata=_existing_key_metadata,
|
||||
existing_exporters=getattr(existing_key_row, "logging_exporters", None),
|
||||
scope_team_id=getattr(_key_team, "team_id", None),
|
||||
scope_org_id=getattr(_key_team, "organization_id", None),
|
||||
)
|
||||
|
|
@ -3507,6 +3501,7 @@ async def generate_key_helper_fn(
|
|||
rotation_interval: Optional[str] = None,
|
||||
router_settings: Optional[dict] = None,
|
||||
access_group_ids: Optional[list] = None,
|
||||
logging_exporters: Optional[list] = None, # admin-owned OTEL destinations (credential names)
|
||||
budget_limits: Optional[list] = None, # multiple concurrent budget windows
|
||||
):
|
||||
from litellm.proxy.proxy_server import premium_user, prisma_client
|
||||
|
|
@ -3638,6 +3633,7 @@ async def generate_key_helper_fn(
|
|||
"object_permission_id": object_permission_id,
|
||||
"router_settings": router_settings_json,
|
||||
"access_group_ids": access_group_ids or [],
|
||||
"logging_exporters": logging_exporters or [],
|
||||
}
|
||||
|
||||
# Add rotation fields if auto_rotate is enabled
|
||||
|
|
@ -4580,26 +4576,22 @@ async def regenerate_key_fn( # noqa: C901
|
|||
)
|
||||
|
||||
# logging_exporters gate on regenerate matches /key/generate and
|
||||
# /key/update. Without this, a key owner could set
|
||||
# metadata.logging_exporters on /key/{id}/regenerate and route
|
||||
# future traces to a destination they aren't allowed to assign
|
||||
# (Veria F3). The validator no-ops when the effective value
|
||||
# doesn't change; pass stored metadata so removal-via-omission
|
||||
# gates too (Veria F4).
|
||||
if data is not None and isinstance(data.metadata, dict):
|
||||
# /key/update. Without this, a key owner could set logging_exporters on
|
||||
# /key/{id}/regenerate and route future traces to a destination they
|
||||
# aren't allowed to assign (Veria F3). The validator no-ops when the
|
||||
# effective value doesn't change; pass the stored column value so a
|
||||
# non-admin cannot clear an admin-assigned one.
|
||||
if data is not None and data.logging_exporters is not None:
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
_is_user_org_admin_for_team,
|
||||
_is_user_team_admin,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.logging_exporter_validation import (
|
||||
validate_logging_exporter_assignment,
|
||||
validate_logging_exporter_field,
|
||||
)
|
||||
|
||||
_regen_existing_metadata = (
|
||||
_key_in_db.metadata if isinstance(getattr(_key_in_db, "metadata", None), dict) else None
|
||||
)
|
||||
validate_logging_exporter_assignment(
|
||||
data.metadata,
|
||||
validate_logging_exporter_field(
|
||||
data.logging_exporters,
|
||||
user_api_key_dict,
|
||||
caller_is_team_admin=(
|
||||
regenerate_team_table is not None
|
||||
|
|
@ -4615,7 +4607,7 @@ async def regenerate_key_fn( # noqa: C901
|
|||
team_obj=regenerate_team_table,
|
||||
)
|
||||
),
|
||||
existing_metadata=_regen_existing_metadata,
|
||||
existing_exporters=getattr(_key_in_db, "logging_exporters", None),
|
||||
scope_team_id=getattr(regenerate_team_table, "team_id", None),
|
||||
scope_org_id=getattr(regenerate_team_table, "organization_id", None),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -172,6 +172,38 @@ def _exporter_value_changes(
|
|||
return requested_metadata.get(LOGGING_EXPORTERS_KEY) != existing
|
||||
|
||||
|
||||
def validate_logging_exporter_field(
|
||||
requested_exporters: Optional[list],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
*,
|
||||
caller_is_team_admin: bool = False,
|
||||
caller_is_org_admin: bool = False,
|
||||
existing_exporters: Optional[list] = None,
|
||||
scope_team_id: Optional[str] = None,
|
||||
scope_org_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Authorize a typed ``logging_exporters`` write (the column-backed field).
|
||||
|
||||
Adapts the typed list to the metadata-shaped input the shared assignment
|
||||
validator expects, so the authorization logic lives in one place.
|
||||
``requested_exporters is None`` means the field was not provided (no-op); an
|
||||
empty list is an explicit clear and is gated like any other change.
|
||||
``existing_exporters`` is the stored column value, passed so a change is
|
||||
detected and a non-admin cannot silently clear an admin-assigned value.
|
||||
"""
|
||||
requested_metadata = None if requested_exporters is None else {LOGGING_EXPORTERS_KEY: requested_exporters}
|
||||
existing_metadata = None if existing_exporters is None else {LOGGING_EXPORTERS_KEY: existing_exporters}
|
||||
validate_logging_exporter_assignment(
|
||||
requested_metadata,
|
||||
user_api_key_dict,
|
||||
caller_is_team_admin=caller_is_team_admin,
|
||||
caller_is_org_admin=caller_is_org_admin,
|
||||
existing_metadata=existing_metadata,
|
||||
scope_team_id=scope_team_id,
|
||||
scope_org_id=scope_org_id,
|
||||
)
|
||||
|
||||
|
||||
def validate_logging_exporter_assignment(
|
||||
metadata: Optional[dict],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
|
|
|
|||
|
|
@ -199,10 +199,10 @@ async def new_organization(
|
|||
```
|
||||
"""
|
||||
from litellm.proxy.management_endpoints.logging_exporter_validation import (
|
||||
validate_logging_exporter_assignment,
|
||||
validate_logging_exporter_field,
|
||||
)
|
||||
|
||||
validate_logging_exporter_assignment(getattr(data, "metadata", None), user_api_key_dict)
|
||||
validate_logging_exporter_field(getattr(data, "logging_exporters", None), user_api_key_dict)
|
||||
|
||||
from litellm.proxy.proxy_server import (
|
||||
litellm_proxy_admin_name,
|
||||
|
|
@ -459,10 +459,10 @@ async def update_organization(
|
|||
data = LiteLLM_OrganizationTableUpdate(**raw_data_with_flat_budget_fields)
|
||||
|
||||
from litellm.proxy.management_endpoints.logging_exporter_validation import (
|
||||
validate_logging_exporter_assignment,
|
||||
validate_logging_exporter_field,
|
||||
)
|
||||
|
||||
validate_logging_exporter_assignment(getattr(data, "metadata", None), user_api_key_dict)
|
||||
validate_logging_exporter_field(getattr(data, "logging_exporters", None), user_api_key_dict)
|
||||
|
||||
# Validate budget values are not negative
|
||||
if data.max_budget is not None and (not math.isfinite(data.max_budget) or data.max_budget < 0):
|
||||
|
|
|
|||
|
|
@ -992,8 +992,7 @@ async def new_team(
|
|||
_is_user_org_admin_for_org_id,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.logging_exporter_validation import (
|
||||
LOGGING_EXPORTERS_KEY,
|
||||
validate_logging_exporter_assignment,
|
||||
validate_logging_exporter_field,
|
||||
)
|
||||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
get_audit_log_changed_by,
|
||||
|
|
@ -1010,9 +1009,9 @@ async def new_team(
|
|||
# the destination org may assign logging exporters at creation time.
|
||||
# Skip the org-admin lookup entirely when the field isn't being
|
||||
# written, to avoid hitting the cache for unrelated /team/new calls.
|
||||
if isinstance(data.metadata, dict) and LOGGING_EXPORTERS_KEY in data.metadata:
|
||||
validate_logging_exporter_assignment(
|
||||
data.metadata,
|
||||
if data.logging_exporters is not None:
|
||||
validate_logging_exporter_field(
|
||||
data.logging_exporters,
|
||||
user_api_key_dict,
|
||||
caller_is_org_admin=await _is_user_org_admin_for_org_id(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
|
|
@ -1663,7 +1662,7 @@ async def update_team(
|
|||
_is_user_org_admin_for_team,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.logging_exporter_validation import (
|
||||
validate_logging_exporter_assignment,
|
||||
validate_logging_exporter_field,
|
||||
)
|
||||
from litellm.proxy.proxy_server import (
|
||||
litellm_proxy_admin_name,
|
||||
|
|
@ -1725,18 +1724,15 @@ async def update_team(
|
|||
# this path. Pass only the org-admin flag so the validator can't
|
||||
# silently grant team-admins if the route gate is ever widened. The
|
||||
# validator no-ops when the effective value doesn't change; pass the
|
||||
# stored metadata so removal-via-omission gates too (Veria F4).
|
||||
if isinstance(data.metadata, dict):
|
||||
existing_team_metadata = (
|
||||
existing_team_row.metadata if isinstance(existing_team_row.metadata, dict) else None
|
||||
)
|
||||
validate_logging_exporter_assignment(
|
||||
data.metadata,
|
||||
# stored column value so a non-admin cannot clear an admin-assigned one.
|
||||
if data.logging_exporters is not None:
|
||||
validate_logging_exporter_field(
|
||||
data.logging_exporters,
|
||||
user_api_key_dict,
|
||||
caller_is_org_admin=await _is_user_org_admin_for_team(
|
||||
user_api_key_dict=user_api_key_dict, team_obj=team_for_auth
|
||||
),
|
||||
existing_metadata=existing_team_metadata,
|
||||
existing_exporters=getattr(existing_team_row, "logging_exporters", None),
|
||||
scope_team_id=getattr(team_for_auth, "team_id", None),
|
||||
scope_org_id=getattr(team_for_auth, "organization_id", None),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ model LiteLLM_OrganizationTable {
|
|||
budget_id String
|
||||
metadata Json @default("{}")
|
||||
models String[]
|
||||
logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this org (credential names)
|
||||
spend Float @default(0.0)
|
||||
model_spend Json @default("{}")
|
||||
object_permission_id String?
|
||||
|
|
@ -142,6 +143,7 @@ model LiteLLM_TeamTable {
|
|||
team_member_permissions String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
policies String[] @default([])
|
||||
logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this team (credential names)
|
||||
default_team_member_models String[] @default([]) // default allowed_models for newly added team members; empty = no per-member restriction
|
||||
budget_limits Json? // per-model budget limits for the team
|
||||
model_id Int? @unique // id for LiteLLM_ModelTable -> stores team-level model aliases
|
||||
|
|
@ -210,6 +212,7 @@ model LiteLLM_DeletedTeamTable {
|
|||
team_member_permissions String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
policies String[] @default([])
|
||||
logging_exporters String[] @default([])
|
||||
model_id Int? // id for LiteLLM_ModelTable -> stores team-level model aliases
|
||||
allow_team_guardrail_config Boolean @default(false)
|
||||
|
||||
|
|
@ -415,6 +418,7 @@ model LiteLLM_VerificationToken {
|
|||
allowed_routes String[] @default([])
|
||||
policies String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this key (credential names)
|
||||
model_spend Json @default("{}")
|
||||
model_max_budget Json @default("{}")
|
||||
budget_id String?
|
||||
|
|
@ -508,6 +512,7 @@ model LiteLLM_DeletedVerificationToken {
|
|||
allowed_routes String[] @default([])
|
||||
policies String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
logging_exporters String[] @default([])
|
||||
model_spend Json @default("{}")
|
||||
model_max_budget Json @default("{}")
|
||||
router_settings Json? @default("{}")
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ model LiteLLM_OrganizationTable {
|
|||
budget_id String
|
||||
metadata Json @default("{}")
|
||||
models String[]
|
||||
logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this org (credential names)
|
||||
spend Float @default(0.0)
|
||||
model_spend Json @default("{}")
|
||||
object_permission_id String?
|
||||
|
|
@ -142,6 +143,7 @@ model LiteLLM_TeamTable {
|
|||
team_member_permissions String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
policies String[] @default([])
|
||||
logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this team (credential names)
|
||||
default_team_member_models String[] @default([]) // default allowed_models for newly added team members; empty = no per-member restriction
|
||||
budget_limits Json? // per-model budget limits for the team
|
||||
model_id Int? @unique // id for LiteLLM_ModelTable -> stores team-level model aliases
|
||||
|
|
@ -210,6 +212,7 @@ model LiteLLM_DeletedTeamTable {
|
|||
team_member_permissions String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
policies String[] @default([])
|
||||
logging_exporters String[] @default([])
|
||||
model_id Int? // id for LiteLLM_ModelTable -> stores team-level model aliases
|
||||
allow_team_guardrail_config Boolean @default(false)
|
||||
|
||||
|
|
@ -415,6 +418,7 @@ model LiteLLM_VerificationToken {
|
|||
allowed_routes String[] @default([])
|
||||
policies String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this key (credential names)
|
||||
model_spend Json @default("{}")
|
||||
model_max_budget Json @default("{}")
|
||||
budget_id String?
|
||||
|
|
@ -508,6 +512,7 @@ model LiteLLM_DeletedVerificationToken {
|
|||
allowed_routes String[] @default([])
|
||||
policies String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
logging_exporters String[] @default([])
|
||||
model_spend Json @default("{}")
|
||||
model_max_budget Json @default("{}")
|
||||
router_settings Json? @default("{}")
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from litellm.proxy.management_endpoints.logging_exporter_validation import (
|
|||
is_admin_gated_credential_info,
|
||||
validate_credential_access,
|
||||
validate_logging_exporter_assignment,
|
||||
validate_logging_exporter_field,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -393,3 +394,60 @@ def test_validate_credential_access_rejects_unknown_field():
|
|||
validate_credential_access({"access": {"global": True, "legacy_field": "x"}})
|
||||
assert exc.value.status_code == 400
|
||||
assert "legacy_field" in exc.value.detail["error"]
|
||||
|
||||
|
||||
# --- validate_logging_exporter_field (the column-backed adapter) ------------
|
||||
#
|
||||
# The endpoints now pass a typed list off the request's ``logging_exporters``
|
||||
# field instead of a metadata dict. The adapter must gate the same way, and the
|
||||
# typed field's None-means-omitted semantics must not open a bypass.
|
||||
|
||||
|
||||
def test_field_none_is_noop_for_non_admin(_registry):
|
||||
"""A request that omits logging_exporters (None) must not require authorization."""
|
||||
validate_logging_exporter_field(None, _non_admin())
|
||||
|
||||
|
||||
def test_field_set_by_non_admin_without_flags_is_forbidden(_registry):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
validate_logging_exporter_field(["langfuse-eu"], _non_admin())
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_field_scoped_rejection_for_out_of_scope_team(_registry):
|
||||
"""arize-ds is granted to ds-team; a team admin writing in another team's scope
|
||||
cannot name it, exactly as the metadata path gated it."""
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
validate_logging_exporter_field(
|
||||
["arize-ds"],
|
||||
_non_admin(),
|
||||
caller_is_team_admin=True,
|
||||
scope_team_id="platform-team",
|
||||
)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_field_empty_clear_over_existing_is_gated_for_non_admin(_registry):
|
||||
"""Clearing an admin-assigned value ([] over a non-empty stored column) is a
|
||||
change and must be authorized; a non-admin cannot silently wipe it."""
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
validate_logging_exporter_field(
|
||||
[],
|
||||
_non_admin(),
|
||||
existing_exporters=["langfuse-eu"],
|
||||
)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_field_unchanged_value_is_noop(_registry):
|
||||
"""Re-sending the same column value is a no-op even for a non-admin."""
|
||||
validate_logging_exporter_field(
|
||||
["langfuse-eu"],
|
||||
_non_admin(),
|
||||
existing_exporters=["langfuse-eu"],
|
||||
)
|
||||
|
||||
|
||||
def test_field_admin_can_assign_out_of_scope(_registry):
|
||||
"""Proxy admin skips the scope check (parity with the metadata path)."""
|
||||
validate_logging_exporter_field(["arize-ds"], _admin(), scope_team_id="platform-team")
|
||||
|
|
|
|||
|
|
@ -4845,24 +4845,46 @@ def _seeded_logging_credentials():
|
|||
litellm.credential_list = original
|
||||
|
||||
|
||||
def _auth(team_exporters=None, token=None, org_id=None, team_id=None):
|
||||
return UserAPIKeyAuth(
|
||||
api_key="hashed-key",
|
||||
token=token,
|
||||
org_id=org_id,
|
||||
team_id=team_id,
|
||||
team_metadata=({"logging_exporters": team_exporters} if team_exporters else {}),
|
||||
def _auth(token="hashed-key", org_id=None, team_id="team-x"):
|
||||
return UserAPIKeyAuth(api_key="hashed-key", token=token, org_id=org_id, team_id=team_id)
|
||||
|
||||
|
||||
def _patch_identity(monkeypatch, *, key=(), team=(), org=(), team_org_id=None):
|
||||
"""Route the resolver's identity lookups to ``logging_exporters`` columns.
|
||||
|
||||
Assignments now live on typed columns, so the resolver reads each level from
|
||||
its own DB object. This connects a prisma client and patches
|
||||
``get_key_object`` / ``get_team_object`` / ``get_org_object`` to return objects
|
||||
carrying the given ``logging_exporters``. ``team_org_id`` sets the team's
|
||||
``organization_id`` for the token-has-no-org_id org fallback.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import litellm.proxy.proxy_server as proxy_server
|
||||
from litellm.proxy.auth import auth_checks
|
||||
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
|
||||
monkeypatch.setattr(proxy_server, "user_api_key_cache", MagicMock())
|
||||
monkeypatch.setattr(
|
||||
auth_checks, "get_key_object", AsyncMock(return_value=SimpleNamespace(logging_exporters=list(key)))
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
auth_checks,
|
||||
"get_team_object",
|
||||
AsyncMock(return_value=SimpleNamespace(logging_exporters=list(team), organization_id=team_org_id)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
auth_checks, "get_org_object", AsyncMock(return_value=SimpleNamespace(logging_exporters=list(org)))
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_logging_exporters_team_level(_seeded_logging_credentials):
|
||||
# team_metadata is the team's own (not shadowed); resolves without a DB fetch.
|
||||
async def test_resolve_logging_exporters_team_level(_seeded_logging_credentials, monkeypatch):
|
||||
# team assignment lives on the team's logging_exporters column.
|
||||
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
|
||||
|
||||
destinations, backends = await _resolve_logging_exporters(
|
||||
_auth(team_exporters=["langfuse-eu"])
|
||||
)
|
||||
_patch_identity(monkeypatch, team=["langfuse-eu"])
|
||||
destinations, backends = await _resolve_logging_exporters(_auth())
|
||||
assert {d["endpoint"] for d in destinations} == {
|
||||
"https://cloud.langfuse.com/api/public/otel"
|
||||
}
|
||||
|
|
@ -4873,35 +4895,13 @@ async def test_resolve_logging_exporters_team_level(_seeded_logging_credentials)
|
|||
async def test_resolve_logging_exporters_unions_key_team_org(
|
||||
_seeded_logging_credentials, monkeypatch
|
||||
):
|
||||
# key + org are read from their OWN records (the key's .metadata is team-shadowed),
|
||||
# team from team_metadata. All three union, deduped.
|
||||
from types import SimpleNamespace
|
||||
|
||||
import litellm.proxy.proxy_server as proxy_server
|
||||
from litellm.proxy.auth import auth_checks
|
||||
# key, team, and org are each read from their OWN logging_exporters column.
|
||||
# All three union, deduped.
|
||||
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
|
||||
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
|
||||
monkeypatch.setattr(
|
||||
auth_checks,
|
||||
"get_key_object",
|
||||
AsyncMock(
|
||||
return_value=SimpleNamespace(metadata={"logging_exporters": ["arize-prod"]})
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
auth_checks,
|
||||
"get_org_object",
|
||||
AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
metadata={"logging_exporters": ["langfuse-eu"]}
|
||||
)
|
||||
),
|
||||
)
|
||||
_patch_identity(monkeypatch, key=["arize-prod"], team=["langfuse-eu"], org=["langfuse-eu"])
|
||||
|
||||
destinations, backends = await _resolve_logging_exporters(
|
||||
_auth(team_exporters=["langfuse-eu"], token="hashed-key", org_id="org-1")
|
||||
)
|
||||
destinations, backends = await _resolve_logging_exporters(_auth(org_id="org-1"))
|
||||
|
||||
assert {d["endpoint"] for d in destinations} == {
|
||||
"https://cloud.langfuse.com/api/public/otel", # team + org (deduped)
|
||||
|
|
@ -4912,13 +4912,12 @@ async def test_resolve_logging_exporters_unions_key_team_org(
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_logging_exporters_carries_arize_project(
|
||||
_seeded_logging_credentials,
|
||||
_seeded_logging_credentials, monkeypatch
|
||||
):
|
||||
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
|
||||
|
||||
destinations, _ = await _resolve_logging_exporters(
|
||||
_auth(team_exporters=["arize-prod"])
|
||||
)
|
||||
_patch_identity(monkeypatch, team=["arize-prod"])
|
||||
destinations, _ = await _resolve_logging_exporters(_auth())
|
||||
|
||||
assert destinations == [
|
||||
{
|
||||
|
|
@ -4945,20 +4944,19 @@ async def test_resolve_logging_exporters_empty_without_assignment(
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_logging_exporters_skips_unknown_and_provider_creds(
|
||||
_seeded_logging_credentials,
|
||||
_seeded_logging_credentials, monkeypatch
|
||||
):
|
||||
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
|
||||
|
||||
# unknown name + a provider credential (not credential_type=logging) -> nothing
|
||||
destinations, backends = await _resolve_logging_exporters(
|
||||
_auth(team_exporters=["does-not-exist", "openai-key"])
|
||||
)
|
||||
_patch_identity(monkeypatch, team=["does-not-exist", "openai-key"])
|
||||
destinations, backends = await _resolve_logging_exporters(_auth())
|
||||
assert destinations == [] and backends == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_admin_logging_exporters_stamps_and_activates(
|
||||
_seeded_logging_credentials,
|
||||
_seeded_logging_credentials, monkeypatch
|
||||
):
|
||||
from litellm.integrations.otel.plumbing.context import (
|
||||
_request_destinations,
|
||||
|
|
@ -4966,12 +4964,11 @@ async def test_apply_admin_logging_exporters_stamps_and_activates(
|
|||
)
|
||||
from litellm.proxy.litellm_pre_call_utils import _apply_admin_logging_exporters
|
||||
|
||||
_patch_identity(monkeypatch, team=["langfuse-eu"])
|
||||
token = _request_destinations.set(())
|
||||
data: dict = {}
|
||||
try:
|
||||
await _apply_admin_logging_exporters(
|
||||
data, _auth(team_exporters=["langfuse-eu"])
|
||||
)
|
||||
await _apply_admin_logging_exporters(data, _auth())
|
||||
|
||||
# destinations live under litellm_metadata so the body does not leak an
|
||||
# unknown top-level key to the provider; the top-level key stays absent
|
||||
|
|
@ -5074,7 +5071,7 @@ async def test_client_cannot_control_otel_destinations(_seeded_logging_credentia
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_admin_logging_exporters_registers_on_failure(
|
||||
_seeded_logging_credentials,
|
||||
_seeded_logging_credentials, monkeypatch
|
||||
):
|
||||
"""An admin-owned destination must capture a FAILED upstream call, not only a
|
||||
successful one.
|
||||
|
|
@ -5088,14 +5085,13 @@ async def test_apply_admin_logging_exporters_registers_on_failure(
|
|||
from litellm.integrations.otel.plumbing.context import _request_destinations
|
||||
from litellm.proxy.litellm_pre_call_utils import _apply_admin_logging_exporters
|
||||
|
||||
_patch_identity(monkeypatch, team=["langfuse-eu", "arize-prod"])
|
||||
token = _request_destinations.set(())
|
||||
# Seed a pre-existing failure callback to prove backends are unioned in, not
|
||||
# overwriting, and that a duplicate backend is not appended twice.
|
||||
data: dict = {"failure_callback": ["arize"]}
|
||||
try:
|
||||
await _apply_admin_logging_exporters(
|
||||
data, _auth(team_exporters=["langfuse-eu", "arize-prod"])
|
||||
)
|
||||
await _apply_admin_logging_exporters(data, _auth())
|
||||
for callback_list in ("success_callback", "failure_callback"):
|
||||
registered = data[callback_list]
|
||||
assert "langfuse_otel" in registered
|
||||
|
|
@ -5176,31 +5172,29 @@ async def test_resolve_grant_does_not_auto_enable(
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_name_with_grant_enables(
|
||||
_seeded_logging_credentials_with_access,
|
||||
_seeded_logging_credentials_with_access, monkeypatch
|
||||
):
|
||||
"""Naming a destination the caller is granted enables it (alongside the
|
||||
auto_enable default)."""
|
||||
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
|
||||
|
||||
destinations, _ = await _resolve_logging_exporters(
|
||||
_auth(team_id="team-eu", team_exporters=["langfuse-eu"])
|
||||
)
|
||||
_patch_identity(monkeypatch, team=["langfuse-eu"])
|
||||
destinations, _ = await _resolve_logging_exporters(_auth(team_id="team-eu"))
|
||||
|
||||
assert {d["endpoint"] for d in destinations} == {_LANGFUSE_ENDPOINT, _ARIZE_ENDPOINT}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_name_without_visibility_is_dropped(
|
||||
_seeded_logging_credentials_with_access,
|
||||
_seeded_logging_credentials_with_access, monkeypatch
|
||||
):
|
||||
"""A name that points at a destination NOT visible to the request identity is
|
||||
defensively ignored, so a stale or cross-tenant assignment can never route
|
||||
traffic out. team-other names langfuse-eu (granted only to team-eu)."""
|
||||
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
|
||||
|
||||
destinations, _ = await _resolve_logging_exporters(
|
||||
_auth(team_id="team-other", team_exporters=["langfuse-eu"])
|
||||
)
|
||||
_patch_identity(monkeypatch, team=["langfuse-eu"])
|
||||
destinations, _ = await _resolve_logging_exporters(_auth(team_id="team-other"))
|
||||
|
||||
# langfuse-eu dropped (not visible to team-other); only auto_enable survives.
|
||||
assert {d["endpoint"] for d in destinations} == {_ARIZE_ENDPOINT}
|
||||
|
|
@ -5248,11 +5242,7 @@ async def test_resolve_org_scoped_via_team_when_token_has_no_org_id(monkeypatch)
|
|||
accepts the assignment, so the resolver must agree (M1); without the fallback the
|
||||
org-granted destination is named but invisible (org_id None) and silently dropped.
|
||||
Reverting _effective_org_id to user_api_key_dict.org_id fails this test."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import litellm.proxy.proxy_server as proxy_server
|
||||
from litellm.models.credentials import CredentialItem
|
||||
from litellm.proxy.auth import auth_checks
|
||||
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
|
||||
|
||||
original = litellm.credential_list
|
||||
|
|
@ -5267,26 +5257,11 @@ async def test_resolve_org_scoped_via_team_when_token_has_no_org_id(monkeypatch)
|
|||
},
|
||||
),
|
||||
]
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
|
||||
monkeypatch.setattr(proxy_server, "user_api_key_cache", MagicMock())
|
||||
monkeypatch.setattr(
|
||||
auth_checks, "get_key_object", AsyncMock(return_value=SimpleNamespace(metadata={}))
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
auth_checks,
|
||||
"get_org_object",
|
||||
AsyncMock(return_value=SimpleNamespace(metadata={})),
|
||||
)
|
||||
# the key's token has no org_id; the team it belongs to is in org-7.
|
||||
monkeypatch.setattr(
|
||||
auth_checks,
|
||||
"get_team_object",
|
||||
AsyncMock(return_value=SimpleNamespace(organization_id="org-7")),
|
||||
)
|
||||
# The key's token has no org_id; the team it belongs to is in org-7, and the
|
||||
# arize-org destination is named on the team's logging_exporters column.
|
||||
_patch_identity(monkeypatch, team=["arize-org"], team_org_id="org-7")
|
||||
try:
|
||||
destinations, _ = await _resolve_logging_exporters(
|
||||
_auth(team_id="team-x", team_exporters=["arize-org"])
|
||||
)
|
||||
destinations, _ = await _resolve_logging_exporters(_auth(team_id="team-x"))
|
||||
assert {d["endpoint"] for d in destinations} == {"https://otlp.arize.com/v1"}
|
||||
finally:
|
||||
litellm.credential_list = original
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue