Skip to content

Commit cbdb57c

Browse files
authored
feat: add local LLM summarization via OpenAI-compatible endpoints (#8)
- Implemented `OpenAIBatchSummarizer` using `httpx` to query local endpoints. - Placed local LLMs as the fallback after Anthropic and Gemini options. - Added support for `OPENAI_API_BASE`, `OPENAI_API_KEY`, and `OPENAI_MODEL` environment variables. - Configured a dynamic HTTP request timeout via `OPENAI_TIMEOUT` (default: 60s). - Wrote full unit tests including mock HTTP server resolution and timeout configuration. - Standardized documentation and fallback defaults to `qwen3-coder`. - Promoted local LLMs guidelines structurally above the Env Vars reference in `README.md`.
1 parent 1802af0 commit cbdb57c

4 files changed

Lines changed: 246 additions & 7 deletions

File tree

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,13 +290,42 @@ See SECURITY.md for details.
290290

291291
---
292292

293+
## Local LLMs (Ollama / LM Studio)
294+
295+
You can use local, privacy-preserving AI models to generate summaries by providing an OpenAI-compatible endpoint.
296+
297+
For **Ollama**, run a model locally, then configure the MCP server:
298+
```json
299+
"env": {
300+
"OPENAI_API_BASE": "http://localhost:11434/v1",
301+
"OPENAI_MODEL": "qwen3-coder"
302+
}
303+
```
304+
305+
For **LM Studio**, ensure the Local Server is running (usually on port 1234):
306+
```json
307+
"env": {
308+
"OPENAI_API_BASE": "http://127.0.0.1:1234/v1",
309+
"OPENAI_MODEL": "openai/gpt-oss-20b"
310+
}
311+
```
312+
313+
> [!TIP]
314+
> **Performance Note:** Local models can be slow to load into memory on their first request, potentially causing the MCP server to time out and fall back to generic signature summaries. It is highly recommended to **pre-load the model** in Ollama or LM Studio before starting the server, or increase the `OPENAI_TIMEOUT` environment variable (e.g., to `"120.0"`) to allow more time for generation.
315+
316+
---
317+
293318
## Environment Variables
294319

295320
| Variable | Purpose | Required |
296321
| --------------------------- | ------------------------- | -------- |
297322
| `GITHUB_TOKEN` | GitHub API auth | No |
298323
| `ANTHROPIC_API_KEY` | Symbol summaries via Claude Haiku (takes priority) | No |
299324
| `GOOGLE_API_KEY` | Symbol summaries via Gemini Flash | No |
325+
| `OPENAI_API_BASE` | Base URL for local LLMs (e.g. `http://localhost:11434/v1`) | No |
326+
| `OPENAI_API_KEY` | API key for local LLMs (default: `local-llm`) | No |
327+
| `OPENAI_MODEL` | Model name for local LLMs (default: `qwen3-coder`) | No |
328+
| `OPENAI_TIMEOUT` | Timeout in seconds for local requests (default: `60.0`) | No |
300329
| `CODE_INDEX_PATH` | Custom cache path | No |
301330
| `JCODEMUNCH_SHARE_SAVINGS` | Set to `0` to disable anonymous community token savings reporting | No |
302331

src/jcodemunch_mcp/summarizer/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .batch_summarize import (
44
BatchSummarizer,
55
GeminiBatchSummarizer,
6+
OpenAIBatchSummarizer,
67
extract_summary_from_docstring,
78
signature_fallback,
89
summarize_symbols_simple,
@@ -12,6 +13,7 @@
1213
__all__ = [
1314
"BatchSummarizer",
1415
"GeminiBatchSummarizer",
16+
"OpenAIBatchSummarizer",
1517
"extract_summary_from_docstring",
1618
"signature_fallback",
1719
"summarize_symbols_simple",

src/jcodemunch_mcp/summarizer/batch_summarize.py

Lines changed: 141 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -270,10 +270,139 @@ def _parse_response(self, text: str, expected_count: int) -> list[str]:
270270
return summaries
271271

272272

273+
@dataclass
274+
class OpenAIBatchSummarizer:
275+
"""AI-based batch summarization using OpenAI-compatible endpoints (Tier 2).
276+
277+
Ideal for local LLMs like Ollama (http://localhost:11434/v1) or LM Studio.
278+
"""
279+
280+
model: str = "qwen3-coder"
281+
max_tokens_per_batch: int = 500
282+
283+
def __post_init__(self):
284+
self.client = None
285+
self.api_base = os.environ.get("OPENAI_API_BASE")
286+
if self.api_base:
287+
# Strip trailing slash if present
288+
self.api_base = self.api_base.rstrip("/")
289+
self.model = os.environ.get("OPENAI_MODEL", self.model)
290+
self._init_client()
291+
292+
def _init_client(self):
293+
"""Initialize HTTP client for OpenAI requests."""
294+
try:
295+
import httpx
296+
297+
timeout_str = os.environ.get("OPENAI_TIMEOUT", "60.0")
298+
try:
299+
timeout = float(timeout_str)
300+
except ValueError:
301+
timeout = 60.0
302+
303+
api_key = os.environ.get("OPENAI_API_KEY", "local-llm")
304+
headers = {"Authorization": f"Bearer {api_key}"}
305+
self.client = httpx.Client(timeout=timeout, headers=headers)
306+
except ImportError:
307+
self.client = None
308+
309+
def summarize_batch(self, symbols: list[Symbol], batch_size: int = 10) -> list[Symbol]:
310+
"""Summarize a batch of symbols using OpenAI compatible endpoint."""
311+
if not self.client or not self.api_base:
312+
for sym in symbols:
313+
if not sym.summary:
314+
sym.summary = signature_fallback(sym)
315+
return symbols
316+
317+
to_summarize = [s for s in symbols if not s.summary and not s.docstring]
318+
319+
if not to_summarize:
320+
return symbols
321+
322+
for i in range(0, len(to_summarize), batch_size):
323+
batch = to_summarize[i:i + batch_size]
324+
self._summarize_one_batch(batch)
325+
326+
return symbols
327+
328+
def _summarize_one_batch(self, batch: list[Symbol]):
329+
"""Summarize one batch of symbols via HTTP POST."""
330+
prompt = self._build_prompt(batch)
331+
332+
try:
333+
payload = {
334+
"model": self.model,
335+
"messages": [{"role": "user", "content": prompt}],
336+
"max_tokens": self.max_tokens_per_batch,
337+
"temperature": 0.0,
338+
}
339+
340+
response = self.client.post(f"{self.api_base}/chat/completions", json=payload)
341+
response.raise_for_status()
342+
343+
data = response.json()
344+
# Extract content from the first choice
345+
text = data["choices"][0]["message"]["content"]
346+
summaries = self._parse_response(text, len(batch))
347+
348+
for sym, summary in zip(batch, summaries):
349+
if summary:
350+
sym.summary = summary
351+
else:
352+
sym.summary = signature_fallback(sym)
353+
354+
except Exception:
355+
for sym in batch:
356+
if not sym.summary:
357+
sym.summary = signature_fallback(sym)
358+
359+
def _build_prompt(self, symbols: list[Symbol]) -> str:
360+
"""Build summarization prompt for a batch."""
361+
lines = [
362+
"Summarize each code symbol in ONE short sentence (max 15 words).",
363+
"Focus on what it does, not how.",
364+
"",
365+
"Input:",
366+
]
367+
368+
for i, sym in enumerate(symbols, 1):
369+
lines.append(f"{i}. {sym.kind}: {sym.signature}")
370+
371+
lines.extend([
372+
"",
373+
"Output format: NUMBER. SUMMARY",
374+
"Example: 1. Authenticates users with username and password.",
375+
"",
376+
"Summaries:",
377+
])
378+
379+
return "\n".join(lines)
380+
381+
def _parse_response(self, text: str, expected_count: int) -> list[str]:
382+
"""Parse numbered summaries from response."""
383+
summaries = [""] * expected_count
384+
385+
for line in text.split("\n"):
386+
line = line.strip()
387+
if not line:
388+
continue
389+
390+
if "." in line:
391+
parts = line.split(".", 1)
392+
try:
393+
num = int(parts[0].strip())
394+
if 1 <= num <= expected_count:
395+
summaries[num - 1] = parts[1].strip()
396+
except ValueError:
397+
continue
398+
399+
return summaries
400+
401+
273402
def _create_summarizer() -> Optional[BatchSummarizer]:
274403
"""Return the appropriate summarizer based on available API keys.
275404
276-
Priority: Anthropic > Google Gemini.
405+
Priority: Anthropic > Google Gemini > OpenAI/Local.
277406
Returns None if no API keys are configured.
278407
"""
279408
if os.environ.get("ANTHROPIC_API_KEY"):
@@ -285,6 +414,11 @@ def _create_summarizer() -> Optional[BatchSummarizer]:
285414
s = GeminiBatchSummarizer()
286415
if s.client:
287416
return s
417+
418+
if os.environ.get("OPENAI_API_BASE"):
419+
s = OpenAIBatchSummarizer()
420+
if s.client:
421+
return s
288422

289423
return None
290424

@@ -313,14 +447,14 @@ def summarize_symbols(symbols: list[Symbol], use_ai: bool = True) -> list[Symbol
313447
"""Full three-tier summarization.
314448
315449
Tier 1: Docstring extraction (free)
316-
Tier 2: AI batch summarization (Claude Haiku or Gemini Flash, auto-detected)
450+
Tier 2: AI batch summarization (Claude Haiku, Gemini Flash, or Local LLM)
317451
Tier 3: Signature fallback (always works)
318452
319-
Provider selection (Tier 2):
320-
- ANTHROPIC_API_KEY set → Claude Haiku
321-
- GOOGLE_API_KEY set → Gemini Flash
322-
- Both set → Anthropic takes priority
323-
- Neither set → skip to Tier 3
453+
Provider selection (Tier 2 priority):
454+
1. ANTHROPIC_API_KEY set → Claude Haiku
455+
2. GOOGLE_API_KEY set → Gemini Flash
456+
3. OPENAI_API_BASE set → Local LLM via OpenAI compatible endpoint
457+
- None set → skip to Tier 3
324458
"""
325459
# Tier 1: Extract from docstrings
326460
for sym in symbols:

tests/test_summarizer.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
signature_fallback,
99
summarize_symbols_simple,
1010
GeminiBatchSummarizer,
11+
OpenAIBatchSummarizer,
1112
)
1213

1314

@@ -155,3 +156,76 @@ def test_gemini_summarizer_with_mock_client():
155156
summarizer.summarize_batch(symbols)
156157
assert symbols[0].summary == "Computes the sum of two integers."
157158

159+
160+
def test_openai_summarizer_no_api_base():
161+
"""OpenAIBatchSummarizer falls back to signature when no API base is set."""
162+
with patch.dict("os.environ", {}, clear=True):
163+
summarizer = OpenAIBatchSummarizer()
164+
assert summarizer.client is None
165+
166+
symbols = [
167+
Symbol(
168+
id="test::bar",
169+
file="test.py",
170+
name="bar",
171+
qualified_name="bar",
172+
kind="function",
173+
language="python",
174+
signature="def bar():",
175+
)
176+
]
177+
summarizer.summarize_batch(symbols)
178+
assert symbols[0].summary == "def bar():"
179+
180+
181+
def test_openai_summarizer_with_mock_client():
182+
"""OpenAIBatchSummarizer parses the response from OpenAI compatible endpoints."""
183+
mock_response = MagicMock()
184+
mock_response.json.return_value = {
185+
"choices": [
186+
{
187+
"message": {"content": "1. Multiplies two integers together."}
188+
}
189+
]
190+
}
191+
192+
mock_client = MagicMock()
193+
mock_client.post.return_value = mock_response
194+
195+
with patch.dict("os.environ", {"OPENAI_API_BASE": "http://localhost:11434/v1", "OPENAI_MODEL": "qwen3-coder"}, clear=True):
196+
summarizer = OpenAIBatchSummarizer()
197+
summarizer.client = mock_client
198+
199+
symbols = [
200+
Symbol(
201+
id="test::multiply",
202+
file="test.py",
203+
name="multiply",
204+
qualified_name="multiply",
205+
kind="function",
206+
language="python",
207+
signature="def multiply(a: int, b: int) -> int:",
208+
)
209+
]
210+
summarizer.summarize_batch(symbols)
211+
212+
# Verify the endpoint URL used
213+
mock_client.post.assert_called_once()
214+
assert mock_client.post.call_args[0][0] == "http://localhost:11434/v1/chat/completions"
215+
assert symbols[0].summary == "Multiplies two integers together."
216+
217+
@pytest.mark.asyncio
218+
async def test_openai_summarizer_timeout_config():
219+
"""OpenAIBatchSummarizer configures custom timeouts via OPENAI_TIMEOUT."""
220+
# Test valid float parsing
221+
with patch.dict("os.environ", {"OPENAI_API_BASE": "http://test", "OPENAI_TIMEOUT": "120.5"}, clear=True):
222+
summarizer = OpenAIBatchSummarizer()
223+
assert summarizer.client is not None
224+
assert summarizer.client.timeout.read == 120.5
225+
226+
# Test invalid string fallback
227+
with patch.dict("os.environ", {"OPENAI_API_BASE": "http://test", "OPENAI_TIMEOUT": "invalid"}, clear=True):
228+
summarizer = OpenAIBatchSummarizer()
229+
assert summarizer.client is not None
230+
assert summarizer.client.timeout.read == 60.0
231+

0 commit comments

Comments
 (0)