Skip to content
Merged
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ Repo2Readme/
├── pyproject.toml
├── repo2readme/
├── config.py
├── llm/
├── factory.py
├── cli/
├── main.py
├── loaders/
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ dependencies = [
"langchain-community>=0.4.1",
"langchain-groq>=1.1.1",
"langchain-google-genai>=4.1.2",
"langchain-openai>=1.0.0",
"langchain-anthropic>=1.0.0",

"groq>=0.37.1",
"google-genai>=1.56.0"
Expand Down
71 changes: 60 additions & 11 deletions repo2readme/cli/main.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import click
from rich import print as rprint
from rich.progress import Progress
from repo2readme.config import get_api_keys, reset_api_keys
from repo2readme.config import (
get_api_keys,
get_api_key,
reset_api_keys,
)
import os
from repo2readme.utils.tree import generate_tree
from repo2readme.utils.detect_language import detect_lang
Expand Down Expand Up @@ -48,7 +52,22 @@ def main():
is_flag=True,
help="Preview the analysis without making any API calls.",
)
def run(url, local, output, force, include_patterns, exclude_patterns, max_file_size_kb, dry_run):
@click.option(
"--provider",
default=None,
help="LLM provider (groq, google, openai, anthropic, openrouter, ollama, etc.)",
)
@click.option(
"--model",
default=None,
help="LLM model name",
)
@click.option(
"--base-url",
default=None,
help="Base URL for OpenAI-compatible providers",
)
def run(url, local, output, force, include_patterns, exclude_patterns, max_file_size_kb, dry_run, provider, model, base_url,):
""" Use --url for GitHub repo url and --local for local repo
"""
if not url and not local:
Expand Down Expand Up @@ -121,9 +140,26 @@ def format_size(size_bytes):
return

try:
groq_key, gemini_key = get_api_keys()
os.environ["GROQ_API_KEY"] = groq_key
os.environ["GOOGLE_API_KEY"] = gemini_key
if provider:
api_key = get_api_key(provider)

provider_env = {
"groq": "GROQ_API_KEY",
"google": "GOOGLE_API_KEY",
"gemini": "GOOGLE_API_KEY",
"openai": "OPENAI_API_KEY",
"anthropic": "ANTHROPIC_API_KEY",
"openrouter": "OPENROUTER_API_KEY",
"together": "TOGETHER_API_KEY",
}

os.environ[provider_env[provider.lower()]] = api_key

else:
groq_key, gemini_key = get_api_keys()
os.environ["GROQ_API_KEY"] = groq_key
os.environ["GOOGLE_API_KEY"] = gemini_key

except Exception as e:
rprint(f"[red]Failed to configure API keys: {e}[/red]")
return
Expand All @@ -143,11 +179,21 @@ def process_document(doc):
meta = doc["metadata"]
try:
lang = detect_lang(meta.get("file_type", "text"))
summary = summarize_file(
file_path=meta["file_path"],
language=lang,
content=doc["content"]
)
if provider or model or base_url:
summary = summarize_file(
file_path=meta["file_path"],
language=lang,
content=doc["content"],
provider=provider,
model_name=model,
base_url=base_url,
)
else:
summary = summarize_file(
file_path=meta["file_path"],
language=lang,
content=doc["content"],
)
with summaries_lock:
summaries.append(summary)
except Exception as e:
Expand Down Expand Up @@ -175,7 +221,10 @@ def process_document(doc):
"max_iterations": 3,
"latest_readme": "",
'best_score': 0.0,
"best_readme": ""
"best_readme": "",
"provider": provider,
"model": model,
"base_url": base_url,
}

final_state = workflow.invoke(initial_state)
Expand Down
47 changes: 35 additions & 12 deletions repo2readme/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,31 +16,54 @@ def save_env(data):
with open(ENV_PATH, "w") as f:
json.dump(data, f, indent=4)


def get_api_keys():
env = load_env()

