mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-18 04:44:37 +00:00
* feat(proxy): let proxy admins open team member defaults to team admins * fix(proxy): keep a team admin's never-resets duration on a team's first member budget * test(proxy): pin the existing-row and stored never-resets cases for member defaults * feat(proxy): let proxy admins open soft_budget to team admins * feat(proxy): let proxy admins open team metadata to team admins, keeping proxy settings with proxy admins * test(ui): name expected objects in team admin edit access tests Keeps local/no-large-inline-object-arg within its eslint budget * fix(proxy): compare team metadata without recursion and count key presence A proxy-controlled metadata key that appears or disappears with a null value is now a change, and the JSON comparison no longer recurses * fix(proxy): compare team metadata numbers exactly Numbers are compared as decimals, so integers past float precision and close floats are no longer treated as equal
470 lines
20 KiB
Python
470 lines
20 KiB
Python
import json
|
|
|
|
import pytest
|
|
|
|
from litellm.proxy._types import LitellmUserRoles
|
|
|
|
from tests.integration._support.actors import Actor, SeededKey
|
|
from tests.integration._support.actors import create_scratch_actor, create_scratch_team
|
|
from tests.integration._support.client import team_admin_permissions
|
|
|
|
pytestmark = pytest.mark.asyncio(loop_scope="session")
|
|
|
|
|
|
# POST /team/update — actor x team-shape matrix (shapes built by _seed_target).
|
|
# The route is self-managed (LIT-5722), so every authenticated caller reaches
|
|
# update_team and denials are the handler's 403, never the route gate's 401.
|
|
# Only PROXY_ADMIN and an ORG_ADMIN of the team's org pass: a team admin is
|
|
# admitted by TeamAccess.strongest_role but then refused because no team field is
|
|
# enabled for team admins (team_admin_editable_team_fields defaults to empty).
|
|
MARKER_ALIAS = "behavior-pin-update-marker-alias"
|
|
|
|
_MATRIX = [
|
|
("alpha/proxy_admin", Actor.PROXY_ADMIN, "alpha", 200),
|
|
("alpha/org_admin", Actor.ORG_ADMIN, "alpha", 200),
|
|
("alpha/team_admin", Actor.TEAM_ADMIN, "alpha", 403),
|
|
("alpha/internal_user", Actor.INTERNAL_USER, "alpha", 403),
|
|
("alpha/owner", Actor.OWNER, "alpha", 403),
|
|
("alpha/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "alpha", 403),
|
|
("alpha/cross_org_user", Actor.CROSS_ORG_USER, "alpha", 403),
|
|
("alpha/service_account", Actor.SERVICE_ACCOUNT, "alpha", 403),
|
|
("alpha/org_b_admin", Actor.ORG_B_ADMIN, "alpha", 403),
|
|
("beta/proxy_admin", Actor.PROXY_ADMIN, "beta", 200),
|
|
("beta/org_admin", Actor.ORG_ADMIN, "beta", 403),
|
|
("beta/team_admin", Actor.TEAM_ADMIN, "beta", 403),
|
|
("beta/internal_user", Actor.INTERNAL_USER, "beta", 403),
|
|
("beta/owner", Actor.OWNER, "beta", 403),
|
|
("beta/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "beta", 403),
|
|
("beta/cross_org_user", Actor.CROSS_ORG_USER, "beta", 403),
|
|
("beta/service_account", Actor.SERVICE_ACCOUNT, "beta", 403),
|
|
("beta/org_b_admin", Actor.ORG_B_ADMIN, "beta", 200),
|
|
]
|
|
|
|
|
|
_TEAM_MEMBER_ACTORS = (
|
|
Actor.TEAM_ADMIN,
|
|
Actor.INTERNAL_USER,
|
|
Actor.OWNER,
|
|
Actor.UNRELATED_SAME_ORG,
|
|
Actor.SERVICE_ACCOUNT,
|
|
Actor.CROSS_ORG_USER,
|
|
)
|
|
|
|
|
|
async def _callers(db, actors, prefix: str) -> dict[Actor, SeededKey]:
|
|
team_members = {
|
|
actor: await create_scratch_actor(
|
|
db, prefix, user_role=LitellmUserRoles.INTERNAL_USER.value, suffix=actor.value
|
|
)
|
|
for actor in _TEAM_MEMBER_ACTORS
|
|
}
|
|
return {**actors.keys, **team_members}
|
|
|
|
|
|
async def _seed_target(db, actors, callers, shape: str, team_id: str) -> str:
|
|
"""Raw-seed the resources target team; returns its organization_id."""
|
|
if shape == "alpha":
|
|
await create_scratch_team(
|
|
db,
|
|
team_id,
|
|
organization_id=actors.org_a_id,
|
|
admin_user_ids=[callers[Actor.TEAM_ADMIN].user_id],
|
|
member_user_ids=[
|
|
callers[Actor.INTERNAL_USER].user_id,
|
|
callers[Actor.OWNER].user_id,
|
|
callers[Actor.UNRELATED_SAME_ORG].user_id,
|
|
callers[Actor.SERVICE_ACCOUNT].user_id,
|
|
],
|
|
)
|
|
return actors.org_a_id
|
|
if shape == "beta":
|
|
await create_scratch_team(
|
|
db,
|
|
team_id,
|
|
organization_id=actors.org_b_id,
|
|
member_user_ids=[callers[Actor.CROSS_ORG_USER].user_id],
|
|
)
|
|
return actors.org_b_id
|
|
pytest.fail(f"unknown shape={shape}") # pragma: no cover
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"actor,shape,expected_status",
|
|
[(a, sh, s) for (_id, a, sh, s) in _MATRIX],
|
|
ids=[s[0] for s in _MATRIX],
|
|
)
|
|
async def test_team_update_authz_matrix(
|
|
actor: Actor,
|
|
shape: str,
|
|
expected_status: int,
|
|
gateway_client,
|
|
db,
|
|
resources,
|
|
actors,
|
|
):
|
|
callers = await _callers(db, actors, resources.prefix)
|
|
org_id = await _seed_target(db, actors, callers, shape, resources.prefix)
|
|
caller = callers[actor]
|
|
|
|
resp = await gateway_client.post(
|
|
"/team/update",
|
|
headers={"Authorization": f"Bearer {caller.cleartext}"},
|
|
json={
|
|
"team_id": resources.prefix,
|
|
"team_alias": MARKER_ALIAS,
|
|
"organization_id": org_id,
|
|
},
|
|
)
|
|
assert (
|
|
resp.status_code == expected_status
|
|
), f"{actor.value} {shape}: {resp.status_code} {resp.text}"
|
|
|
|
row = await db.db.litellm_teamtable.find_unique(
|
|
where={"team_id": resources.prefix}
|
|
)
|
|
assert row is not None
|
|
if expected_status == 200:
|
|
assert row.team_alias == MARKER_ALIAS
|
|
else:
|
|
assert row.team_alias != MARKER_ALIAS, "denied but team mutated"
|
|
|
|
|
|
async def test_team_update_org_admin_resolved_from_team_without_org_context(
|
|
gateway_client, db, resources, actors
|
|
):
|
|
"""With no organization_id in the body the route gate resolves the target
|
|
team's org from team_id, so an org admin of the team's own org is allowed
|
|
(200), same as PROXY_ADMIN. A team admin of that same team reaches the
|
|
handler but is refused (403) until a proxy admin enables fields for team
|
|
admins, and the response says so."""
|
|
callers = await _callers(db, actors, resources.prefix)
|
|
await _seed_target(db, actors, callers, "alpha", resources.prefix)
|
|
|
|
allowed_org_admin = await gateway_client.post(
|
|
"/team/update",
|
|
headers={"Authorization": f"Bearer {actors.keys[Actor.ORG_ADMIN].cleartext}"},
|
|
json={"team_id": resources.prefix, "team_alias": MARKER_ALIAS},
|
|
)
|
|
assert allowed_org_admin.status_code == 200, allowed_org_admin.text
|
|
|
|
allowed_proxy_admin = await gateway_client.post(
|
|
"/team/update",
|
|
headers={"Authorization": f"Bearer {actors.keys[Actor.PROXY_ADMIN].cleartext}"},
|
|
json={"team_id": resources.prefix, "team_alias": MARKER_ALIAS},
|
|
)
|
|
assert allowed_proxy_admin.status_code == 200, allowed_proxy_admin.text
|
|
|
|
denied_team_admin = await gateway_client.post(
|
|
"/team/update",
|
|
headers={"Authorization": f"Bearer {callers[Actor.TEAM_ADMIN].cleartext}"},
|
|
json={"team_id": resources.prefix, "team_alias": MARKER_ALIAS},
|
|
)
|
|
assert denied_team_admin.status_code == 403, denied_team_admin.text
|
|
assert "cannot edit team settings" in denied_team_admin.text, denied_team_admin.text
|
|
assert "Team admin editable fields" in denied_team_admin.text, denied_team_admin.text
|
|
|
|
|
|
_MEMBER_DEFAULTS = [
|
|
pytest.param("team_member_budget", 25.0, "max_budget", id="team_member_budget"),
|
|
pytest.param("team_member_budget_duration", "7d", "budget_duration", id="team_member_budget_duration"),
|
|
pytest.param("team_member_tpm_limit", 200, "tpm_limit", id="team_member_tpm_limit"),
|
|
pytest.param("team_member_rpm_limit", 9, "rpm_limit", id="team_member_rpm_limit"),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("field,value,budget_column", _MEMBER_DEFAULTS)
|
|
async def test_team_update_team_admin_member_default_follows_the_proxy_admin_grant(
|
|
field: str, value: str | float, budget_column: str, gateway_client, db, resources, actors
|
|
):
|
|
callers = await _callers(db, actors, resources.prefix)
|
|
await _seed_target(db, actors, callers, "alpha", resources.prefix)
|
|
headers = {"Authorization": f"Bearer {callers[Actor.TEAM_ADMIN].cleartext}"}
|
|
body = {"team_id": resources.prefix, field: value}
|
|
|
|
with team_admin_permissions(gateway_client.gateway, ["tpm_limit"]):
|
|
denied = await gateway_client.post("/team/update", headers=headers, json=body)
|
|
assert denied.status_code == 403, denied.text
|
|
assert f"'{field}'" in denied.text and "Team admin editable fields" in denied.text, denied.text
|
|
|
|
with team_admin_permissions(gateway_client.gateway, [field]):
|
|
allowed = await gateway_client.post("/team/update", headers=headers, json=body)
|
|
assert allowed.status_code == 200, allowed.text
|
|
|
|
info = await gateway_client.get("/team/info", params={"team_id": resources.prefix})
|
|
assert info.status_code == 200, info.text
|
|
assert info.json()["team_info"]["team_member_budget_table"][budget_column] == value
|
|
|
|
|
|
async def test_team_update_team_admin_resending_member_defaults_leaves_the_budget_row_alone(
|
|
gateway_client, db, resources, actors
|
|
):
|
|
callers = await _callers(db, actors, resources.prefix)
|
|
await _seed_target(db, actors, callers, "alpha", resources.prefix)
|
|
stored = {
|
|
"team_member_budget": 10.0,
|
|
"team_member_budget_duration": "30d",
|
|
"team_member_tpm_limit": 100,
|
|
"team_member_rpm_limit": 5,
|
|
}
|
|
seeded = await gateway_client.post("/team/update", json={"team_id": resources.prefix, **stored})
|
|
assert seeded.status_code == 200, seeded.text
|
|
team = await db.db.litellm_teamtable.find_unique(where={"team_id": resources.prefix})
|
|
assert team is not None
|
|
budget_id = team.metadata["team_member_budget_id"]
|
|
before = await db.db.litellm_budgettable.find_unique(where={"budget_id": budget_id})
|
|
assert before is not None
|
|
|
|
with team_admin_permissions(gateway_client.gateway, ["tpm_limit"]):
|
|
resp = await gateway_client.post(
|
|
"/team/update",
|
|
headers={"Authorization": f"Bearer {callers[Actor.TEAM_ADMIN].cleartext}"},
|
|
json={"team_id": resources.prefix, "tpm_limit": 1000, **stored},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
after = await db.db.litellm_budgettable.find_unique(where={"budget_id": budget_id})
|
|
assert after is not None and after.updated_at == before.updated_at
|
|
team = await db.db.litellm_teamtable.find_unique(where={"team_id": resources.prefix})
|
|
assert team is not None and team.tpm_limit == 1000
|
|
|
|
|
|
async def test_team_update_team_admin_cannot_change_member_defaults_of_another_team(
|
|
gateway_client, db, resources, actors
|
|
):
|
|
callers = await _callers(db, actors, resources.prefix)
|
|
await _seed_target(db, actors, callers, "beta", resources.prefix)
|
|
|
|
with team_admin_permissions(gateway_client.gateway, ["team_member_budget"]):
|
|
resp = await gateway_client.post(
|
|
"/team/update",
|
|
headers={"Authorization": f"Bearer {callers[Actor.TEAM_ADMIN].cleartext}"},
|
|
json={"team_id": resources.prefix, "team_member_budget": 25.0},
|
|
)
|
|
assert resp.status_code == 403, resp.text
|
|
team = await db.db.litellm_teamtable.find_unique(where={"team_id": resources.prefix})
|
|
assert team is not None and "team_member_budget_id" not in (team.metadata or {}), "denied but team mutated"
|
|
|
|
|
|
async def test_team_update_team_admin_soft_budget_follows_the_proxy_admin_grant(gateway_client, db, resources, actors):
|
|
callers = await _callers(db, actors, resources.prefix)
|
|
await _seed_target(db, actors, callers, "alpha", resources.prefix)
|
|
headers = {"Authorization": f"Bearer {callers[Actor.TEAM_ADMIN].cleartext}"}
|
|
body = {"team_id": resources.prefix, "soft_budget": 7.5}
|
|
|
|
with team_admin_permissions(gateway_client.gateway, ["max_budget"]):
|
|
denied = await gateway_client.post("/team/update", headers=headers, json=body)
|
|
assert denied.status_code == 403, denied.text
|
|
assert "'soft_budget'" in denied.text and "Team admin editable fields" in denied.text, denied.text
|
|
row = await db.db.litellm_teamtable.find_unique(where={"team_id": resources.prefix})
|
|
assert row is not None and row.soft_budget != 7.5, "denied but team mutated"
|
|
|
|
with team_admin_permissions(gateway_client.gateway, ["soft_budget"]):
|
|
allowed = await gateway_client.post("/team/update", headers=headers, json=body)
|
|
assert allowed.status_code == 200, allowed.text
|
|
row = await db.db.litellm_teamtable.find_unique(where={"team_id": resources.prefix})
|
|
assert row is not None and row.soft_budget == 7.5
|
|
|
|
|
|
async def test_team_update_team_admin_cannot_set_soft_budget_on_a_team_they_do_not_administer(
|
|
gateway_client, db, resources, actors
|
|
):
|
|
callers = await _callers(db, actors, resources.prefix)
|
|
await _seed_target(db, actors, callers, "beta", resources.prefix)
|
|
|
|
with team_admin_permissions(gateway_client.gateway, ["soft_budget"]):
|
|
resp = await gateway_client.post(
|
|
"/team/update",
|
|
headers={"Authorization": f"Bearer {callers[Actor.TEAM_ADMIN].cleartext}"},
|
|
json={"team_id": resources.prefix, "soft_budget": 7.5},
|
|
)
|
|
assert resp.status_code == 403, resp.text
|
|
row = await db.db.litellm_teamtable.find_unique(where={"team_id": resources.prefix})
|
|
assert row is not None and row.soft_budget != 7.5, "denied but team mutated"
|
|
|
|
|
|
async def _seed_team_metadata(db, team_id: str, metadata: dict[str, object]) -> None:
|
|
await db.db.litellm_teamtable.update(where={"team_id": team_id}, data={"metadata": json.dumps(metadata)})
|
|
|
|
|
|
async def _stored_team_metadata(db, team_id: str) -> dict[str, object]:
|
|
row = await db.db.litellm_teamtable.find_unique(where={"team_id": team_id})
|
|
assert row is not None
|
|
return dict(row.metadata or {})
|
|
|
|
|
|
_STORED_TEAM_METADATA = {"cost_center": "eng", "region": "eu", "priority": "high"}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"sent",
|
|
[
|
|
pytest.param({**_STORED_TEAM_METADATA, "project": "atlas"}, id="add"),
|
|
pytest.param({**_STORED_TEAM_METADATA, "cost_center": "sales"}, id="edit"),
|
|
pytest.param({"region": "eu", "priority": "high"}, id="delete"),
|
|
],
|
|
)
|
|
async def test_team_update_team_admin_edits_ordinary_metadata_keys(sent, gateway_client, db, resources, actors):
|
|
callers = await _callers(db, actors, resources.prefix)
|
|
await _seed_target(db, actors, callers, "alpha", resources.prefix)
|
|
await _seed_team_metadata(db, resources.prefix, _STORED_TEAM_METADATA)
|
|
headers = {"Authorization": f"Bearer {callers[Actor.TEAM_ADMIN].cleartext}"}
|
|
|
|
with team_admin_permissions(gateway_client.gateway, ["tpm_limit"]):
|
|
denied = await gateway_client.post(
|
|
"/team/update", headers=headers, json={"team_id": resources.prefix, "metadata": sent}
|
|
)
|
|
assert denied.status_code == 403, denied.text
|
|
assert "'metadata'" in denied.text, denied.text
|
|
assert await _stored_team_metadata(db, resources.prefix) == _STORED_TEAM_METADATA
|
|
|
|
with team_admin_permissions(gateway_client.gateway, ["metadata"]):
|
|
allowed = await gateway_client.post(
|
|
"/team/update", headers=headers, json={"team_id": resources.prefix, "metadata": sent}
|
|
)
|
|
assert allowed.status_code == 200, allowed.text
|
|
assert await _stored_team_metadata(db, resources.prefix) == sent
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"sent",
|
|
[
|
|
pytest.param({**_STORED_TEAM_METADATA, "priority": "low"}, id="change"),
|
|
pytest.param({"cost_center": "eng", "region": "eu"}, id="remove"),
|
|
pytest.param({**_STORED_TEAM_METADATA, "callback_settings": {"callbacks": []}}, id="add"),
|
|
],
|
|
)
|
|
async def test_team_update_team_admin_cannot_touch_proxy_controlled_metadata_keys(
|
|
sent, gateway_client, db, resources, actors
|
|
):
|
|
callers = await _callers(db, actors, resources.prefix)
|
|
await _seed_target(db, actors, callers, "alpha", resources.prefix)
|
|
await _seed_team_metadata(db, resources.prefix, _STORED_TEAM_METADATA)
|
|
|
|
with team_admin_permissions(gateway_client.gateway, ["metadata"]):
|
|
resp = await gateway_client.post(
|
|
"/team/update",
|
|
headers={"Authorization": f"Bearer {callers[Actor.TEAM_ADMIN].cleartext}"},
|
|
json={"team_id": resources.prefix, "metadata": sent},
|
|
)
|
|
assert resp.status_code == 403, resp.text
|
|
assert "proxy setting" in resp.text, resp.text
|
|
assert await _stored_team_metadata(db, resources.prefix) == _STORED_TEAM_METADATA
|
|
|
|
|
|
async def test_team_update_team_admin_resending_stored_metadata_is_not_an_edit(gateway_client, db, resources, actors):
|
|
callers = await _callers(db, actors, resources.prefix)
|
|
await _seed_target(db, actors, callers, "alpha", resources.prefix)
|
|
await _seed_team_metadata(db, resources.prefix, _STORED_TEAM_METADATA)
|
|
|
|
with team_admin_permissions(gateway_client.gateway, ["tpm_limit"]):
|
|
resp = await gateway_client.post(
|
|
"/team/update",
|
|
headers={"Authorization": f"Bearer {callers[Actor.TEAM_ADMIN].cleartext}"},
|
|
json={"team_id": resources.prefix, "tpm_limit": 500, "metadata": dict(_STORED_TEAM_METADATA)},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
row = await db.db.litellm_teamtable.find_unique(where={"team_id": resources.prefix})
|
|
assert row is not None and row.tpm_limit == 500
|
|
assert dict(row.metadata or {}) == _STORED_TEAM_METADATA
|
|
|
|
|
|
# Relocation gate — moving a team to a different org. The resources team starts
|
|
# in actors.org_a_id; each scenario relocates it to actors.org_b_id. PROXY_ADMIN bypasses;
|
|
# ORG_B_ADMIN reaches the handler but holds no role on the source team (403);
|
|
# ORG_ADMIN holds the source team but not the destination org (403 from the
|
|
# relocation gate); the team admin is refused by the empty field allow-list and
|
|
# the internal user holds no role at all (403). The relocation-*allowed* branch
|
|
# (caller is org admin of both orgs) is covered by
|
|
# test_team_update_org_relocation_allowed_for_dual_org_admin below.
|
|
_RELOCATION = [
|
|
("proxy_admin", Actor.PROXY_ADMIN, 200),
|
|
("org_b_admin", Actor.ORG_B_ADMIN, 403),
|
|
("org_admin", Actor.ORG_ADMIN, 403),
|
|
("team_admin", Actor.TEAM_ADMIN, 403),
|
|
("internal_user", Actor.INTERNAL_USER, 403),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"actor,expected_status",
|
|
[(a, s) for (_id, a, s) in _RELOCATION],
|
|
ids=[s[0] for s in _RELOCATION],
|
|
)
|
|
async def test_team_update_org_relocation_gate(
|
|
actor: Actor,
|
|
expected_status: int,
|
|
gateway_client,
|
|
db,
|
|
resources,
|
|
actors,
|
|
):
|
|
callers = await _callers(db, actors, resources.prefix)
|
|
await _seed_target(db, actors, callers, "alpha", resources.prefix)
|
|
caller = callers[actor]
|
|
|
|
resp = await gateway_client.post(
|
|
"/team/update",
|
|
headers={"Authorization": f"Bearer {caller.cleartext}"},
|
|
json={"team_id": resources.prefix, "organization_id": actors.org_b_id},
|
|
)
|
|
assert (
|
|
resp.status_code == expected_status
|
|
), f"{actor.value}: {resp.status_code} {resp.text}"
|
|
|
|
row = await db.db.litellm_teamtable.find_unique(
|
|
where={"team_id": resources.prefix}
|
|
)
|
|
assert row is not None
|
|
if expected_status == 200:
|
|
assert row.organization_id == actors.org_b_id
|
|
else:
|
|
assert row.organization_id == actors.org_a_id, "denied but team relocated"
|
|
|
|
|
|
# Phase 4 F6 — explicit pin on the `team_access_denied` 403 detail string
|
|
# when an org_admin clears the destination route gate but fails the source
|
|
# team's org-membership check. The relocation matrix above covers the
|
|
# status; this guard turns a silent rename of the helper's exception detail
|
|
# into a CI red.
|
|
async def test_team_update_org_b_admin_relocation_rejection_detail(
|
|
gateway_client, db, resources, actors
|
|
):
|
|
await _seed_target(db, actors, await _callers(db, actors, resources.prefix), "alpha", resources.prefix)
|
|
resp = await gateway_client.post(
|
|
"/team/update",
|
|
headers={"Authorization": f"Bearer {actors.keys[Actor.ORG_B_ADMIN].cleartext}"},
|
|
json={"team_id": resources.prefix, "organization_id": actors.org_b_id},
|
|
)
|
|
assert resp.status_code == 403, resp.text
|
|
assert "do not have access to this team" in resp.text, resp.text
|
|
|
|
|
|
async def test_team_update_org_relocation_allowed_for_dual_org_admin(
|
|
gateway_client, db, resources, actors
|
|
):
|
|
"""Relocation-allowed branch: a caller who is org admin of BOTH the source
|
|
and destination org may relocate a team between them. Completes the
|
|
_RELOCATION matrix, whose allowed branch PR2 left open — no seeded actor is
|
|
a dual-org admin, so one is minted with create_scratch_actor."""
|
|
actor = await create_scratch_actor(
|
|
db,
|
|
resources.prefix,
|
|
user_role=LitellmUserRoles.ORG_ADMIN.value,
|
|
org_admin_of=(actors.org_a_id, actors.org_b_id),
|
|
)
|
|
team_id = await create_scratch_team(
|
|
db, resources.tag("team"), organization_id=actors.org_a_id
|
|
)
|
|
|
|
resp = await gateway_client.post(
|
|
"/team/update",
|
|
headers={"Authorization": f"Bearer {actor.cleartext}"},
|
|
json={"team_id": team_id, "organization_id": actors.org_b_id},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
row = await db.db.litellm_teamtable.find_unique(where={"team_id": team_id})
|
|
assert row is not None
|
|
assert (
|
|
row.organization_id == actors.org_b_id
|
|
), "dual-org admin relocation not applied"
|