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 <deepanshu.lulla@hq.bill.com>
This commit is contained in:
Deepanshu Lulla
2025-10-28 14:46:50 -07:00
committed by GitHub
co-authored by deepanshu
parent 647f2f5d86
commit 3a7c498eff
4 changed files with 554 additions and 28 deletions
+10 -9
View File
@@ -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",
]
@@ -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,
}
@@ -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"
)
)
@@ -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=<GitLabTemplateManager>).
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"