groq = env.get("GROQ_API_KEY")
gemini = env.get("GOOGLE_API_KEY")

if groq and gemini:
return groq, gemini

rprint("[yellow]API keys are missing! Let's add them.[/yellow]\n")

if not groq:
groq = input("Enter your Groq API key: ").strip()
groq = get_api_key("groq")

if not gemini:
gemini = input("Enter your Google Gemini API key: ").strip()
gemini = get_api_key("google")

return groq, gemini

def get_api_key(provider: str):
env = load_env()

env["GROQ_API_KEY"] = groq
env["GOOGLE_API_KEY"] = gemini
provider_map = {
"groq": "GROQ_API_KEY",
"google": "GOOGLE_API_KEY",
"gemini": "GOOGLE_API_KEY",
"openai": "OPENAI_API_KEY",
"anthropic": "ANTHROPIC_API_KEY",
"openrouter": "OPENROUTER_API_KEY",
"together": "TOGETHER_API_KEY",
}

provider = provider.lower()

if provider not in provider_map:
raise ValueError(f"Unsupported provider: {provider}")

env_var = provider_map[provider]
api_key = env.get(env_var)

if api_key:
return api_key

rprint(f"[yellow]{provider} API key is missing![/yellow]\n")

api_key = input(f"Enter your {provider} API key: ").strip()

env[env_var] = api_key
save_env(env)
Comment on lines +57 to 62

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Validate non-empty API key before persisting.

input(...).strip() returns an empty string if the user presses Enter. The empty key is saved to the env file and returned, causing confusing authentication errors downstream with no clear root cause.

🛡️ Proposed fix: reject empty input
     rprint(f"[yellow]{provider} API key is missing![/yellow]\n")

     api_key = input(f"Enter your {provider} API key: ").strip()

+    if not api_key:
+        rprint("[red]API key cannot be empty. Aborting.[/red]")
+        raise ValueError("API key cannot be empty")
+
     env[env_var] = api_key
     save_env(env)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
