From 3a7c498eff64da87fefb72b28acc72a75a1e6b04 Mon Sep 17 00:00:00 2001 From: Deepanshu Lulla Date: Tue, 28 Oct 2025 17:46:50 -0400 Subject: [PATCH] Add GitlabPromptCache and enable subfolder access (#15712) * Add GitlabPromptCache and enable subfolder access * Add GitlabPromptCache and enable subfolder access * Add GitlabPromptCache and enable subfolder access --------- Co-authored-by: deepanshu --- litellm/integrations/gitlab/__init__.py | 19 +- .../gitlab/gitlab_prompt_manager.py | 180 ++++++++- .../gitlab/test_gitlab_integration.py | 20 +- .../gitlab/test_gitlab_prompt_manager.py | 363 +++++++++++++++++- 4 files changed, 554 insertions(+), 28 deletions(-) diff --git a/litellm/integrations/gitlab/__init__.py b/litellm/integrations/gitlab/__init__.py index cd22afc2ba..c73a23b687 100644 --- a/litellm/integrations/gitlab/__init__.py +++ b/litellm/integrations/gitlab/__init__.py @@ -8,7 +8,7 @@ if TYPE_CHECKING: from litellm.types.prompts.init_prompts import SupportedPromptIntegrations from litellm.integrations.custom_prompt_management import CustomPromptManagement from litellm.types.prompts.init_prompts import PromptSpec, PromptLiteLLMParams -from .gitlab_prompt_manager import GitLabPromptManager +from .gitlab_prompt_manager import GitLabPromptManager, GitLabPromptCache # Global instances global_gitlab_config: Optional[dict] = None @@ -16,13 +16,13 @@ global_gitlab_config: Optional[dict] = None def set_global_gitlab_config(config: dict) -> None: """ - Set the global BitBucket configuration for prompt management. + Set the global gitlab configuration for prompt management. Args: - config: Dictionary containing BitBucket configuration - - workspace: BitBucket workspace name + config: Dictionary containing gitlab configuration + - workspace: gitlab workspace name - repository: Repository name - - access_token: BitBucket access token + - access_token: gitlab access token - branch: Branch to fetch prompts from (default: main) """ import litellm @@ -34,7 +34,7 @@ def prompt_initializer( litellm_params: "PromptLiteLLMParams", prompt_spec: "PromptSpec" ) -> "CustomPromptManagement": """ - Initialize a prompt from a BitBucket repository. + Initialize a prompt from a Gitlab repository. """ gitlab_config = getattr(litellm_params, "gitlab_config", None) prompt_id = getattr(litellm_params, "prompt_id", None) @@ -42,16 +42,16 @@ def prompt_initializer( if not gitlab_config: raise ValueError( - "bitbucket_config is required for BitBucket prompt integration" + "gitlab_config is required for gitlab prompt integration" ) try: - bitbucket_prompt_manager = GitLabPromptManager( + gitlab_prompt_manager = GitLabPromptManager( gitlab_config=gitlab_config, prompt_id=prompt_id, ) - return bitbucket_prompt_manager + return gitlab_prompt_manager except Exception as e: raise e @@ -90,6 +90,7 @@ prompt_initializer_registry = { # Export public API __all__ = [ "GitLabPromptManager", + "GitLabPromptCache", "set_global_gitlab_config", "global_gitlab_config", ] diff --git a/litellm/integrations/gitlab/gitlab_prompt_manager.py b/litellm/integrations/gitlab/gitlab_prompt_manager.py index b782f10ccc..37013273cb 100644 --- a/litellm/integrations/gitlab/gitlab_prompt_manager.py +++ b/litellm/integrations/gitlab/gitlab_prompt_manager.py @@ -12,10 +12,24 @@ from litellm.integrations.prompt_management_base import ( ) from litellm.types.llms.openai import AllMessageValues from litellm.types.utils import StandardCallbackDynamicParams - from litellm.integrations.gitlab.gitlab_client import GitLabClient +GITLAB_PREFIX = "gitlab::" + +def encode_prompt_id(raw_id: str) -> str: + """Convert GitLab path IDs like 'invoice/extract' → 'gitlab::invoice::extract'""" + if raw_id.startswith(GITLAB_PREFIX): + return raw_id # already encoded + return f"{GITLAB_PREFIX}{raw_id.replace('/', '::')}" + +def decode_prompt_id(encoded_id: str) -> str: + """Convert 'gitlab::invoice::extract' → 'invoice/extract'""" + if not encoded_id.startswith(GITLAB_PREFIX): + return encoded_id + return encoded_id[len(GITLAB_PREFIX):].replace("::", "/") + + class GitLabPromptTemplate: def __init__( self, @@ -87,6 +101,7 @@ class GitLabTemplateManager: def _id_to_repo_path(self, prompt_id: str) -> str: """Map a prompt_id to a repo path (respects prompts_path and adds .prompt).""" + prompt_id = decode_prompt_id(prompt_id) if self.prompts_path: return f"{self.prompts_path}/{prompt_id}.prompt" return f"{prompt_id}.prompt" @@ -101,26 +116,27 @@ class GitLabTemplateManager: path = path[len(self.prompts_path.strip("/")) + 1 :] if path.endswith(".prompt"): path = path[: -len(".prompt")] - return path + return encode_prompt_id(path) # ---------- loading ---------- def _load_prompt_from_gitlab(self, prompt_id: str, *, ref: Optional[str] = None) -> None: """Load a specific .prompt file from GitLab (scoped under prompts_path if set).""" try: + # prompt_id = decode_prompt_id(prompt_id) file_path = self._id_to_repo_path(prompt_id) prompt_content = self.gitlab_client.get_file_content(file_path, ref=ref) if prompt_content: template = self._parse_prompt_file(prompt_content, prompt_id) self.prompts[prompt_id] = template except Exception as e: - raise Exception(f"Failed to load prompt '{prompt_id}' from GitLab: {e}") + raise Exception(f"Failed to load prompt '{encode_prompt_id(prompt_id)}' from GitLab: {e}") def load_all_prompts(self, *, recursive: bool = True) -> List[str]: """ Eagerly load all .prompt files from prompts_path. Returns loaded IDs. """ - files = self.list_templates(recursive=recursive) # reuse logic + files = self.list_templates(recursive=recursive) loaded: List[str] = [] for pid in files: if pid not in self.prompts: @@ -195,9 +211,6 @@ class GitLabTemplateManager: return self.prompts.get(template_id) def list_templates(self, *, recursive: bool = True) -> List[str]: - """ - List available prompt IDs discovered under prompts_path (no extension, relative to prompts_path). - """ """ List available prompt IDs under prompts_path (no extension). Compatible with both list_files signatures: @@ -248,7 +261,7 @@ class GitLabPromptManager(CustomPromptManagement): "access_token": "glpat_***", "tag": "v1.2.3", # optional; takes precedence "branch": "main", # default fallback - "prompts_path": "prompts/chat" # <--- NEW + "prompts_path": "prompts/chat" } """ @@ -438,9 +451,11 @@ class GitLabPromptManager(CustomPromptManagement): prompt_version: Optional[int] = None, ) -> PromptManagementClient: try: - if prompt_id not in self.prompt_manager.prompts: + decoded_id = decode_prompt_id(prompt_id) + if decoded_id not in self.prompt_manager.prompts: git_ref = getattr(dynamic_callback_params, "extra", {}).get("git_ref") if hasattr(dynamic_callback_params, "extra") else None - self.prompt_manager._load_prompt_from_gitlab(prompt_id, ref=git_ref) + self.prompt_manager._load_prompt_from_gitlab(decoded_id, ref=git_ref) + rendered_prompt, prompt_metadata = self.get_prompt_template( prompt_id, prompt_variables @@ -486,3 +501,148 @@ class GitLabPromptManager(CustomPromptManagement): prompt_label, prompt_version, ) + + +class GitLabPromptCache: + """ + Cache all .prompt files from a GitLab repo into memory. + + - Keys are the *repo file paths* (e.g. "prompts/chat/greet/hi.prompt") + mapped to JSON-like dicts containing content + metadata. + - Also exposes a by-ID view (ID == path relative to prompts_path without ".prompt", + e.g. "greet/hi"). + + Usage: + + cfg = { + "project": "group/subgroup/repo", + "access_token": "glpat_***", + "prompts_path": "prompts/chat", # optional, can be empty for repo root + # "branch": "main", # default is "main" + # "tag": "v1.2.3", # takes precedence over branch + # "base_url": "https://gitlab.com/api/v4" # default + } + + cache = GitLabPromptCache(cfg) + cache.load_all() # fetch + parse all .prompt files + + print(cache.list_files()) # repo file paths + print(cache.list_ids()) # template IDs relative to prompts_path + + prompt_json = cache.get_by_file("prompts/chat/greet/hi.prompt") + prompt_json2 = cache.get_by_id("greet/hi") + + # If GitLab content changes and you want to refresh: + cache.reload() # re-scan and refresh all + """ + + def __init__( + self, + gitlab_config: Dict[str, Any], + *, + ref: Optional[str] = None, + gitlab_client: Optional[GitLabClient] = None, + ) -> None: + # Build a PromptManager (which internally builds TemplateManager + Client) + self.prompt_manager = GitLabPromptManager( + gitlab_config=gitlab_config, + prompt_id=None, + ref=ref, + gitlab_client=gitlab_client, + ) + self.template_manager: GitLabTemplateManager = self.prompt_manager.prompt_manager + + # In-memory stores + self._by_file: Dict[str, Dict[str, Any]] = {} + self._by_id: Dict[str, Dict[str, Any]] = {} + + # ------------------------- + # Public API + # ------------------------- + + def load_all(self, *, recursive: bool = True) -> Dict[str, Dict[str, Any]]: + """ + Scan GitLab for all .prompt files under prompts_path, load and parse each, + and return the mapping of repo file path -> JSON-like dict. + """ + ids = self.template_manager.list_templates(recursive=recursive) # IDs relative to prompts_path + for pid in ids: + # Ensure template is loaded into TemplateManager + if pid not in self.template_manager.prompts: + self.template_manager._load_prompt_from_gitlab(pid) + + tmpl = self.template_manager.get_template(pid) + if tmpl is None: + # If something raced/failed, try once more + self.template_manager._load_prompt_from_gitlab(pid) + tmpl = self.template_manager.get_template(pid) + if tmpl is None: + continue + + file_path = self.template_manager._id_to_repo_path(pid) # "prompts/chat/..../file.prompt" + entry = self._template_to_json(pid, tmpl) + + self._by_file[file_path] = entry + # prefixed_id = pid if pid.startswith("gitlab::") else f"gitlab::{pid}" + encoded_id = encode_prompt_id(pid) + self._by_id[encoded_id] = entry + # self._by_id[pid] = entry + + return self._by_id + + def reload(self, *, recursive: bool = True) -> Dict[str, Dict[str, Any]]: + """Clear the cache and re-load from GitLab.""" + self._by_file.clear() + self._by_id.clear() + return self.load_all(recursive=recursive) + + def list_files(self) -> List[str]: + """Return the repo file paths currently cached.""" + return list(self._by_file.keys()) + + def list_ids(self) -> List[str]: + """Return the template IDs (relative to prompts_path, without extension) currently cached.""" + return list(self._by_id.keys()) + + def get_by_file(self, file_path: str) -> Optional[Dict[str, Any]]: + """Get a cached prompt JSON by repo file path.""" + return self._by_file.get(file_path) + + def get_by_id(self, prompt_id: str) -> Optional[Dict[str, Any]]: + """Get a cached prompt JSON by prompt ID (relative to prompts_path).""" + if prompt_id in self._by_id: + return self._by_id[prompt_id] + + # Try normalized forms + decoded = decode_prompt_id(prompt_id) + encoded = encode_prompt_id(decoded) + + return self._by_id.get(encoded) or self._by_id.get(decoded) + + # ------------------------- + # Internals + # ------------------------- + + def _template_to_json(self, prompt_id: str, tmpl: GitLabPromptTemplate) -> Dict[str, Any]: + """ + Normalize a GitLabPromptTemplate into a JSON-like dict that is easy to serialize. + """ + # Safer copy of metadata (avoid accidental mutation) + md = dict(tmpl.metadata or {}) + + # Pull standard fields (also present in metadata sometimes) + model = tmpl.model + temperature = tmpl.temperature + max_tokens = tmpl.max_tokens + optional_params = dict(tmpl.optional_params or {}) + + return { + "id": prompt_id, # e.g. "greet/hi" + "path": self.template_manager._id_to_repo_path(prompt_id), # e.g. "prompts/chat/greet/hi.prompt" + "content": tmpl.content, # rendered content (without frontmatter) + "metadata": md, # parsed frontmatter + "model": model, + "temperature": temperature, + "max_tokens": max_tokens, + "optional_params": optional_params, + } \ No newline at end of file diff --git a/tests/test_litellm/integrations/gitlab/test_gitlab_integration.py b/tests/test_litellm/integrations/gitlab/test_gitlab_integration.py index 6ad7901459..ae0de4f464 100644 --- a/tests/test_litellm/integrations/gitlab/test_gitlab_integration.py +++ b/tests/test_litellm/integrations/gitlab/test_gitlab_integration.py @@ -1,11 +1,14 @@ import os import sys -from unittest.mock import MagicMock, patch + import pytest -sys.path.insert(0, os.path.abspath("../../..")) +sys.path.insert( + 0, os.path.abspath("../../..") +) # Adds the parent directory to the system path +from unittest.mock import MagicMock, patch from litellm.integrations.gitlab.gitlab_prompt_manager import GitLabPromptManager @@ -84,8 +87,9 @@ def test_gitlab_prompt_manager_error_handling_load(mock_client_class): config = {"project": "g/s/r", "access_token": "tkn"} - with pytest.raises(Exception, match="Failed to load prompt 'oops' from GitLab"): - GitLabPromptManager(config, prompt_id="oops").prompt_manager # triggers load + with pytest.raises(Exception, match="Failed to load prompt 'gitlab::oops' from GitLab"): + GitLabPromptManager(config, prompt_id="oops").prompt_manager + def test_gitlab_prompt_manager_config_validation_via_client_ctor(): @@ -257,7 +261,7 @@ def test_gitlab_prompt_manager_list_templates_with_prompts_path(mock_client_clas # list_templates strips folder prefix + extension ids = manager.get_available_prompts() assert "a" in ids - assert "sub/b" in ids + assert "gitlab::sub::b" in ids assert all(not x.endswith(".prompt") for x in ids) assert all("/prompts/chat/" not in x for x in ids) @@ -284,8 +288,8 @@ def test_gitlab_template_manager_load_all_prompts(mock_client_class): pm = GitLabPromptManager(config).prompt_manager loaded = pm.load_all_prompts() - assert set(loaded) == {"a", "sub/b"} - assert "a" in pm.prompts and "sub/b" in pm.prompts + assert set(loaded) == {"gitlab::a", "gitlab::sub::b"} + assert "gitlab::a" in pm.prompts and "gitlab::sub::b" in pm.prompts # ----------------------------- @@ -452,4 +456,4 @@ def test_gitlab_prompt_version_with_prompts_path(mock_client_class): # Path should include prompts_path and end with .prompt mock_client.get_file_content.assert_any_call( "prompts/chat/folder/sub/my_prompt.prompt", ref="commit-sha-999" - ) + ) \ No newline at end of file diff --git a/tests/test_litellm/integrations/gitlab/test_gitlab_prompt_manager.py b/tests/test_litellm/integrations/gitlab/test_gitlab_prompt_manager.py index 5b533fe765..8475252cfc 100644 --- a/tests/test_litellm/integrations/gitlab/test_gitlab_prompt_manager.py +++ b/tests/test_litellm/integrations/gitlab/test_gitlab_prompt_manager.py @@ -1,7 +1,6 @@ import os import sys from unittest.mock import MagicMock, patch - import pytest sys.path.insert(0, os.path.abspath("../../..")) # Adds the parent directory to the system path @@ -10,6 +9,10 @@ from litellm.integrations.gitlab.gitlab_client import GitLabClient from litellm.integrations.gitlab.gitlab_prompt_manager import ( GitLabPromptManager, GitLabPromptTemplate, + GitLabTemplateManager, + GitLabPromptCache, + encode_prompt_id, + decode_prompt_id, ) # ----------------------- @@ -475,3 +478,361 @@ def test_gitlab_prompt_manager_version_precedence(mock_client_class): prompt_variables={"q": "hello"}, ) mock_client.get_file_content.assert_any_call("pC.prompt", ref="manager-default") + + + + +# --------------------------------------------------------------------- +# ID Encoding/Decoding helpers +# --------------------------------------------------------------------- + +def test_encode_decode_prompt_id_roundtrip(): + raw = "invoice/extract" + encoded = encode_prompt_id(raw) + assert encoded == "gitlab::invoice::extract" + assert decode_prompt_id(encoded) == raw + +def test_encode_prompt_id_already_encoded(): + encoded = "gitlab::test::path" + assert encode_prompt_id(encoded) == encoded + + +# --------------------------------------------------------------------- +# GitLabTemplateManager behavior +# --------------------------------------------------------------------- + +@pytest.fixture +def mock_gitlab_client(): + client = MagicMock() + client.get_file_content.return_value = """--- +model: bedrock/anthropic.claude-3-sonnet +temperature: 0.3 +max_tokens: 100 +--- +system: You are a helpful bot. +user: Hello {{ name }} +""" + client.list_files.return_value = [ + "prompts/chat/hello.prompt", + "prompts/chat/nested/sub.prompt", + ] + return client + + +@pytest.fixture +def manager(mock_gitlab_client): + cfg = { + "project": "group/repo", + "access_token": "token", + "prompts_path": "prompts/chat", + } + return GitLabTemplateManager(gitlab_config=cfg, gitlab_client=mock_gitlab_client) + + +def test_list_templates_returns_encoded_ids(manager): + ids = manager.list_templates() + assert all(id.startswith("gitlab::") for id in ids) + assert "gitlab::hello" in ids + assert "gitlab::nested::sub" in ids + + +def test_load_prompt_from_gitlab_parses_metadata(manager, mock_gitlab_client): + manager._load_prompt_from_gitlab("gitlab::hello") + assert "gitlab::hello" in manager.prompts + + tmpl = manager.prompts["gitlab::hello"] + assert isinstance(tmpl, GitLabPromptTemplate) + assert tmpl.metadata["model"].startswith("bedrock/") + assert "You are a helpful bot." in tmpl.content + + +def test_render_template_renders_jinja(manager, mock_gitlab_client): + manager._load_prompt_from_gitlab("gitlab::hello") + output = manager.render_template("gitlab::hello", {"name": "Prishu"}) + assert "Hello Prishu" in output + + +def test_get_template_returns_none_if_not_loaded(manager): + assert manager.get_template("gitlab::missing") is None + + +def test_repo_path_conversion(manager): + raw = "gitlab::nested::sub" + repo_path = manager._id_to_repo_path(raw) + assert repo_path.endswith("nested/sub.prompt") + # Ensure decode/encode reversibility + decoded = manager._repo_path_to_id(repo_path) + assert decoded == raw + + +# --------------------------------------------------------------------- +# GitLabPromptManager high-level integration +# --------------------------------------------------------------------- + +@pytest.fixture +def prompt_manager(mock_gitlab_client): + cfg = {"project": "group/repo", "access_token": "tkn", "prompts_path": "prompts/chat"} + return GitLabPromptManager(gitlab_config=cfg, gitlab_client=mock_gitlab_client) + + +def test_get_prompt_template_renders_content(prompt_manager): + encoded_id = "gitlab::hello" + content, meta = prompt_manager.get_prompt_template(encoded_id, {"name": "World"}) + assert "Hello World" in content + assert "model" in meta + + +def test_pre_call_hook_parses_roles(prompt_manager): + prompt_id = "gitlab::hello" + messages, params = prompt_manager.pre_call_hook( + user_id="user123", + messages=[], + prompt_id=prompt_id, + prompt_variables={"name": "Tester"}, + ) + assert isinstance(messages, list) + roles = [m["role"] for m in messages] + assert "system" in roles and "user" in roles + assert "model" in params + + +def test_get_available_prompts_returns_sorted(prompt_manager): + ids = prompt_manager.get_available_prompts() + assert any(id.startswith("gitlab::") for id in ids) + assert ids == sorted(ids) + + +# --------------------------------------------------------------------- +# GitLabPromptCache behavior +# --------------------------------------------------------------------- + +@pytest.fixture +def prompt_cache(mock_gitlab_client): + cfg = {"project": "group/repo", "access_token": "tkn", "prompts_path": "prompts/chat"} + return GitLabPromptCache(cfg, gitlab_client=mock_gitlab_client) + + +def test_cache_load_all_builds_internal_maps(prompt_cache): + result = prompt_cache.load_all() + assert isinstance(result, dict) + # check encoded key presence + assert any(k.startswith("gitlab::") for k in result) + assert prompt_cache.list_files() + assert prompt_cache.list_ids() + + +def test_cache_get_by_id_handles_encoded_and_decoded(prompt_cache): + prompt_cache.load_all() + encoded = "gitlab::hello" + decoded = decode_prompt_id(encoded) + assert prompt_cache.get_by_id(encoded) + assert prompt_cache.get_by_id(decoded) + + +def test_cache_reload_resets_and_reloads(prompt_cache): + prompt_cache.load_all() + before = set(prompt_cache.list_ids()) + prompt_cache.reload() + after = set(prompt_cache.list_ids()) + assert before == after + + +# ----------------------- +# Test fakes / fixtures +# ----------------------- + +class FakeTemplateManager: + """ + Minimal stand-in for GitLabTemplateManager that GitLabPromptCache expects. + """ + def __init__(self, prompts_path="prompts"): + # simulate a configured prompts folder (affects _id_to_repo_path) + self.prompts_path = prompts_path.strip("/") + self.prompts = {} # id -> GitLabPromptTemplate + + # Seeds used by list_templates() + self._discoverable_ids = [] + + # Methods used by GitLabPromptCache.load_all + def list_templates(self, *, recursive: bool = True): + return list(self._discoverable_ids) + + def _load_prompt_from_gitlab(self, pid, ref=None): + # Pretend we fetched and parsed a file; add a basic template if not present + if pid not in self.prompts: + self.prompts[pid] = GitLabPromptTemplate( + template_id=pid, + content=f"User: Hello from {pid}", + metadata={"model": "gpt-4", "temperature": 0.1}, + ) + + def get_template(self, pid): + return self.prompts.get(pid) + + def _id_to_repo_path(self, pid): + base = f"{self.prompts_path}/" if self.prompts_path else "" + return f"{base}{pid}.prompt" + + +class FakePromptManagerWrapper: + """ + Minimal wrapper to mimic GitLabPromptManager(prompt_manager=). + GitLabPromptCache.__init__ expects GitLabPromptManager(...).prompt_manager. + """ + def __init__(self, fake_tm): + self.prompt_manager = fake_tm + + +@pytest.fixture() +def fake_managers(): + """ + Provide a fresh FakeTemplateManager plus a wrapper for each test. + """ + tm = FakeTemplateManager(prompts_path="prompts/chat") + wrapper = FakePromptManagerWrapper(tm) + return tm, wrapper + + +# ----------------------- +# Tests +# ----------------------- + +@patch("litellm.integrations.gitlab.gitlab_prompt_manager.GitLabPromptManager") +def test_cache_load_all_encodes_ids_and_populates_maps(mock_pm_cls, fake_managers): + tm, wrapper = fake_managers + # Simulate two files discovered under prompts_path + tm._discoverable_ids = ["a", "sub/b"] + + # When GitLabPromptCache constructs GitLabPromptManager(...), return our wrapper + mock_pm_cls.return_value = wrapper + + cache = GitLabPromptCache({"project": "g/s/r", "access_token": "tkn"}) + result = cache.load_all() + + # Encoded keys are present + assert set(result.keys()) == {encode_prompt_id("a"), encode_prompt_id("sub/b")} + + # Files map built with full repo paths + expect_a_path = tm._id_to_repo_path("a") + expect_b_path = tm._id_to_repo_path("sub/b") + assert cache.list_files() == [expect_a_path, expect_b_path] + + # IDs list is the encoded IDs + assert set(cache.list_ids()) == {encode_prompt_id("a"), encode_prompt_id("sub/b")} + + # Stored entries have normalized json shape + a_entry = cache.get_by_id("gitlab::a") + assert a_entry["id"] == "a" # id is the raw (decoded) id in the entry body + assert a_entry["path"] == expect_a_path + assert a_entry["metadata"]["model"] == "gpt-4" + + +@patch("litellm.integrations.gitlab.gitlab_prompt_manager.GitLabPromptManager") +def test_cache_get_by_id_accepts_encoded_and_decoded(mock_pm_cls, fake_managers): + tm, wrapper = fake_managers + tm._discoverable_ids = ["x/y"] + mock_pm_cls.return_value = wrapper + + cache = GitLabPromptCache({"project": "g/s/r", "access_token": "tkn"}) + cache.load_all() + + # Encoded lookup + encoded = encode_prompt_id("x/y") + decoded = "x/y" + + by_encoded = cache.get_by_id(encoded) + by_decoded = cache.get_by_id(decoded) + + assert by_encoded is not None + assert by_decoded is not None + assert by_encoded == by_decoded # normalization works + # sanity on shape + assert by_encoded["id"] == "x/y" + assert by_encoded["path"].endswith("prompts/chat/x/y.prompt") + + +@patch("litellm.integrations.gitlab.gitlab_prompt_manager.GitLabPromptManager") +def test_cache_reload_clears_then_reloads(mock_pm_cls, fake_managers): + tm, wrapper = fake_managers + tm._discoverable_ids = ["p1"] + mock_pm_cls.return_value = wrapper + + cache = GitLabPromptCache({"project": "g/s/r", "access_token": "tkn"}) + first = cache.load_all() + assert encode_prompt_id("p1") in first + + # Change discovered ids and ensure reload reflects the change + tm._discoverable_ids = ["p2"] + reloaded = cache.reload() + + assert encode_prompt_id("p1") not in reloaded + assert encode_prompt_id("p2") in reloaded + # internal maps should reflect only new state + assert cache.list_ids() == [encode_prompt_id("p2")] + + +@patch("litellm.integrations.gitlab.gitlab_prompt_manager.GitLabPromptManager") +def test_cache_skips_when_template_missing_even_after_reload_attempt(mock_pm_cls, fake_managers): + """ + If get_template(pid) returns None even after a retry load, the entry is skipped. + """ + class MissingTemplateManager(FakeTemplateManager): + def get_template(self, pid): + # Always return None to trigger the continue path + return None + + def _load_prompt_from_gitlab(self, pid, ref=None): + # Pretend to load, but still don't populate prompts so get_template stays None + pass + + tm = MissingTemplateManager(prompts_path="prompts") + wrapper = FakePromptManagerWrapper(tm) + mock_pm_cls.return_value = wrapper + + cache = GitLabPromptCache({"project": "g/s/r", "access_token": "tkn"}) + tm._discoverable_ids = ["will/vanish"] + out = cache.load_all() + + assert out == {} + assert cache.list_files() == [] + assert cache.list_ids() == [] + + +@patch("litellm.integrations.gitlab.gitlab_prompt_manager.GitLabPromptManager") +def test_cache_get_by_file_returns_exact_entry(mock_pm_cls, fake_managers): + tm, wrapper = fake_managers + tm._discoverable_ids = ["alpha", "nested/beta"] + mock_pm_cls.return_value = wrapper + + cache = GitLabPromptCache({"project": "g/s/r", "access_token": "tkn"}) + cache.load_all() + + alpha_path = tm._id_to_repo_path("alpha") + beta_path = tm._id_to_repo_path("nested/beta") + + alpha = cache.get_by_file(alpha_path) + beta = cache.get_by_file(beta_path) + + assert alpha and alpha["id"] == "alpha" + assert beta and beta["id"] == "nested/beta" + + +@patch("litellm.integrations.gitlab.gitlab_prompt_manager.GitLabPromptManager") +def test_encode_decode_helpers_roundtrip_in_cache_context(mock_pm_cls, fake_managers): + tm, wrapper = fake_managers + tm._discoverable_ids = ["dir1/dir2/item"] + mock_pm_cls.return_value = wrapper + + cache = GitLabPromptCache({"project": "g/s/r", "access_token": "tkn"}) + cache.load_all() + + encoded = encode_prompt_id("dir1/dir2/item") + assert encoded in cache.list_ids() + + # decode → encode → lookup should still work + decoded = decode_prompt_id(encoded) + assert decoded == "dir1/dir2/item" + + got = cache.get_by_id(decoded) + assert got is not None + assert got["id"] == "dir1/dir2/item" \ No newline at end of file