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
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,13 @@ ml = [
"sentence-transformers>=2.2.0", # For VectorSearcher and DocstringIndexer
"chromadb>=1.0.0", # Vector database for semantic search (1.0+ required for Cloud)
]
litellm = [
"litellm>=1.80.0,<1.87.0", # LiteLLM AI gateway for 100+ LLM providers
]
all = [
"sentence-transformers>=2.2.0",
"chromadb>=1.0.0",
"litellm>=1.80.0,<1.87.0",
]

[tool.ruff]
Expand Down
4 changes: 2 additions & 2 deletions src/kit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from .vector_searcher import VectorSearcher

try:
from .summaries import AnthropicConfig, GoogleConfig, LLMError, OpenAIConfig, Summarizer
from .summaries import AnthropicConfig, GoogleConfig, LiteLLMConfig, LLMError, OpenAIConfig, Summarizer
except ImportError:
# Allow kit to be imported even if LLM extras aren't installed.
# Users will get an ImportError later if they try to use Summarizer.
Expand Down Expand Up @@ -107,7 +107,7 @@ def _typer_make_metavar(self, ctx=None): # type: ignore[override]
"get_tool_schemas",
# Conditionally add Summarizer related classes if they were imported
*(
["Summarizer", "OpenAIConfig", "AnthropicConfig", "GoogleConfig", "LLMError"]
["Summarizer", "OpenAIConfig", "AnthropicConfig", "GoogleConfig", "LiteLLMConfig", "LLMError"]
if "Summarizer" in globals()
else []
),
Expand Down
6 changes: 6 additions & 0 deletions src/kit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,8 @@ def commit_changes(
)
elif detected_provider == LLMProvider.OLLAMA:
new_api_key = "not_required" # Ollama doesn't need API key
elif detected_provider == LLMProvider.LITELLM:
new_api_key = os.getenv("LITELLM_API_KEY") or "litellm"
else: # OpenAI
new_api_key = os.getenv("KIT_OPENAI_TOKEN") or os.getenv("OPENAI_API_KEY")
if not new_api_key:
Expand Down Expand Up @@ -886,6 +888,8 @@ def review_pr(
"Configuration error",
"Set KIT_ANTHROPIC_TOKEN environment variable",
)
elif detected_provider == LLMProvider.LITELLM:
new_api_key = os.getenv("LITELLM_API_KEY") or "litellm"
else: # OpenAI
new_api_key = os.getenv("KIT_OPENAI_TOKEN") or os.getenv("OPENAI_API_KEY")
if not new_api_key:
Expand Down Expand Up @@ -1367,6 +1371,8 @@ def summarize_pr(
)
elif detected_provider == LLMProvider.OLLAMA:
new_api_key = "not_required" # Ollama doesn't need API key
elif detected_provider == LLMProvider.LITELLM:
new_api_key = os.getenv("LITELLM_API_KEY") or "litellm"
else: # OpenAI
new_api_key = os.getenv("KIT_OPENAI_TOKEN") or os.getenv("OPENAI_API_KEY")
if not new_api_key:
Expand Down
31 changes: 30 additions & 1 deletion src/kit/deep_research.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from kit.summaries import (
AnthropicConfig,
GoogleConfig,
LiteLLMConfig,
LLMError,
OllamaConfig,
OpenAIConfig,
Expand All @@ -29,7 +30,7 @@ class ResearchResult:
class DeepResearch:
"""LLM-based research for comprehensive answers."""

def __init__(self, config: Optional[Union[OpenAIConfig, AnthropicConfig, GoogleConfig, OllamaConfig]] = None):
def __init__(self, config: Optional[Union[OpenAIConfig, AnthropicConfig, GoogleConfig, OllamaConfig, LiteLLMConfig]] = None):
"""Initialize with LLM config."""
self.config = config
self._llm_client = None
Expand Down Expand Up @@ -66,6 +67,14 @@ def _init_llm_client(self):
elif isinstance(self.config, OllamaConfig):
self._llm_client = "ollama"

elif isinstance(self.config, LiteLLMConfig):
try:
import litellm

self._llm_client = litellm
except ImportError:
raise LLMError("litellm package not installed. Run: pip install 'cased-kit[litellm]'")

def research(self, query: str) -> ResearchResult:
"""
Perform research by prompting an LLM for a comprehensive answer.
Expand Down Expand Up @@ -146,6 +155,26 @@ def research(self, query: str) -> ResearchResult:
answer = response.json().get("response", "No response from Ollama")
else:
answer = f"Ollama error: {response.status_code}"
elif isinstance(self.config, LiteLLMConfig):
completion_kwargs = {
"model": self.config.model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"max_tokens": self.config.max_tokens,
"drop_params": self.config.drop_params,
}
if self.config.api_key:
completion_kwargs["api_key"] = self.config.api_key
if self.config.api_base:
completion_kwargs["api_base"] = self.config.api_base

response = self._llm_client.completion(**completion_kwargs)
answer = response.choices[0].message.content

if not answer:
answer = "The LLM returned an empty response. Please try again."
else:
answer = "No LLM configured."

Expand Down
34 changes: 31 additions & 3 deletions src/kit/llm_client_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

if TYPE_CHECKING:
from kit.pr_review.config import LLMConfig
from kit.summaries import AnthropicConfig, GoogleConfig, OllamaConfig, OpenAIConfig
from kit.summaries import AnthropicConfig, GoogleConfig, LiteLLMConfig, OllamaConfig, OpenAIConfig


class LLMClientError(Exception):
Expand Down Expand Up @@ -111,8 +111,32 @@ def create_ollama_client(
return OllamaClient(base_url, model, session)


def create_litellm_client(
api_key: Optional[str] = None,
api_base: Optional[str] = None,
) -> Any:
"""Create a LiteLLM client (returns the litellm module for direct use).

Args:
api_key: Optional API key (passed per-call; provider env vars also work)
api_base: Optional base URL for LiteLLM proxy

Returns:
The litellm module

Raises:
LLMClientError: If the litellm package is not installed
"""
try:
import litellm
except ImportError:
raise LLMClientError("litellm package not installed. Run: pip install 'cased-kit[litellm]'")

return litellm


def create_client_from_config(
config: Union["OpenAIConfig", "AnthropicConfig", "GoogleConfig", "OllamaConfig"],
config: Union["OpenAIConfig", "AnthropicConfig", "GoogleConfig", "OllamaConfig", "LiteLLMConfig"],
) -> Any:
"""Create an LLM client from a summaries config object.

Expand All @@ -127,7 +151,7 @@ def create_client_from_config(
TypeError: If the config type is not recognized
"""
# Import here to avoid circular imports
from kit.summaries import AnthropicConfig, GoogleConfig, OllamaConfig, OpenAIConfig
from kit.summaries import AnthropicConfig, GoogleConfig, LiteLLMConfig, OllamaConfig, OpenAIConfig

if isinstance(config, OpenAIConfig):
return create_openai_client(config.api_key or "", config.base_url)
Expand All @@ -137,6 +161,8 @@ def create_client_from_config(
return create_google_client(config.api_key or "")
elif isinstance(config, OllamaConfig):
return create_ollama_client(config.base_url, config.model)
elif isinstance(config, LiteLLMConfig):
return create_litellm_client(config.api_key, config.api_base)
else:
raise TypeError(f"Unsupported config type: {type(config)}")

Expand Down Expand Up @@ -173,5 +199,7 @@ def create_client_from_review_config(
llm_config.model,
session,
)
elif llm_config.provider == LLMProvider.LITELLM:
return create_litellm_client(llm_config.api_key, llm_config.api_base_url)
else:
raise ValueError(f"Unsupported LLM provider: {llm_config.provider}")
117 changes: 117 additions & 0 deletions src/kit/pr_review/agentic_reviewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -924,6 +924,121 @@ async def make_api_call():

return "Analysis completed after maximum turns"

async def _run_agentic_analysis_litellm(self, initial_prompt: str) -> str:
"""Run multi-turn agentic analysis using LiteLLM (OpenAI-compatible tool calling)."""
try:
import litellm
except ImportError:
raise RuntimeError("litellm package not installed. Run: pip install 'cased-kit[litellm]'")

tools = [
{
"type": "function",
"function": {
"name": tool["name"],
"description": tool["description"],
"parameters": tool["input_schema"],
},
}
for tool in self._get_available_tools()
]
messages: List[Dict[str, Any]] = [{"role": "user", "content": initial_prompt}]

max_turns = self.max_turns
turn = 0

while turn < max_turns:
turn += 1
print(f"\U0001f916 Agentic turn {turn}...")

if turn >= max_turns - 3:
messages.append(
{
"role": "user",
"content": f"URGENT: You are on turn {turn} of {max_turns}. You MUST finalize your review NOW using the finalize_review tool. Do not use any other tools.",
}
)
elif turn >= self.finalize_threshold:
messages.append(
{
"role": "user",
"content": f"You are on turn {turn} of {max_turns}. Please finalize your review soon using the finalize_review tool with your comprehensive analysis.",
}
)

try:
completion_kwargs: Dict[str, Any] = {
"model": self.config.llm.model,
"tools": tools,
"messages": messages,
"max_tokens": self.config.llm.max_tokens,
"drop_params": True,
}
if self.config.llm.api_key and self.config.llm.api_key != "litellm":
completion_kwargs["api_key"] = self.config.llm.api_key
if self.config.llm.api_base_url:
completion_kwargs["api_base"] = self.config.llm.api_base_url

async def make_api_call():
import asyncio

loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
lambda: litellm.completion(**completion_kwargs),
)

response = await retry_with_backoff(make_api_call)

# Track cost
input_tokens = getattr(response.usage, "prompt_tokens", 0) if response.usage else 0
output_tokens = getattr(response.usage, "completion_tokens", 0) if response.usage else 0
self.cost_tracker.track_llm_usage(
self.config.llm.provider, self.config.llm.model, input_tokens, output_tokens
)

message = response.choices[0].message
messages.append({"role": "assistant", "content": message.content, "tool_calls": message.tool_calls})

if message.tool_calls:
print(
f"\U0001f680 Executing {len(message.tool_calls)} {'tool' if len(message.tool_calls) == 1 else 'tools'} in parallel..."
)

tool_tasks = []
tool_call_info = []

for tool_call in message.tool_calls:
tool_name = tool_call.function.name
tool_input = json.loads(tool_call.function.arguments)

if tool_name == "finalize_review":
final_review = tool_input.get("review_markdown", "")
if final_review:
return final_review
return tool_input.get("analysis", "No analysis provided")

tool_tasks.append(self._execute_tool(tool_name, tool_input))
tool_call_info.append((tool_call.id, tool_name))

results = await asyncio.gather(*tool_tasks, return_exceptions=True)

for (tool_id, tool_name), result in zip(tool_call_info, results):
if isinstance(result, Exception):
result_str = f"Error: {result}"
else:
result_str = str(result)[:4000]
messages.append({"role": "tool", "tool_call_id": tool_id, "content": result_str})
else:
text_content = message.content or ""
if text_content and turn > 1:
return text_content

except Exception as e:
return f"Error during agentic analysis turn {turn}: {e}"

return "Analysis completed after maximum turns"

async def analyze_pr_agentic(self, repo_path: str, pr_details: Dict[str, Any], files: List[Dict[str, Any]]) -> str:
"""Run agentic analysis of the PR."""
from kit import Repository
Expand Down Expand Up @@ -1031,6 +1146,8 @@ async def analyze_pr_agentic(self, repo_path: str, pr_details: Dict[str, Any], f
"Please use --provider anthropic, openai, or google for agentic reviews, "
"or run without --agentic for standard reviews with Ollama."
)
elif self.config.llm.provider == LLMProvider.LITELLM:
analysis = await self._run_agentic_analysis_litellm(initial_prompt)
else:
analysis = await self._run_agentic_analysis_openai(initial_prompt)

Expand Down
36 changes: 36 additions & 0 deletions src/kit/pr_review/commit_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ async def analyze_changes_for_commit(self, repo_path: str) -> str:
message = await self._generate_with_google(commit_prompt)
elif self.config.llm.provider == LLMProvider.OLLAMA:
message = await self._generate_with_ollama(commit_prompt)
elif self.config.llm.provider == LLMProvider.LITELLM:
message = await self._generate_with_litellm(commit_prompt)
else:
message = await self._generate_with_openai(commit_prompt)

Expand Down Expand Up @@ -252,6 +254,40 @@ async def _generate_with_ollama(self, prompt: str) -> str:
except Exception as e:
return f"Update files (error: {e})"

async def _generate_with_litellm(self, prompt: str) -> str:
"""Generate commit message using LiteLLM."""
try:
import litellm
except ImportError:
return "Update files (error: litellm not installed)"

try:
completion_kwargs: Dict[str, Any] = {
"model": self.config.llm.model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 200,
"drop_params": True,
}
if self.config.llm.api_key and self.config.llm.api_key != "litellm":
completion_kwargs["api_key"] = self.config.llm.api_key
if self.config.llm.api_base_url:
completion_kwargs["api_base"] = self.config.llm.api_base_url

response = await asyncio.to_thread(litellm.completion, **completion_kwargs)

# Track cost
input_tokens = getattr(response.usage, "prompt_tokens", 0) if response.usage else 0
output_tokens = getattr(response.usage, "completion_tokens", 0) if response.usage else 0
self.cost_tracker.track_llm_usage(
self.config.llm.provider, self.config.llm.model, input_tokens, output_tokens
)

content = response.choices[0].message.content
return content if content is not None else "Update files"

except Exception as e:
return f"Update files (error: {e})"

def commit_with_message(self, message: str) -> None:
"""Execute git commit with the generated message."""
try:
Expand Down
Loading