rprint(f"[yellow]{provider} API key is missing![/yellow]\n")
api_key = input(f"Enter your {provider} API key: ").strip()
env[env_var] = api_key
save_env(env)
rprint(f"[yellow]{provider} API key is missing![/yellow]\n")
api_key = input(f"Enter your {provider} API key: ").strip()
if not api_key:
rprint("[red]API key cannot be empty. Aborting.[/red]")
raise ValueError("API key cannot be empty")
env[env_var] = api_key
save_env(env)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@repo2readme/config.py` around lines 57 - 62, The API key prompt flow in the
config helper currently saves whatever `input(...).strip()` returns, including
an empty string when the user just presses Enter. Update the API key handling
logic around the prompt and `save_env` call to validate that the trimmed value
is non-empty before persisting or returning it, and if it is empty, re-prompt or
fail with a clear message instead of writing it to the env file. Use the
existing `provider`, `env_var`, and `save_env` flow to keep the fix localized.


rprint("[green]API keys saved successfully![/green]")
rprint("[green]API key saved successfully![/green]")

return groq, gemini
return api_key


def reset_api_keys():
Expand Down
Empty file added repo2readme/llm/__init__.py
Empty file.
76 changes: 76 additions & 0 deletions repo2readme/llm/factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from __future__ import annotations

import os

from langchain_core.language_models.chat_models import BaseChatModel

def create_llm(
provider: str,
model: str,
api_key: str | None = None,
base_url: str | None = None,
**kwargs,
) -> BaseChatModel:
"""
Factory function to create a LangChain chat model.

Parameters
----------
provider : str
LLM provider name.
model : str
Model name.
api_key : str | None
Optional API key. Falls back to environment variable.
base_url : str | None
Optional base URL for OpenAI-compatible APIs.
"""

provider = provider.lower()

if provider == "groq":
from langchain_groq import ChatGroq
return ChatGroq(
model=model,
api_key=api_key or os.getenv("GROQ_API_KEY"),
**kwargs,
)

elif provider in ("google", "gemini"):
from langchain_google_genai import ChatGoogleGenerativeAI
return ChatGoogleGenerativeAI(
model=model,
google_api_key=api_key or os.getenv("GOOGLE_API_KEY"),
**kwargs,
)

elif provider == "openai":
from langchain_openai import ChatOpenAI
return ChatOpenAI(
model=model,
api_key=api_key or os.getenv("OPENAI_API_KEY"),
base_url=base_url,
**kwargs,
)

elif provider == "anthropic":
from langchain_anthropic import ChatAnthropic
return ChatAnthropic(
model=model,
api_key=api_key or os.getenv("ANTHROPIC_API_KEY"),
**kwargs,
)

elif provider == "openrouter":
from langchain_openai import ChatOpenAI
return ChatOpenAI(
model=model,
api_key=api_key or os.getenv("OPENROUTER_API_KEY"),
base_url=base_url or "https://openrouter.ai/api/v1",
**kwargs,
)

else:
raise ValueError(
f"Unsupported provider '{provider}'."
)
Comment on lines +64 to +76

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Missing "together" provider creates cross-file inconsistency.

config.py's get_api_key and main.py's provider_env both support "together", but create_llm does not. A user passing --provider together would successfully configure an API key, then hit a ValueError during summarization — all documents fail and a poor README is generated from error dicts.

🔧 Proposed fix: add "together" provider support
     elif provider == "openrouter":
         return ChatOpenAI(
             model=model,
             api_key=api_key or os.getenv("OPENROUTER_API_KEY"),
             base_url=base_url or "https://openrouter.ai/api/v1",
             **kwargs,
         )

+    elif provider == "together":
+        return ChatOpenAI(
+            model=model,
+            api_key=api_key or os.getenv("TOGETHER_API_KEY"),
+            base_url=base_url or "https://api.together.xyz/v1",
+            **kwargs,
+        )
+
     else:
         raise ValueError(
             f"Unsupported provider '{provider}'."
         )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
elif provider == "openrouter":
return ChatOpenAI(
model=model,
api_key=api_key or os.getenv("OPENROUTER_API_KEY"),
base_url=base_url or "https://openrouter.ai/api/v1",
**kwargs,
)
else:
raise ValueError(
f"Unsupported provider '{provider}'."
)
elif provider == "openrouter":
return ChatOpenAI(
model=model,
api_key=api_key or os.getenv("OPENROUTER_API_KEY"),
base_url=base_url or "https://openrouter.ai/api/v1",
**kwargs,
)
elif provider == "together":
return ChatOpenAI(
model=model,
api_key=api_key or os.getenv("TOGETHER_API_KEY"),
base_url=base_url or "https://api.together.xyz/v1",
**kwargs,
)
else:
raise ValueError(
f"Unsupported provider '{provider}'."
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@repo2readme/llm/factory.py` around lines 66 - 77, The create_llm factory is
missing support for the "together" provider, which causes a ValueError even
though config.py and main.py already accept it. Update create_llm in factory.py
to handle provider == "together" alongside the existing provider branches, using
the same pattern as the other ChatOpenAI initializations and wiring the
appropriate Together API key/base URL settings. Keep the unsupported-provider
ValueError only for truly unknown providers so the provider flow stays
consistent across files.

10 changes: 8 additions & 2 deletions repo2readme/readme/agent_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ class ReadmeState(TypedDict):
best_score:float
iteration_no:int
max_iterations:int
provider: str | None
model: str | None
base_url: str | None

