Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion py/src/braintrust/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -1678,13 +1678,14 @@ def compute_metadata():
eprint(f"Failed to load prompt, attempting to fall back to cache: {server_error}")
try:
if id:
return _state._prompt_cache.get(id=id)
return _state._prompt_cache.get(id=id, org_id=_state.org_id)
Copy link
Collaborator

@ibolmo ibolmo Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can revert this line since id will always win maybe even combine the next statement for the same reason into one

else:
return _state._prompt_cache.get(
slug,
version=str(version) if version else "latest",
project_id=project_id,
project_name=project,
org_id=_state.org_id,
)
except Exception as cache_error:
if id:
Expand Down Expand Up @@ -1714,6 +1715,7 @@ def compute_metadata():
_state._prompt_cache.set(
prompt,
id=id,
org_id=_state.org_id,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here combine with the next statement

)
elif slug:
_state._prompt_cache.set(
Expand All @@ -1722,6 +1724,7 @@ def compute_metadata():
version=str(version) if version else "latest",
project_id=project_id,
project_name=project,
org_id=_state.org_id,
)
except Exception as e:
eprint(f"Failed to store prompt in cache: {e}")
Expand Down
24 changes: 19 additions & 5 deletions py/src/braintrust/prompt_cache/prompt_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
2. A persistent disk-based cache that serves as a backing store

This allows for efficient prompt retrieval while maintaining persistence across sessions.
The cache is keyed by project identifier (ID or name), prompt slug, and version.
The cache is keyed by organization ID, project identifier (ID or name), prompt slug, and version.
"""


Expand All @@ -15,22 +15,32 @@


def _create_cache_key(
org_id: str | None,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if org_id is required to prevent cross org issues, it should be required (e.g. not none)

project_id: str | None,
project_name: str | None,
slug: str | None,
version: str = "latest",
id: str | None = None,
) -> str:
"""Creates a unique cache key from project identifier, slug and version, or from ID."""
"""Creates a unique cache key from org ID, project identifier, slug and version, or from prompt ID.

The org_id is included to ensure cache isolation between organizations. Without it,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think the org_id can always be in the cache key

