mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-02 08:21:53 +00:00
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:
co-authored by
deepanshu
parent
647f2f5d86
commit
3a7c498eff
@@ -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"
|
||||
Reference in New Issue
Block a user