Skip to content

LLM API

factory

LLM Provider Factory - intelligently selects the best provider.

Priority order: 1. MCP Delegation (if in Claude Code/Cursor context) 2. Explicit CLI flags (--llm, --model) 3. Environment variables (NERVAPACK_LLM_PROVIDER, *_API_KEY) 4. Config file (.nervapack/config.yaml) 5. Ollama (default fallback)

detect_mcp_context()

Detect if NervaPack is running in an MCP context.

Returns:

Type Description
bool

True if running as MCP server (e.g., through Claude Code)

Source code in src/nervapack/llm/factory.py
def detect_mcp_context() -> bool:
    """
    Detect if NervaPack is running in an MCP context.

    Returns:
        True if running as MCP server (e.g., through Claude Code)
    """
    # Check for MCP-specific environment variables
    if os.getenv("MCP_SERVER_NAME") == "nervapack":
        return True

    # Check if stdin/stdout are pipes (typical for MCP stdio transport)
    if not sys.stdin.isatty() and not sys.stdout.isatty():
        # Additional check: MCP servers typically don't have $TERM set
        if not os.getenv("TERM"):
            return True

    return False

get_llm_provider(provider=None, model=None, api_key=None, prefer_mcp=True)

Get the appropriate LLM provider based on context and configuration.

Priority order: 1. MCP delegation (if in MCP context and prefer_mcp=True) 2. Explicit parameters (provider, model, api_key) 3. Environment variables 4. Config file 5. Ollama (default)

Parameters:

Name Type Description Default
provider Optional[str]

Explicit provider name ("ollama", "claude", "openai", "mcp")

None
model Optional[str]

Model name (provider-specific)

None
api_key Optional[str]

API key for cloud providers

None
prefer_mcp bool

If True, auto-detect and prefer MCP delegation

True

Returns:

Type Description
LLMProvider

Configured LLM provider

Raises:

Type Description
ValueError

If provider is invalid or required config is missing

Source code in src/nervapack/llm/factory.py
def get_llm_provider(
    provider: Optional[str] = None,
    model: Optional[str] = None,
    api_key: Optional[str] = None,
    prefer_mcp: bool = True
) -> LLMProvider:
    """
    Get the appropriate LLM provider based on context and configuration.

    Priority order:
    1. MCP delegation (if in MCP context and prefer_mcp=True)
    2. Explicit parameters (provider, model, api_key)
    3. Environment variables
    4. Config file
    5. Ollama (default)

    Args:
        provider: Explicit provider name ("ollama", "claude", "openai", "mcp")
        model: Model name (provider-specific)
        api_key: API key for cloud providers
        prefer_mcp: If True, auto-detect and prefer MCP delegation

    Returns:
        Configured LLM provider

    Raises:
        ValueError: If provider is invalid or required config is missing
    """
    # Priority 1: MCP delegation (if in MCP context)
    if prefer_mcp and provider != "ollama":  # Allow explicit Ollama override
        if detect_mcp_context() or provider == "mcp":
            return MCPDelegationProvider()

    # Priority 2: Get provider from parameters or environment
    provider = provider or os.getenv("NERVAPACK_LLM_PROVIDER", "ollama")
    provider = provider.lower()

    # Priority 3: Dispatch to appropriate provider
    if provider == "ollama":
        # "llama3" is passed as preferred but OllamaProvider._resolve_model()
        # will fall back to the first available model if llama3 isn't installed.
        model = model or os.getenv("OLLAMA_MODEL", "llama3")
        base_url = os.getenv("OLLAMA_BASE_URL")
        return OllamaProvider(model=model, base_url=base_url)

    elif provider in ["claude", "claude-api", "anthropic"]:
        if ClaudeAPIProvider is None:
            raise ValueError(
                "Claude API provider not available. "
                "Install with: pip install 'nervapack[claude]'"
            )
        api_key = api_key or os.getenv("ANTHROPIC_API_KEY")
        model = model or os.getenv("CLAUDE_MODEL", "claude-haiku-4-5-20251001")

        if not api_key:
            raise ValueError(
                "Claude API key required. Set ANTHROPIC_API_KEY environment variable "
                "or pass api_key parameter. Get your key at: "
                "https://console.anthropic.com/"
            )

        return ClaudeAPIProvider(api_key=api_key, model=model)

    elif provider in ["openai", "openai-api", "gpt"]:
        if OpenAIProvider is None:
            raise ValueError(
                "OpenAI API provider not available. "
                "Install with: pip install 'nervapack[openai]'"
            )
        api_key = api_key or os.getenv("OPENAI_API_KEY")
        model = model or os.getenv("OPENAI_MODEL", "gpt-4o-mini")

        if not api_key:
            raise ValueError(
                "OpenAI API key required. Set OPENAI_API_KEY environment variable "
                "or pass api_key parameter. Get your key at: "
                "https://platform.openai.com/api-keys"
            )

        return OpenAIProvider(api_key=api_key, model=model)

    elif provider == "mcp":
        return MCPDelegationProvider()

    else:
        raise ValueError(
            f"Unknown provider: {provider}\n"
            f"Available providers: ollama, claude, openai, mcp"
        )

