litellm/tests/integration/configuration/test_callback_delete.py
devin-ai-integration[bot] e45f2737d3
fix(proxy)!: delete callbacks configured under callbacks or failure_callback (#42740)
* fix(proxy): delete callbacks configured under callbacks or failure_callback

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(e2e): assert on configured callback rows and use a callback absent from CI configs

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(e2e): follow logging suite conventions for callback delete tests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(proxy): flatten remaining callbacks with a helper generator

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(proxy): type callback delete helpers to satisfy basedpyright budget

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(proxy): save callback config without mutating the loaded dict

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(proxy): delete callbacks from every litellm_settings list GET shows

POST /config/callback/delete only searched success_callback while
GET /get/config/callbacks also lists failure_callback and callbacks, so
the Admin UI Delete returned 404 for those rows

Remove the name from every list that holds it, matching entries
case-insensitively, and skip lists the config file owns when the
database owns another copy so the save is not refused

Replace the e2e test with an integration test: the shared e2e stack
config owns litellm_settings.callbacks, so it cannot exercise that list

* test(proxy): cover config-owned success_callback on callback delete and wait on runtime state

Add the mirror mixed-ownership case where the config file sets
success_callback and the database owns failure_callback, which main
refused with a 400, plus a string-valued list case

Poll GET listings and /active/callbacks instead of reading them once, so
a request served by a worker that has not synced yet cannot fail the
test, and reuse the existing callback list key map in the route

* test(proxy): annotate callback delete test helper locals with Final

* fix(proxy): save callback delete through the config it read so concurrent settings edits are kept

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: yucheng-berri <293926326+yucheng-berri@users.noreply.github.com>
2026-10-16 09:22:22 -07:00

351 lines
14 KiB
Python

import json
import os
import uuid
from collections.abc import Generator, Iterator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from types import MappingProxyType
from typing import Final
import pytest
import yaml
from integration._support.client import JSON_OBJECT, Gateway, JsonValue, eventually, list_value, object_value
from integration._support.database import read_rows, scratch_database
from integration._support.process import owned_proxy
from integration._support.wire import Reply, Request, Wire, wire_server
CALLBACK: Final = "generic_api"
SIBLING: Final = "langsmith"
LIST_TYPES: Final = MappingProxyType(
{
"success_callback": "success",
"failure_callback": "failure",
"callbacks": "success_and_failure",
}
)
SINK_ENVIRONMENT: Final = MappingProxyType(
{
"GENERIC_LOGGER_ENDPOINT": "http://127.0.0.1:9/unused",
"LANGSMITH_API_KEY": "synthetic-langsmith-key",
"LANGSMITH_BASE_URL": "http://127.0.0.1:9",
}
)
NOT_FOUND: Final = f"Callback '{CALLBACK}' not found in active configuration"
WORKER_SYNC_SECONDS: Final = 40
@dataclass(frozen=True, slots=True)
class Rig:
proxy: Gateway
database_url: str
@contextmanager
def _rig(gateway: Gateway, tmp_path: Path, file_settings: Mapping[str, JsonValue]) -> Generator[Rig]:
config: Final = JSON_OBJECT.validate_python(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()))
path: Final = tmp_path / f"callback_delete_{uuid.uuid4().hex}.yaml"
path.write_text(
yaml.safe_dump({**config, "litellm_settings": {**object_value(config["litellm_settings"]), **file_settings}})
)
with (
scratch_database() as database_url,
owned_proxy(
gateway,
tmp_path,
{"DATABASE_URL": database_url, **SINK_ENVIRONMENT},
config=path,
remove_environment=("DATABASE_URL_READ_REPLICA",),
workers=2,
) as proxy,
):
yield Rig(proxy, database_url)
@pytest.fixture
def rig(gateway: Gateway, tmp_path: Path) -> Iterator[Rig]:
with _rig(gateway, tmp_path, {}) as created:
yield created
def _configured(proxy: Gateway) -> tuple[tuple[str, str], ...]:
rows: Final = proxy.get("/get/config/callbacks")["callbacks"]
assert isinstance(rows, list)
return tuple(
sorted(
(str(row["name"]), str(row["type"]))
for row in (object_value(entry) for entry in rows)
if not row.get("read_only")
)
)
def _listed(proxy: Gateway, expected: tuple[tuple[str, str], ...]) -> None:
eventually(lambda: _configured(proxy), lambda rows: rows == expected, seconds=WORKER_SYNC_SECONDS)
def _stored_settings(database_url: str) -> Mapping[str, JsonValue]:
rows: Final = read_rows(
'SELECT param_value FROM "LiteLLM_Config" WHERE param_name = %s',
("litellm_settings",),
database_url=database_url,
)
assert len(rows) == 1, rows
value: Final = rows[0]["param_value"]
return object_value(json.loads(value) if isinstance(value, str) else value)
def _configure(
proxy: Gateway, settings: Mapping[str, JsonValue], environment: Mapping[str, JsonValue] | None = None
) -> None:
body: Final[Mapping[str, JsonValue]] = {
"litellm_settings": dict(settings),
"environment_variables": None if environment is None else dict(environment),
}
assert proxy.post("/config/update", body) == {"message": "Config updated successfully"}
def _delete(proxy: Gateway, name: str) -> tuple[int, Mapping[str, JsonValue]]:
response: Final = proxy.request("POST", "/config/callback/delete", {"callback_name": name})
return response.status_code, JSON_OBJECT.validate_json(response.content)
@pytest.mark.parametrize("list_key", tuple(LIST_TYPES))
def test_callback_listed_under_any_settings_list_is_deleted_from_the_listing_and_the_store(
rig: Rig, list_key: str
) -> None:
_configure(rig.proxy, {list_key: [CALLBACK]})
_listed(rig.proxy, ((CALLBACK, LIST_TYPES[list_key]),))
status, body = _delete(rig.proxy, CALLBACK)
assert status == 200, body
assert (body["removed_callback"], body["remaining_callbacks"]) == (CALLBACK, [])
assert _stored_settings(rig.database_url)[list_key] == []
_listed(rig.proxy, ())
def test_callback_in_both_success_and_failure_lists_is_removed_from_both_in_one_delete(rig: Rig) -> None:
_configure(rig.proxy, {"success_callback": [CALLBACK], "failure_callback": [CALLBACK]})
_listed(rig.proxy, ((CALLBACK, "failure"), (CALLBACK, "success")))
status, body = _delete(rig.proxy, CALLBACK)
assert status == 200, body
stored: Final = _stored_settings(rig.database_url)
assert (stored["success_callback"], stored["failure_callback"]) == ([], [])
_listed(rig.proxy, ())
def test_delete_keeps_other_callbacks_and_reports_what_is_left_in_the_list_it_edited(rig: Rig) -> None:
_configure(rig.proxy, {"callbacks": [SIBLING, CALLBACK], "failure_callback": [SIBLING]})
_listed(rig.proxy, ((CALLBACK, "success_and_failure"), (SIBLING, "failure"), (SIBLING, "success_and_failure")))
status, body = _delete(rig.proxy, CALLBACK)
assert status == 200, body
assert body["remaining_callbacks"] == [SIBLING]
stored: Final = _stored_settings(rig.database_url)
assert (stored["callbacks"], stored["failure_callback"]) == ([SIBLING], [SIBLING])
_listed(rig.proxy, ((SIBLING, "failure"), (SIBLING, "success_and_failure")))
def test_delete_matches_the_callback_name_case_insensitively(rig: Rig) -> None:
_configure(rig.proxy, {"failure_callback": [CALLBACK.upper()], "callbacks": [CALLBACK]})
_listed(rig.proxy, ((CALLBACK.upper(), "failure"), (CALLBACK, "success_and_failure")))
status, body = _delete(rig.proxy, CALLBACK.upper())
assert status == 200, body
assert body["removed_callback"] == CALLBACK
_listed(rig.proxy, ())
def test_delete_of_an_unconfigured_callback_is_a_404_and_changes_nothing(rig: Rig) -> None:
_configure(rig.proxy, {"callbacks": [SIBLING]})
_listed(rig.proxy, ((SIBLING, "success_and_failure"),))
before: Final = _stored_settings(rig.database_url)
status, body = _delete(rig.proxy, CALLBACK)
assert (status, body) == (404, {"detail": {"error": NOT_FOUND}})
assert _stored_settings(rig.database_url) == before
_listed(rig.proxy, ((SIBLING, "success_and_failure"),))
@pytest.mark.parametrize(
("file_key", "database_key"), (("failure_callback", "success_callback"), ("success_callback", "failure_callback"))
)
def test_delete_removes_the_database_copy_and_keeps_the_copy_the_config_file_sets(
gateway: Gateway, tmp_path: Path, file_key: str, database_key: str
) -> None:
with _rig(gateway, tmp_path, {file_key: [CALLBACK]}) as file_owned:
_configure(file_owned.proxy, {database_key: [CALLBACK]})
_listed(file_owned.proxy, ((CALLBACK, "failure"), (CALLBACK, "success")))
status, body = _delete(file_owned.proxy, CALLBACK)
assert status == 200, body
assert body["remaining_callbacks"] == []
assert _stored_settings(file_owned.database_url)[database_key] == []
_listed(file_owned.proxy, ((CALLBACK, LIST_TYPES[file_key]),))
def test_delete_of_a_callback_only_the_config_file_lists_is_refused_and_names_the_file(
gateway: Gateway, tmp_path: Path
) -> None:
with _rig(gateway, tmp_path, {"callbacks": [CALLBACK]}) as file_owned:
_listed(file_owned.proxy, ((CALLBACK, "success_and_failure"),))
status, body = _delete(file_owned.proxy, CALLBACK)
assert status == 400, body
detail: Final = object_value(body["detail"])
assert (detail["keys"], detail["section"]) == (["callbacks"], "litellm_settings")
assert "set in the config file" in str(detail["error"])
_listed(file_owned.proxy, ((CALLBACK, "success_and_failure"),))
def _completion(request: Request) -> Reply:
message: Final = object_value(list_value(JSON_OBJECT.validate_json(request.body)["messages"])[0])
if str(message["content"]).endswith("-failure"):
return Reply(
status=400,
body=json.dumps(
{"error": {"type": "invalid_request_error", "code": "synthetic", "message": "synthetic failure"}}
).encode(),
)
return Reply(
body=json.dumps(
{
"id": "chatcmpl-" + uuid.uuid4().hex,
"object": "chat.completion",
"created": 1,
"model": "gpt-4o-mini",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 3, "completion_tokens": 1, "total_tokens": 4},
}
).encode()
)
@dataclass(slots=True)
class Sink:
wire: Wire
bodies: list[bytes] # mutable-ok: drain() consumes the queue, later reads keep earlier batches
def received(self, markers: tuple[str, ...]) -> bool:
self.bodies.extend(request.body for request in self.wire.drain())
return all(any(marker.encode() in body for body in self.bodies) for marker in markers)
@dataclass(frozen=True, slots=True)
class Pair:
first: Gateway
peer: Gateway
database_url: str
provider: Wire
generic: Sink
langsmith: Sink
def running(self, name: str) -> tuple[bool, bool]:
return (_runs(self.first, name), _runs(self.peer, name))
def _runs(proxy: Gateway, name: str) -> bool:
active: Final = list_value(proxy.get("/active/callbacks")["all_litellm_callbacks"])
return any(name in str(entry).lower() for entry in active)
@contextmanager
def _pair(gateway: Gateway, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[Pair]:
with (
scratch_database() as database_url,
wire_server(_completion) as provider,
wire_server(lambda _: Reply()) as generic,
wire_server(lambda _: Reply()) as langsmith,
):
monkeypatch.setenv("DATABASE_URL", database_url)
environment: Final = {
"DATABASE_URL": database_url,
"DEFAULT_FLUSH_INTERVAL_SECONDS": "1",
"LITELLM_STORE_AUDIT_LOGS": "True",
}
removed: Final = (
"DATABASE_URL_READ_REPLICA",
"GENERIC_LOGGER_ENDPOINT",
"GENERIC_LOGGER_HEADERS",
"LANGSMITH_API_KEY",
"LANGSMITH_BASE_URL",
)
with (
owned_proxy(gateway, tmp_path, environment, remove_environment=removed) as first,
owned_proxy(gateway, tmp_path, environment, remove_environment=removed) as peer,
):
yield Pair(first, peer, database_url, provider, Sink(generic, []), Sink(langsmith, []))
def _send_on_both(pair: Pair, model: str, key: str, marker: str, outcome: str) -> tuple[str, str]:
markers: Final = (f"{marker}-first-{outcome}", f"{marker}-peer-{outcome}")
for proxy, text in zip((pair.first, pair.peer), markers, strict=True):
response = proxy.request(
"POST", "/v1/chat/completions", {"model": model, "messages": [{"role": "user", "content": text}]}, key=key
)
assert response.status_code == (400 if outcome == "failure" else 200), response.text
return markers
@pytest.mark.parametrize(("list_key", "outcome"), (("failure_callback", "failure"), ("callbacks", "success")))
def test_deleted_callback_stops_receiving_events_on_both_processes(
gateway: Gateway, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, list_key: str, outcome: str
) -> None:
assert os.environ.get("LITELLM_LICENSE"), "LITELLM_LICENSE must be set: audit logs are an enterprise feature"
marker: Final = "callback-delete-" + uuid.uuid4().hex
with _pair(gateway, tmp_path, monkeypatch) as pair, pair.first.scenario() as scenario:
_configure(
pair.first,
{list_key: [SIBLING, CALLBACK]},
{
"GENERIC_LOGGER_ENDPOINT": pair.generic.wire.url,
"LANGSMITH_API_KEY": "langsmith-key-" + marker,
"LANGSMITH_BASE_URL": pair.langsmith.wire.url,
},
)
eventually(lambda: pair.running(SIBLING), lambda running: running == (True, True), seconds=WORKER_SYNC_SECONDS)
model: Final = scenario.model(api_base=pair.provider.url + "/v1")
key: Final = scenario.key(models=[model])
before: Final = _send_on_both(pair, model, key, marker + "-before", outcome)
eventually(lambda: pair.generic.received(before), bool, seconds=25)
eventually(lambda: pair.langsmith.received(before), bool, seconds=25)
status, body = _delete(pair.first, SIBLING)
assert status == 200, body
assert (body["removed_callback"], body["remaining_callbacks"]) == (SIBLING, [CALLBACK])
assert _stored_settings(pair.database_url)[list_key] == [CALLBACK]
audit: Final = eventually(
lambda: read_rows(
'SELECT before_value, updated_values FROM "LiteLLM_AuditLog" WHERE object_id = %s AND action = %s',
("litellm_settings", "deleted"),
database_url=pair.database_url,
),
bool,
seconds=10,
)
assert len(audit) == 1, audit
before_value: Final = list_value(object_value(audit[0]["before_value"])[list_key])
assert sorted(str(name) for name in before_value) == sorted([SIBLING, CALLBACK]), audit
assert audit[0]["updated_values"] == {list_key: [CALLBACK]}, audit
eventually(
lambda: pair.running(SIBLING), lambda running: running == (False, False), seconds=WORKER_SYNC_SECONDS
)
after: Final = _send_on_both(pair, model, key, marker + "-after", outcome)
eventually(lambda: pair.generic.received(after), bool, seconds=25)
_configure(pair.first, {list_key: [CALLBACK, SIBLING]})
eventually(lambda: pair.running(SIBLING), lambda running: running == (True, True), seconds=WORKER_SYNC_SECONDS)
barrier: Final = _send_on_both(pair, model, key, marker + "-barrier", outcome)
eventually(lambda: pair.langsmith.received(barrier), bool, seconds=25)
leaked: Final = tuple(text for text in after if any(text.encode() in body for body in pair.langsmith.bodies))
assert leaked == (), leaked