From 844541a6ad2e6c729878ff70ea8fd36fc0c92465 Mon Sep 17 00:00:00 2001 From: Nimar Date: Thu, 4 Dec 2025 15:34:23 +0100 Subject: [PATCH 1/3] feat(prompts): allow prompt deletion --- langfuse/_client/client.py | 37 +++++ langfuse/api/reference.md | 91 +++++++++++++ langfuse/api/resources/prompts/client.py | 163 +++++++++++++++++++++++ tests/test_prompt.py | 32 ++++- 4 files changed, 322 insertions(+), 1 deletion(-) diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index a3f653ada..86b3f6212 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -78,6 +78,7 @@ from langfuse._utils import _get_timestamp from langfuse._utils.parse_error import handle_fern_exception from langfuse._utils.prompt_cache import PromptCache +from langfuse.api.core.api_error import ApiError from langfuse.api.resources.commons.errors.error import Error from langfuse.api.resources.commons.errors.not_found_error import NotFoundError from langfuse.api.resources.ingestion.types.score_body import ScoreBody @@ -3775,6 +3776,42 @@ def update_prompt( return updated_prompt + def delete_prompt( + self, + name: str, + *, + label: Optional[str] = None, + version: Optional[int] = None, + ) -> None: + """Delete a prompt or specific versions from Langfuse. + + Also invalidates the Langfuse SDK prompt cache for the specified prompt. + + Args: + name: The name of the prompt to delete. + label: Optional label of the prompt to delete. + version: Optional version of the prompt to delete. + + Raises: + NotFoundError: If the prompt does not exist. + Error: If the API request fails. + """ + try: + self.api.prompts.delete( + prompt_name=self._url_encode(name), + label=label, + version=version, + ) + except ApiError as e: + # 204 No Content is a successful deletion, but has empty body + if e.status_code == 204: + pass + else: + raise + + if self._resources is not None: + self._resources.prompt_cache.invalidate(name) + def _url_encode(self, url: str, *, is_url_param: Optional[bool] = False) -> str: # httpx ≥ 0.28 does its own WHATWG-compliant quoting (eg. encodes bare # “%”, “?”, “#”, “|”, … in query/path parts). Re-quoting here would diff --git a/langfuse/api/reference.md b/langfuse/api/reference.md index 7c434f7d2..152d9d5d6 100644 --- a/langfuse/api/reference.md +++ b/langfuse/api/reference.md @@ -5461,6 +5461,97 @@ client.prompts.create( + + + + +
client.prompts.delete(...) +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Delete a prompt or specific versions +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from langfuse.client import FernLangfuse + +client = FernLangfuse( + x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", + x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", + x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", + username="YOUR_USERNAME", + password="YOUR_PASSWORD", + base_url="https://yourhost.com/path/to/api", +) +client.prompts.delete( + prompt_name="promptName", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**prompt_name:** `str` — The name of the prompt + +
+
+ +
+
+ +**label:** `typing.Optional[str]` — Optional label of the prompt to delete + +
+
+ +
+
+ +**version:** `typing.Optional[int]` — Optional version of the prompt to delete + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ +
diff --git a/langfuse/api/resources/prompts/client.py b/langfuse/api/resources/prompts/client.py index 793cf3f77..258a90dda 100644 --- a/langfuse/api/resources/prompts/client.py +++ b/langfuse/api/resources/prompts/client.py @@ -15,6 +15,7 @@ from ..commons.errors.method_not_allowed_error import MethodNotAllowedError from ..commons.errors.not_found_error import NotFoundError from ..commons.errors.unauthorized_error import UnauthorizedError +from ..scim.types.empty_response import EmptyResponse from .types.create_prompt_request import CreatePromptRequest from .types.prompt import Prompt from .types.prompt_meta_list_response import PromptMetaListResponse @@ -292,6 +293,83 @@ def create( raise ApiError(status_code=_response.status_code, body=_response.text) raise ApiError(status_code=_response.status_code, body=_response_json) + def delete( + self, + prompt_name: str, + *, + label: typing.Optional[str] = None, + version: typing.Optional[int] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> EmptyResponse: + """ + Delete a prompt or specific versions + + Parameters + ---------- + prompt_name : str + The name of the prompt + + label : typing.Optional[str] + Optional label of the prompt to delete + + version : typing.Optional[int] + Optional version of the prompt to delete + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + EmptyResponse + + Examples + -------- + from langfuse.client import FernLangfuse + + client = FernLangfuse( + x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", + x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", + x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", + username="YOUR_USERNAME", + password="YOUR_PASSWORD", + base_url="https://yourhost.com/path/to/api", + ) + client.prompts.delete( + prompt_name="promptName", + ) + """ + _response = self._client_wrapper.httpx_client.request( + f"api/public/v2/prompts/{jsonable_encoder(prompt_name)}", + method="DELETE", + params={"label": label, "version": version}, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + return pydantic_v1.parse_obj_as(EmptyResponse, _response.json()) # type: ignore + if _response.status_code == 400: + raise Error(pydantic_v1.parse_obj_as(typing.Any, _response.json())) # type: ignore + if _response.status_code == 401: + raise UnauthorizedError( + pydantic_v1.parse_obj_as(typing.Any, _response.json()) + ) # type: ignore + if _response.status_code == 403: + raise AccessDeniedError( + pydantic_v1.parse_obj_as(typing.Any, _response.json()) + ) # type: ignore + if _response.status_code == 405: + raise MethodNotAllowedError( + pydantic_v1.parse_obj_as(typing.Any, _response.json()) + ) # type: ignore + if _response.status_code == 404: + raise NotFoundError( + pydantic_v1.parse_obj_as(typing.Any, _response.json()) + ) # type: ignore + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, body=_response.text) + raise ApiError(status_code=_response.status_code, body=_response_json) + class AsyncPromptsClient: def __init__(self, *, client_wrapper: AsyncClientWrapper): @@ -585,3 +663,88 @@ async def main() -> None: except JSONDecodeError: raise ApiError(status_code=_response.status_code, body=_response.text) raise ApiError(status_code=_response.status_code, body=_response_json) + + async def delete( + self, + prompt_name: str, + *, + label: typing.Optional[str] = None, + version: typing.Optional[int] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> EmptyResponse: + """ + Delete a prompt or specific versions + + Parameters + ---------- + prompt_name : str + The name of the prompt + + label : typing.Optional[str] + Optional label of the prompt to delete + + version : typing.Optional[int] + Optional version of the prompt to delete + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + EmptyResponse + + Examples + -------- + import asyncio + + from langfuse.client import AsyncFernLangfuse + + client = AsyncFernLangfuse( + x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", + x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", + x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", + username="YOUR_USERNAME", + password="YOUR_PASSWORD", + base_url="https://yourhost.com/path/to/api", + ) + + + async def main() -> None: + await client.prompts.delete( + prompt_name="promptName", + ) + + + asyncio.run(main()) + """ + _response = await self._client_wrapper.httpx_client.request( + f"api/public/v2/prompts/{jsonable_encoder(prompt_name)}", + method="DELETE", + params={"label": label, "version": version}, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + return pydantic_v1.parse_obj_as(EmptyResponse, _response.json()) # type: ignore + if _response.status_code == 400: + raise Error(pydantic_v1.parse_obj_as(typing.Any, _response.json())) # type: ignore + if _response.status_code == 401: + raise UnauthorizedError( + pydantic_v1.parse_obj_as(typing.Any, _response.json()) + ) # type: ignore + if _response.status_code == 403: + raise AccessDeniedError( + pydantic_v1.parse_obj_as(typing.Any, _response.json()) + ) # type: ignore + if _response.status_code == 405: + raise MethodNotAllowedError( + pydantic_v1.parse_obj_as(typing.Any, _response.json()) + ) # type: ignore + if _response.status_code == 404: + raise NotFoundError( + pydantic_v1.parse_obj_as(typing.Any, _response.json()) + ) # type: ignore + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, body=_response.text) + raise ApiError(status_code=_response.status_code, body=_response_json) diff --git a/tests/test_prompt.py b/tests/test_prompt.py index bc3a5b7eb..7490fa843 100644 --- a/tests/test_prompt.py +++ b/tests/test_prompt.py @@ -682,7 +682,7 @@ def test_prompt_end_to_end(): @pytest.fixture def langfuse(): from langfuse._client.resource_manager import LangfuseResourceManager - + langfuse_instance = Langfuse() langfuse_instance.api = Mock() @@ -1485,6 +1485,36 @@ def test_update_prompt(): assert sorted(updated_prompt.labels) == expected_labels +def test_delete_prompt(): + """Test that deleting a prompt works and invalidates cache.""" + langfuse = Langfuse() + prompt_name = f"folder/subfolder/{create_uuid()}" + + langfuse.create_prompt( + name=prompt_name, + prompt="test prompt", + labels=["production"], + ) + + # Fetch to populate cache + cached_prompt = langfuse.get_prompt(prompt_name) + assert cached_prompt.prompt == "test prompt" + + cache_key = PromptCache.generate_cache_key( + prompt_name, version=None, label="production" + ) + assert langfuse._resources.prompt_cache.get(cache_key) is not None + + langfuse.delete_prompt(prompt_name) + + # Verify cache is invalidated + assert langfuse._resources.prompt_cache.get(cache_key) is None + + # Verify prompt is deleted from server + with pytest.raises(NotFoundError): + langfuse.get_prompt(prompt_name, cache_ttl_seconds=0) + + def test_update_prompt_in_folder(): langfuse = Langfuse() prompt_name = f"some-folder/{create_uuid()}" From f17168fbab80e7c5b3b18743fa8407bd6c482120 Mon Sep 17 00:00:00 2001 From: Nimar Date: Thu, 4 Dec 2025 17:07:06 +0100 Subject: [PATCH 2/3] fix empty response --- langfuse/api/reference.md | 8 ++++---- langfuse/api/resources/projects/client.py | 4 ++-- langfuse/api/resources/prompts/client.py | 25 +++++++++++------------ 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/langfuse/api/reference.md b/langfuse/api/reference.md index 152d9d5d6..66c008bb7 100644 --- a/langfuse/api/reference.md +++ b/langfuse/api/reference.md @@ -4482,7 +4482,7 @@ client.organizations.get_organization_api_keys()
-Get Project associated with API key +Get Project associated with API key (requires project-scoped API key). You can use GET /api/public/organizations/projects to get all projects with an organization-scoped key.
@@ -5477,7 +5477,7 @@ client.prompts.create(
-Delete a prompt or specific versions +Delete prompt versions. If neither version nor label is specified, all versions of the prompt are deleted.
@@ -5528,7 +5528,7 @@ client.prompts.delete(
-**label:** `typing.Optional[str]` — Optional label of the prompt to delete +**label:** `typing.Optional[str]` — Optional label to filter deletion. If specified, deletes all prompt versions that have this label.
@@ -5536,7 +5536,7 @@ client.prompts.delete(
-**version:** `typing.Optional[int]` — Optional version of the prompt to delete +**version:** `typing.Optional[int]` — Optional version to filter deletion. If specified, deletes only this specific version of the prompt.
diff --git a/langfuse/api/resources/projects/client.py b/langfuse/api/resources/projects/client.py index 9af7cfdfa..5af232dfb 100644 --- a/langfuse/api/resources/projects/client.py +++ b/langfuse/api/resources/projects/client.py @@ -32,7 +32,7 @@ def get( self, *, request_options: typing.Optional[RequestOptions] = None ) -> Projects: """ - Get Project associated with API key + Get Project associated with API key (requires project-scoped API key). You can use GET /api/public/organizations/projects to get all projects with an organization-scoped key. Parameters ---------- @@ -545,7 +545,7 @@ async def get( self, *, request_options: typing.Optional[RequestOptions] = None ) -> Projects: """ - Get Project associated with API key + Get Project associated with API key (requires project-scoped API key). You can use GET /api/public/organizations/projects to get all projects with an organization-scoped key. Parameters ---------- diff --git a/langfuse/api/resources/prompts/client.py b/langfuse/api/resources/prompts/client.py index 258a90dda..b8d6f31d4 100644 --- a/langfuse/api/resources/prompts/client.py +++ b/langfuse/api/resources/prompts/client.py @@ -15,7 +15,6 @@ from ..commons.errors.method_not_allowed_error import MethodNotAllowedError from ..commons.errors.not_found_error import NotFoundError from ..commons.errors.unauthorized_error import UnauthorizedError -from ..scim.types.empty_response import EmptyResponse from .types.create_prompt_request import CreatePromptRequest from .types.prompt import Prompt from .types.prompt_meta_list_response import PromptMetaListResponse @@ -300,9 +299,9 @@ def delete( label: typing.Optional[str] = None, version: typing.Optional[int] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> EmptyResponse: + ) -> None: """ - Delete a prompt or specific versions + Delete prompt versions. If neither version nor label is specified, all versions of the prompt are deleted. Parameters ---------- @@ -310,17 +309,17 @@ def delete( The name of the prompt label : typing.Optional[str] - Optional label of the prompt to delete + Optional label to filter deletion. If specified, deletes all prompt versions that have this label. version : typing.Optional[int] - Optional version of the prompt to delete + Optional version to filter deletion. If specified, deletes only this specific version of the prompt. request_options : typing.Optional[RequestOptions] Request-specific configuration. Returns ------- - EmptyResponse + None Examples -------- @@ -346,7 +345,7 @@ def delete( ) try: if 200 <= _response.status_code < 300: - return pydantic_v1.parse_obj_as(EmptyResponse, _response.json()) # type: ignore + return if _response.status_code == 400: raise Error(pydantic_v1.parse_obj_as(typing.Any, _response.json())) # type: ignore if _response.status_code == 401: @@ -671,9 +670,9 @@ async def delete( label: typing.Optional[str] = None, version: typing.Optional[int] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> EmptyResponse: + ) -> None: """ - Delete a prompt or specific versions + Delete prompt versions. If neither version nor label is specified, all versions of the prompt are deleted. Parameters ---------- @@ -681,17 +680,17 @@ async def delete( The name of the prompt label : typing.Optional[str] - Optional label of the prompt to delete + Optional label to filter deletion. If specified, deletes all prompt versions that have this label. version : typing.Optional[int] - Optional version of the prompt to delete + Optional version to filter deletion. If specified, deletes only this specific version of the prompt. request_options : typing.Optional[RequestOptions] Request-specific configuration. Returns ------- - EmptyResponse + None Examples -------- @@ -725,7 +724,7 @@ async def main() -> None: ) try: if 200 <= _response.status_code < 300: - return pydantic_v1.parse_obj_as(EmptyResponse, _response.json()) # type: ignore + return if _response.status_code == 400: raise Error(pydantic_v1.parse_obj_as(typing.Any, _response.json())) # type: ignore if _response.status_code == 401: From 1ca5a2dbfd18bf9bf755e1d15a5af4af6c86ca08 Mon Sep 17 00:00:00 2001 From: Nimar Date: Thu, 4 Dec 2025 17:10:23 +0100 Subject: [PATCH 3/3] fix --- langfuse/_client/client.py | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index 86b3f6212..2b620e789 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -78,7 +78,6 @@ from langfuse._utils import _get_timestamp from langfuse._utils.parse_error import handle_fern_exception from langfuse._utils.prompt_cache import PromptCache -from langfuse.api.core.api_error import ApiError from langfuse.api.resources.commons.errors.error import Error from langfuse.api.resources.commons.errors.not_found_error import NotFoundError from langfuse.api.resources.ingestion.types.score_body import ScoreBody @@ -3783,31 +3782,24 @@ def delete_prompt( label: Optional[str] = None, version: Optional[int] = None, ) -> None: - """Delete a prompt or specific versions from Langfuse. + """Delete a prompt with all its versions or selected versions from Langfuse. - Also invalidates the Langfuse SDK prompt cache for the specified prompt. + The Langfuse SDK prompt cache is invalidated for all cached versions with the specified name. Args: - name: The name of the prompt to delete. - label: Optional label of the prompt to delete. + name: The name of the prompt to delete prompt versions for. If neither `label` nor `version` are specified, all prompt versions will be deleted. + label: Label of the prompt versions to delete. All prompt versions with the given label will be deleted. version: Optional version of the prompt to delete. Raises: NotFoundError: If the prompt does not exist. Error: If the API request fails. """ - try: - self.api.prompts.delete( - prompt_name=self._url_encode(name), - label=label, - version=version, - ) - except ApiError as e: - # 204 No Content is a successful deletion, but has empty body - if e.status_code == 204: - pass - else: - raise + self.api.prompts.delete( + prompt_name=self._url_encode(name), + label=label, + version=version, + ) if self._resources is not None: self._resources.prompt_cache.invalidate(name)