load_config_file(project_root='.')

Load configuration from .nervapack/config.yaml.

Parameters:

Name Type Description Default
project_root str

Project root directory

'.'

Returns:

Type Description
dict

Config dict, or empty dict if file doesn't exist

Source code in src/nervapack/llm/factory.py
def load_config_file(project_root: str = ".") -> dict:
    """
    Load configuration from .nervapack/config.yaml.

    Args:
        project_root: Project root directory

    Returns:
        Config dict, or empty dict if file doesn't exist
    """
    config_path = Path(project_root) / ".nervapack" / "config.yaml"

    if not config_path.exists():
        return {}

    try:
        import yaml
        with open(config_path) as f:
            return yaml.safe_load(f) or {}
    except ImportError:
        # PyYAML not installed - that's okay, just skip config file
        return {}
    except Exception:
        # Config file exists but can't be parsed - skip it
        return {}

base

Base LLM Provider abstraction for NervaPack.

Supports multiple LLM backends: - MCP Delegation (Claude Code/Cursor - uses existing auth) - Ollama (local, privacy-first) - Claude API (cloud, direct) - OpenAI API (cloud, direct)

LLMProvider

Bases: ABC

Abstract base class for LLM providers.

Source code in src/nervapack/llm/base.py
class LLMProvider(ABC):
    """Abstract base class for LLM providers."""

    @abstractmethod
    def chat(
        self,
        messages: List[Dict[str, str]],
        system_prompt: str = "",
        temperature: float = 0.0,
        max_tokens: int = 1024
    ) -> str:
        """
        Send a chat completion request.

        Args:
            messages: List of {role: str, content: str}
            system_prompt: Optional system message
            temperature: Sampling temperature (0.0 = deterministic)
            max_tokens: Maximum tokens to generate

        Returns:
            Response text content

        Raises:
            LLMProviderError: If the request fails
        """
        pass

    @abstractmethod
    def validate_config(self) -> bool:
        """
        Check if provider is properly configured.

        Returns:
            True if provider can make requests, False otherwise
        """
        pass

    @abstractmethod
    def get_provider_name(self) -> str:
        """
        Get the provider name for logging/display.

        Returns:
            Provider name (e.g., "ollama", "claude-api", "mcp-delegation")
        """
        pass

    @abstractmethod
    def estimate_cost(self, num_calls: int) -> Optional[float]:
        """
        Estimate cost for N API calls.

        Args:
            num_calls: Number of binding calls to estimate

        Returns:
            Estimated cost in USD, or None if free/unknown
        """
        pass

    def summarize_entity(
        self,
        entity_type: str,
        entity_name: str,
        content: str
    ) -> str:
        """
        Generate a summary for a code entity.

        This is shared logic across all providers.

        Args:
            entity_type: Type of entity (function, class, etc.)
            entity_name: Name of the entity
            content: Code content to summarize

        Returns:
            1-3 sentence summary
        """
        prompt = (
            f"Summarize the following {entity_type} named '{entity_name}':\n\n"
            f"```\n{content}\n```\n\n"
            f"Summary (1-3 sentences):"
        )
        messages = [{"role": "user", "content": prompt}]
        system = (
            "You are a concise code summarizer. "
            "Output only a 1-3 sentence summary of what the code does."
        )

        try:
            return self.chat(messages, system_prompt=system)
        except Exception as e:
            return f"Summary unavailable: {str(e)}"

    def bind_docs_to_ast(
        self,
        doc_chunk: str,
        ast_nodes: List[Dict[str, str]]
    ) -> List[str]:
        """
        Identify which AST nodes a documentation chunk explains.

        Pre-filters candidates with keyword overlap so the LLM prompt
        stays small enough for local models to handle reliably.
        """
        if not ast_nodes:
            return []

        # Pre-filter: score each node by keyword overlap with the doc chunk,
        # keep the top 12. This prevents 500+ node prompts that cause local
        # models to hallucinate or time out.
        doc_words = set(self._tokenise(doc_chunk))
        if doc_words:
            scored = []
            for n in ast_nodes:
                node_text = f"{n['node_id']} {n.get('summary', '')}"
                node_words = set(self._tokenise(node_text))
                overlap = len(doc_words & node_words)
                scored.append((overlap, n))
            scored.sort(key=lambda x: -x[0])
            # Only keep nodes with at least 1 word overlap, max 12
            candidates_nodes = [n for score, n in scored if score > 0][:12]
        else:
            candidates_nodes = ast_nodes[:12]

        if not candidates_nodes:
            return []

        candidates = "\n".join([
            f"ID: {n['node_id']} | Summary: {n.get('summary', 'No summary')}"
            for n in candidates_nodes
        ])

        prompt = (
            f"Documentation chunk:\n{doc_chunk[:800]}\n\n"
            f"Which of these code entities does the documentation explain? "
            f"Reply with ONLY the matching IDs as a comma-separated list, or 'None'.\n\n"
            f"Candidates:\n{candidates}\n\n"
            f"Matched IDs:"
        )

        messages = [{"role": "user", "content": prompt}]
        system = (
            "You are a code documentation linker. "
            "Output ONLY a comma-separated list of IDs from the candidates, or the word None. "
            "Do not explain. Do not add text."
        )

        try:
            response = self.chat(messages, system_prompt=system, max_tokens=256).strip()

            if not response or response.lower().startswith("none"):
                return []

            # Extract only IDs that were actually in our candidate list
            valid_ids = {n["node_id"] for n in candidates_nodes}
            ids = [i.strip() for i in response.split(",") if i.strip() in valid_ids]
            return ids

        except Exception:
            return []

    @staticmethod
    def _tokenise(text: str) -> List[str]:
        """Split text into lowercase words ≥4 chars, stripping punctuation."""
        return re.findall(r"[a-zA-Z_]{4,}", text.lower())