def generate_readme_node(state:ReadmeState):
latest_readme = (
Expand All @@ -22,14 +25,17 @@ def generate_readme_node(state:ReadmeState):
summaries=state['summaries'],
tree_structure=state['tree_structure'],
feedback=state['feedback'],
latest_readme=latest_readme
latest_readme=latest_readme,
provider=state["provider"],
model_name=state["model"],
base_url=state["base_url"]
)
return {
'readme':[readme]
}
def readme_reviewer_node(state:ReadmeState):
latest_readme=state['readme'][-1]
review=readme_reviewer(latest_readme)
review=readme_reviewer(latest_readme,provider=state["provider"], model_name=state["model"], base_url=state["base_url"])
best_score=state['best_score']
best_readme=state['best_readme']
if review.score>best_score:
Expand Down
13 changes: 7 additions & 6 deletions repo2readme/readme/readme_generator.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
from langchain_google_genai import ChatGoogleGenerativeAI
from langchain_groq import ChatGroq
import os
from dotenv import load_dotenv
load_dotenv()
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser
from typing import List
def generate_readme(summaries:List[str],tree_structure:str,feedback:List[str],latest_readme:str):
api_key = os.getenv("GOOGLE_API_KEY")
model=ChatGoogleGenerativeAI(model="gemini-2.5-flash", google_api_key=api_key)
from repo2readme.llm.factory import create_llm
def generate_readme(summaries:List[str],tree_structure:str,feedback:List[str],latest_readme:str,provider:str, model_name:str, base_url:str):
model = create_llm(
provider= provider or "groq",
model= model_name or "openai/gpt-oss-120b",
base_url=base_url
)

prompt = PromptTemplate(
template="""
Expand Down
16 changes: 12 additions & 4 deletions repo2readme/readme/reviewer_agent.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
from langchain_core.output_parsers import PydanticOutputParser
from pydantic import BaseModel,Field
from langchain_core.prompts import PromptTemplate
from langchain_google_genai import ChatGoogleGenerativeAI
import os
from dotenv import load_dotenv
from repo2readme.llm.factory import create_llm

load_dotenv()
class ReviewSchema(BaseModel):
score:float=Field(description="A score between 1 and 10 based on the quality of the README")
feedback:str=Field(description="Actionable comment how to improve the README")

def readme_reviewer(readme:str):
api_key = os.getenv("GOOGLE_API_KEY")
model=ChatGoogleGenerativeAI(model="gemini-2.5-flash", google_api_key=api_key)
def readme_reviewer(
readme: str,
provider: str | None = None,
model_name: str | None = None,
base_url: str | None = None,
):
model = create_llm(
provider=provider or "google",
model=model_name or "gemini-2.5-flash",
base_url=base_url,
)
parser=PydanticOutputParser(pydantic_object=ReviewSchema)
review_prompt=PromptTemplate(
template="""
Expand Down
24 changes: 16 additions & 8 deletions repo2readme/summarize/summary.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
from langchain_groq import ChatGroq
from langchain_core.prompts import PromptTemplate
from dotenv import load_dotenv
import os
from langchain_core.output_parsers import JsonOutputParser
from repo2readme.llm.factory import create_llm

load_dotenv()


def create_summarizer(file_path: str, language: str, content: str):
model = ChatGroq(
model="openai/gpt-oss-120b",
api_key=os.getenv("GROQ_API_KEY"),
temperature=0.2,
def create_summarizer(file_path, language,
content,provider=None, model_name=None, base_url=None,):
model = create_llm(
provider=provider or "groq",
model=model_name or "openai/gpt-oss-120b",
base_url=base_url,
)
parser=JsonOutputParser()
prompt = PromptTemplate(
Expand Down Expand Up @@ -80,9 +81,16 @@ def create_summarizer(file_path: str, language: str, content: str):
return chain


def summarize_file(file_path: str, language: str, content: str):
def summarize_file(
file_path,
language,
content,
provider=None,
model_name=None,
base_url=None,
):
try:
chain = create_summarizer(file_path, language, content)
chain = create_summarizer(file_path, language, content, provider, model_name, base_url,)
return chain.invoke({
"file_path": file_path.replace("\\", "/"),
"language": language,
Expand Down