two organizations with the same project name and prompt slug could get each other's
cached prompts, leading to incorrect prompt retrieval.
"""
if id:
# When caching by ID, we don't need project or slug
# When caching by ID, we don't need project or slug (IDs are globally unique)
return f"id:{id}"

prefix = project_id or project_name
if not prefix:
raise ValueError("Either project_id or project_name must be provided")
if not slug:
raise ValueError("Slug must be provided when not using ID")

# Include org_id in cache key if available to ensure cross-org isolation
if org_id:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like login is called L1696 before updating/setting the prompt. you should be able to assume org_id exists .. perhaps raise if not set

return f"{org_id}:{prefix}:{slug}:{version}"
return f"{prefix}:{slug}:{version}"


Expand Down Expand Up @@ -65,6 +75,7 @@ def get(
project_id: str | None = None,
project_name: str | None = None,
id: str | None = None,
org_id: str | None = None,
) -> prompt.PromptSchema:
"""
Retrieve a prompt from the cache.
Expand All @@ -75,6 +86,7 @@ def get(
project_id: The ID of the project containing the prompt.
project_name: The name of the project containing the prompt.
id: The ID of a specific prompt. If provided, slug and project parameters are ignored.
org_id: The ID of the organization. Used to ensure cache isolation between orgs.

Returns:
The cached Prompt object.
Expand All @@ -83,7 +95,7 @@ def get(
ValueError: If neither project_id nor project_name is provided (when not using id).
KeyError: If the prompt is not found in the cache.
"""
cache_key = _create_cache_key(project_id, project_name, slug, version, id)
cache_key = _create_cache_key(org_id, project_id, project_name, slug, version, id)

# First check memory cache.
try:
Expand Down Expand Up @@ -111,6 +123,7 @@ def set(
project_id: str | None = None,
project_name: str | None = None,
id: str | None = None,
org_id: str | None = None,
) -> None:
"""
Store a prompt in the cache.
Expand All @@ -122,12 +135,13 @@ def set(
project_id: The ID of the project containing the prompt.
project_name: The name of the project containing the prompt.
id: The ID of a specific prompt. If provided, slug and project parameters are ignored.
org_id: The ID of the organization. Used to ensure cache isolation between orgs.

Raises:
ValueError: If neither project_id nor project_name is provided (when not using id).
RuntimeError: If there is an error writing to the disk cache.
"""
cache_key = _create_cache_key(project_id, project_name, slug, version, id)
cache_key = _create_cache_key(org_id, project_id, project_name, slug, version, id)

# Update memory cache.
self.memory_cache.set(cache_key, value)
Expand Down
106 changes: 106 additions & 0 deletions py/src/braintrust/prompt_cache/test_prompt_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,112 @@ def test_id_cache_with_disk_persistence(self):
result = self.cache.get(id=prompt_id)
self.assertEqual(result.as_dict(), self.test_prompt.as_dict())

def test_handle_different_orgs_with_same_project_and_slug(self):
"""Test that prompts from different orgs with same project/slug are isolated.

This test verifies the fix for the cross-org cache collision bug where
two organizations with the same project name and prompt slug could get
each other's cached prompts.
"""
org1_prompt = prompt.PromptSchema(
id="org1-prompt-id",
project_id="shared-project-id",
_xact_id="111",
name="shared-prompt",
slug="shared-prompt",
description="This is Org 1's prompt",
prompt_data=prompt.PromptData(),
tags=None,
)

org2_prompt = prompt.PromptSchema(
id="org2-prompt-id",
project_id="shared-project-id",
_xact_id="222",
name="shared-prompt",
slug="shared-prompt",
description="This is Org 2's prompt",
prompt_data=prompt.PromptData(),
tags=None,
)

# Store prompts from different orgs with same project_name and slug
self.cache.set(
org1_prompt,
slug="shared-prompt",
version="latest",
project_name="MyProject",
org_id="org-111",
)
self.cache.set(
org2_prompt,
slug="shared-prompt",
version="latest",
project_name="MyProject",
org_id="org-222",
)

# Retrieve each org's prompt - should get the correct one
result1 = self.cache.get(
slug="shared-prompt",
version="latest",
project_name="MyProject",
org_id="org-111",
)
result2 = self.cache.get(
slug="shared-prompt",
version="latest",
project_name="MyProject",
org_id="org-222",
)

# Verify org isolation - each org gets their own prompt
self.assertEqual(result1.description, "This is Org 1's prompt")
self.assertEqual(result2.description, "This is Org 2's prompt")
self.assertEqual(result1.id, "org1-prompt-id")
self.assertEqual(result2.id, "org2-prompt-id")

def test_org_id_isolation_with_disk_cache(self):
"""Test that org_id isolation works after memory eviction (via disk cache)."""
org1_prompt = prompt.PromptSchema(
id="disk-org1-id",
project_id="project",
_xact_id="111",
name="prompt",
slug="prompt",
description="Org 1 disk prompt",
prompt_data=prompt.PromptData(),
tags=None,
)

org2_prompt = prompt.PromptSchema(
id="disk-org2-id",
project_id="project",
_xact_id="222",
name="prompt",
slug="prompt",
description="Org 2 disk prompt",
prompt_data=prompt.PromptData(),
tags=None,
)

# Store org1's prompt
self.cache.set(org1_prompt, slug="prompt", version="v1", project_name="proj", org_id="org1")

# Fill memory cache to evict org1's prompt (memory cache max_size=2)
self.cache.set(self.test_prompt, slug="filler1", version="v1", project_id="123")
self.cache.set(self.test_prompt, slug="filler2", version="v1", project_id="123")

# Store org2's prompt (should not overwrite org1's cached prompt)
self.cache.set(org2_prompt, slug="prompt", version="v1", project_name="proj", org_id="org2")

# Both should be retrievable with correct isolation
result1 = self.cache.get(slug="prompt", version="v1", project_name="proj", org_id="org1")
result2 = self.cache.get(slug="prompt", version="v1", project_name="proj", org_id="org2")

self.assertEqual(result1.description, "Org 1 disk prompt")
self.assertEqual(result2.description, "Org 2 disk prompt")


if __name__ == "__main__":
unittest.main()
Loading