chat(messages, system_prompt='', temperature=0.0, max_tokens=1024) abstractmethod

Send a chat completion request.

Parameters:

Name Type Description Default
messages List[Dict[str, str]]

List of {role: str, content: str}

required
system_prompt str

Optional system message

''
temperature float

Sampling temperature (0.0 = deterministic)

0.0
max_tokens int

Maximum tokens to generate

1024

Returns:

Type Description
str

Response text content

Raises:

Type Description
LLMProviderError

If the request fails

Source code in src/nervapack/llm/base.py
@abstractmethod
def chat(
    self,
    messages: List[Dict[str, str]],
    system_prompt: str = "",
    temperature: float = 0.0,
    max_tokens: int = 1024
) -> str:
    """
    Send a chat completion request.

    Args:
        messages: List of {role: str, content: str}
        system_prompt: Optional system message
        temperature: Sampling temperature (0.0 = deterministic)
        max_tokens: Maximum tokens to generate

    Returns:
        Response text content

    Raises:
        LLMProviderError: If the request fails
    """
    pass

validate_config() abstractmethod

Check if provider is properly configured.

Returns:

Type Description
bool

True if provider can make requests, False otherwise

Source code in src/nervapack/llm/base.py
@abstractmethod
def validate_config(self) -> bool:
    """
    Check if provider is properly configured.

    Returns:
        True if provider can make requests, False otherwise
    """
    pass

get_provider_name() abstractmethod

Get the provider name for logging/display.

Returns:

Type Description
str

Provider name (e.g., "ollama", "claude-api", "mcp-delegation")

Source code in src/nervapack/llm/base.py
@abstractmethod
def get_provider_name(self) -> str:
    """
    Get the provider name for logging/display.

    Returns:
        Provider name (e.g., "ollama", "claude-api", "mcp-delegation")
    """
    pass

estimate_cost(num_calls) abstractmethod

Estimate cost for N API calls.

Parameters:

Name Type Description Default
num_calls int

Number of binding calls to estimate

required

Returns:

Type Description
Optional[float]

Estimated cost in USD, or None if free/unknown

Source code in src/nervapack/llm/base.py
@abstractmethod
def estimate_cost(self, num_calls: int) -> Optional[float]:
    """
    Estimate cost for N API calls.

    Args:
        num_calls: Number of binding calls to estimate

    Returns:
        Estimated cost in USD, or None if free/unknown
    """
    pass

summarize_entity(entity_type, entity_name, content)

Generate a summary for a code entity.

This is shared logic across all providers.

Parameters:

Name Type Description Default
entity_type str

Type of entity (function, class, etc.)

required
entity_name str

Name of the entity

required
content str

Code content to summarize

required

Returns:

Type Description
str

1-3 sentence summary

Source code in src/nervapack/llm/base.py
def summarize_entity(
    self,
    entity_type: str,
    entity_name: str,
    content: str
) -> str:
    """
    Generate a summary for a code entity.

    This is shared logic across all providers.

    Args:
        entity_type: Type of entity (function, class, etc.)
        entity_name: Name of the entity
        content: Code content to summarize

    Returns:
        1-3 sentence summary
    """
    prompt = (
        f"Summarize the following {entity_type} named '{entity_name}':\n\n"
        f"```\n{content}\n```\n\n"
        f"Summary (1-3 sentences):"
    )
    messages = [{"role": "user", "content": prompt}]
    system = (
        "You are a concise code summarizer. "
        "Output only a 1-3 sentence summary of what the code does."
    )

    try:
        return self.chat(messages, system_prompt=system)
    except Exception as e:
        return f"Summary unavailable: {str(e)}"

bind_docs_to_ast(doc_chunk, ast_nodes)

Identify which AST nodes a documentation chunk explains.

Pre-filters candidates with keyword overlap so the LLM prompt stays small enough for local models to handle reliably.

Source code in src/nervapack/llm/base.py
def bind_docs_to_ast(
    self,
    doc_chunk: str,
    ast_nodes: List[Dict[str, str]]
) -> List[str]:
    """
    Identify which AST nodes a documentation chunk explains.

    Pre-filters candidates with keyword overlap so the LLM prompt
    stays small enough for local models to handle reliably.
    """
    if not ast_nodes:
        return []

    # Pre-filter: score each node by keyword overlap with the doc chunk,
    # keep the top 12. This prevents 500+ node prompts that cause local
    # models to hallucinate or time out.
    doc_words = set(self._tokenise(doc_chunk))
    if doc_words:
        scored = []
        for n in ast_nodes:
            node_text = f"{n['node_id']} {n.get('summary', '')}"
            node_words = set(self._tokenise(node_text))
            overlap = len(doc_words & node_words)
            scored.append((overlap, n))
        scored.sort(key=lambda x: -x[0])
        # Only keep nodes with at least 1 word overlap, max 12
        candidates_nodes = [n for score, n in scored if score > 0][:12]
    else:
        candidates_nodes = ast_nodes[:12]

    if not candidates_nodes:
        return []

    candidates = "\n".join([
        f"ID: {n['node_id']} | Summary: {n.get('summary', 'No summary')}"
        for n in candidates_nodes
    ])

    prompt = (
        f"Documentation chunk:\n{doc_chunk[:800]}\n\n"
        f"Which of these code entities does the documentation explain? "
        f"Reply with ONLY the matching IDs as a comma-separated list, or 'None'.\n\n"
        f"Candidates:\n{candidates}\n\n"
        f"Matched IDs:"
    )

    messages = [{"role": "user", "content": prompt}]
    system = (
        "You are a code documentation linker. "
        "Output ONLY a comma-separated list of IDs from the candidates, or the word None. "
        "Do not explain. Do not add text."
    )

    try:
        response = self.chat(messages, system_prompt=system, max_tokens=256).strip()

        if not response or response.lower().startswith("none"):
            return []

        # Extract only IDs that were actually in our candidate list
        valid_ids = {n["node_id"] for n in candidates_nodes}
        ids = [i.strip() for i in response.split(",") if i.strip() in valid_ids]
        return ids

    except Exception:
        return []

LLMProviderError

Bases: Exception

Exception raised when LLM provider encounters an error.

Source code in src/nervapack/llm/base.py
class LLMProviderError(Exception):
    """Exception raised when LLM provider encounters an error."""
    pass