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":
        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-3-haiku-20240307")

        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.

        This is the critical LLM call during ingestion.

        Args:
            doc_chunk: Markdown documentation text
            ast_nodes: List of {node_id, summary, ...} dicts

        Returns:
            List of node_ids that the doc chunk explains
        """
        if not ast_nodes:
            return []

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

        prompt = (
            f"Given the following documentation chunk:\n\n{doc_chunk}\n\n"
            f"Which of the following code entities does it explain or implement? "
            f"Return a comma-separated list of IDs only, or 'None' if none match.\n\n"
            f"Candidates:\n{candidates}\n\n"
            f"Matched IDs:"
        )

        messages = [{"role": "user", "content": prompt}]
        system = (
            "You are an AI binding engine. "
            "Output ONLY a comma-separated list of IDs, or 'None'."
        )

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

            # Parse response
            if response.lower() == "none" or not response:
                return []

            # Extract IDs
            ids = [i.strip() for i in response.split(",") if i.strip()]
            return ids

        except Exception:
            return []

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.

This is the critical LLM call during ingestion.

Parameters:

Name Type Description Default
doc_chunk str

Markdown documentation text

required
ast_nodes List[Dict[str, str]]

List of {node_id, summary, ...} dicts

required

Returns:

Type Description
List[str]

List of node_ids that the doc chunk explains

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.

    This is the critical LLM call during ingestion.

    Args:
        doc_chunk: Markdown documentation text
        ast_nodes: List of {node_id, summary, ...} dicts

    Returns:
        List of node_ids that the doc chunk explains
    """
    if not ast_nodes:
        return []

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

    prompt = (
        f"Given the following documentation chunk:\n\n{doc_chunk}\n\n"
        f"Which of the following code entities does it explain or implement? "
        f"Return a comma-separated list of IDs only, or 'None' if none match.\n\n"
        f"Candidates:\n{candidates}\n\n"
        f"Matched IDs:"
    )

    messages = [{"role": "user", "content": prompt}]
    system = (
        "You are an AI binding engine. "
        "Output ONLY a comma-separated list of IDs, or 'None'."
    )

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

        # Parse response
        if response.lower() == "none" or not response:
            return []

        # Extract IDs
        ids = [i.strip() for i in response.split(",") if i.strip()]
        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