Metadata-Version: 2.4
Name: lmcore
Version: 0.3.1
Summary: Personal core toolkit for AI projects — config loading, LLM client factory, and logging.
Author-email: Luigi Medrano <luigimedrano03@gmail.com>
License: MIT
Project-URL: Homepage, https://github.com/lm45562/lmcore
Project-URL: Repository, https://github.com/lm45562/lmcore
Keywords: ai,llm,openai,anthropic,ollama,config,logging
Classifier: Programming Language :: Python :: 3
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: python-dotenv>=1.0.0
Requires-Dist: pyyaml>=6.0
Provides-Extra: ollama
Requires-Dist: ollama>=0.4.0; extra == "ollama"
Provides-Extra: openai
Requires-Dist: openai>=1.50.0; extra == "openai"
Provides-Extra: anthropic
Requires-Dist: anthropic>=0.40.0; extra == "anthropic"
Provides-Extra: observability
Requires-Dist: langfuse>=2.0.0; extra == "observability"
Provides-Extra: all
Requires-Dist: ollama>=0.4.0; extra == "all"
Requires-Dist: openai>=1.50.0; extra == "all"
Requires-Dist: anthropic>=0.40.0; extra == "all"
Requires-Dist: langfuse>=2.0.0; extra == "all"

# lmcore

Personal core toolkit for AI projects. Install once, call with inputs, never edit the package.

Covers four things every project needs:
- **Config** — load and validate `.env` secrets
- **LLM** — build normalized AI clients from a `models.yaml` file
- **Logging** — colored, consistent logging across all modules
- **Observability** — optional Langfuse tracing, zero config if you don't need it

---

## Install

```bash
pip install lmcore
```

With specific provider dependencies:

```bash
pip install lmcore[openai]
pip install lmcore[anthropic]
pip install lmcore[ollama]
pip install lmcore[observability]   # langfuse tracing
pip install lmcore[all]             # everything
```

Mix and match with quotes (required in zsh):

```bash
pip install 'lmcore[ollama,observability]'
```

---

## Config

Call `load_config` once at startup. Pass the keys your project needs — the package handles loading from `.env`, validating, and returning a plain dict.

```python
from lmcore import load_config

cfg = load_config(
    required=["OPENAI_API_KEY", "CHROMA_API_KEY"],
    optional={"ACTIVE_ARCH": "arch_1", "LOG_LEVEL": "INFO"}
)
```

- `required` — must exist in `.env`, raises `EnvironmentError` if any are missing
- `optional` — uses the default value if not set in `.env`
- returns a plain `dict` — access values with `cfg["KEY"]`

---

## LLM

All providers return the same normalized types — `ChatResponse`, `StreamChunk`, `EmbedResponse` — so your code never changes when you swap providers.

### models.yaml

Lives in your **project root**. Define only what your project uses — `chat` and `embed` are both optional.

```yaml
arch_1:
  chat:
    - provider: openai
      model: gpt-4o
      api_key_env: OPENAI_API_KEY
    - provider: anthropic
      model: claude-sonnet-4-6
      api_key_env: ANTHROPIC_API_KEY
  embed:
    - provider: ollama
      model: nomic-embed-text
      base_url_env: OLLAMA_URL

arch_2:
  chat:
    - provider: anthropic
      model: claude-opus-4-8
      api_key_env: ANTHROPIC_API_KEY
```

**Supported fields per entry:**

| Field | Required | Description |
|---|---|---|
| `provider` | yes | `ollama`, `openai`, or `anthropic` |
| `model` | yes | model name string |
| `api_key_env` | when needed | env var name that holds the API key |
| `base_url` | no | override default endpoint (e.g. local Ollama) |
| `base_url_env` | no | env var name that holds the base URL |

### Building a factory

```python
import asyncio
from lmcore import load_config, load_arch, LLMFactory, ChatRequest, Message

cfg = load_config(optional={"ACTIVE_ARCH": "arch_1"})
arch = load_arch("models.yaml", arch=cfg["ACTIVE_ARCH"])
factory = LLMFactory.from_arch(arch)

provider = factory.get("openai")   # or "anthropic", "ollama"
```

`LLMFactory.from_arch` wires up all providers in the arch and automatically wraps each one with logging, retry, and observability middleware.

### Chat

```python
async def main():
    request = ChatRequest(
        model="gpt-4o",
        messages=[Message(role="user", content="Summarize this document.")],
    )
    response = await provider.chat(request)
    print(response.content)
    print(response.input_tokens, response.output_tokens)

asyncio.run(main())
```

`ChatResponse` fields:

| Field | Type | Description |
|---|---|---|
| `content` | `str` | Full reply text |
| `model` | `str` | Model that responded |
| `input_tokens` | `int` | Tokens in the prompt |
| `output_tokens` | `int` | Tokens in the reply |

### Streaming

```python
async def main():
    request = ChatRequest(
        model="gpt-4o",
        messages=[Message(role="user", content="Write a short story.")],
    )
    async for chunk in provider.stream(request):
        print(chunk.delta, end="", flush=True)
    print()

asyncio.run(main())
```

`StreamChunk` fields:

| Field | Type | Description |
|---|---|---|
| `delta` | `str` | New text fragment |
| `done` | `bool` | `True` on the final chunk |

### Embeddings

```python
from lmcore import EmbedRequest

async def main():
    embed_provider = factory.get("ollama")
    request = EmbedRequest(model="nomic-embed-text", inputs=["Hello world", "Another text"])
    response = await embed_provider.embed(request)
    print(response.embeddings)   # list[list[float]]

asyncio.run(main())
```

### System prompt

Pass `system` directly on `ChatRequest` — all adapters handle it correctly regardless of provider:

```python
request = ChatRequest(
    model="claude-opus-4-8",
    messages=[Message(role="user", content="Hello")],
    system="You are a concise assistant.",
)
```

### Multiple providers in one arch

```python
arch = load_arch("models.yaml", arch="arch_1")
factory = LLMFactory.from_arch(arch)

openai_chat    = factory.get("openai")
anthropic_chat = factory.get("anthropic")
ollama_embed   = factory.get("ollama")
```

### Listing available providers

```python
factory.list_providers()   # ["openai", "anthropic", "ollama"]
"openai" in factory        # True
```

### Manual registration

Build and register a provider by hand if you need full control:

```python
from lmcore import LLMFactory
from lmcore.providers.openai import OpenAIAdapter
from openai import AsyncOpenAI

factory = LLMFactory()
factory.register("openai", OpenAIAdapter(AsyncOpenAI(api_key="sk-...")))
```

---

## Observability

lmcore has optional Langfuse tracing built in. Every chat, stream, and embed call gets a span + generation automatically — you don't write any tracing code in your app.

### Setup

Install the extra:

```bash
pip install 'lmcore[observability]'
```

Add three keys to your `.env`:

```
LANGFUSE_SECRET_KEY=sk-lf-...
LANGFUSE_PUBLIC_KEY=pk-lf-...
LANGFUSE_BASE_URL=https://cloud.langfuse.com
```

That's it. `LLMFactory.from_arch` picks them up automatically on startup. If the keys aren't there or the package isn't installed, everything runs normally with no tracing and no errors.

### What gets traced

| Call | Span | Generation |
|---|---|---|
| `provider.chat(...)` | `lmcore.chat` | `chat-completion` with model, input messages, output text, token usage |
| `provider.stream(...)` | `lmcore.stream` | `stream-completion` with model, input messages, full accumulated output |
| `provider.embed(...)` | `lmcore.embed` | — (span only, with input texts, vector count, dimensions) |

### Sessions

lmcore traces individual calls. Session grouping is your app's job — use Langfuse's session API in your application layer if you need to group traces across a conversation.

---

## Logging

```python
from lmcore import configure_logging, get_logger

configure_logging()          # call once at app startup
configure_logging("DEBUG")   # optional level override

logger = get_logger(__name__)

logger.info("Starting up")
logger.warning("Something looks off")
logger.error("Connection failed")
```

Color scheme:

| Color | Level |
|---|---|
| Cyan | INFO |
| Yellow | WARNING |
| Red | ERROR / CRITICAL |
| Dim | DEBUG |

---

## Full Example

**Project layout:**
```
my-project/
├── .env
├── models.yaml
└── app/
    └── core/
        ├── config.py
        └── llm.py
```

**`.env`:**
```
OPENAI_API_KEY=sk-...
ACTIVE_ARCH=arch_1
LANGFUSE_SECRET_KEY=sk-lf-...
LANGFUSE_PUBLIC_KEY=pk-lf-...
LANGFUSE_BASE_URL=https://cloud.langfuse.com
```

**`models.yaml`:**
```yaml
arch_1:
  chat:
    - provider: openai
      model: gpt-4o
      api_key_env: OPENAI_API_KEY
```

**`app/core/config.py`:**
```python
from lmcore import load_config, configure_logging

configure_logging()

cfg = load_config(
    required=["OPENAI_API_KEY"],
    optional={"ACTIVE_ARCH": "arch_1"}
)
```

**`app/core/llm.py`:**
```python
from lmcore import load_arch, LLMFactory
from app.core.config import cfg

arch = load_arch("models.yaml", arch=cfg["ACTIVE_ARCH"])
factory = LLMFactory.from_arch(arch)
provider = factory.get("openai")
```

**Anywhere in the project:**
```python
import asyncio
from lmcore import get_logger, ChatRequest, Message
from app.core.llm import provider

logger = get_logger(__name__)

async def run():
    response = await provider.chat(
        ChatRequest(
            model="gpt-4o",
            messages=[Message(role="user", content="Summarize this document.")],
        )
    )
    logger.info(f"Done: {response.content}")

asyncio.run(run())
```

---

## Reference

### Types

```python
from lmcore import (
    Message,        # role, content
    ChatRequest,    # model, messages, temperature, max_tokens, system
    ChatResponse,   # content, model, input_tokens, output_tokens
    StreamChunk,    # delta, done
    EmbedRequest,   # model, inputs
    EmbedResponse,  # embeddings, model, input_tokens
)
```

### Config functions

```python
from lmcore import load_config, load_arch, list_archs

load_config(required=[], optional={})          # load .env keys → dict
load_arch("models.yaml", arch="arch_1")        # parse yaml → ArchConfig
list_archs("models.yaml")                      # → ["arch_1", "arch_2"]
```

### Middleware

Applied automatically by `LLMFactory.from_arch` in this order: `ObservabilityMiddleware → RetryMiddleware → LoggingMiddleware → Adapter`. Can be used manually:

```python
from lmcore import LoggingMiddleware, RetryMiddleware, ObservabilityMiddleware

provider = ObservabilityMiddleware(RetryMiddleware(LoggingMiddleware(my_adapter)))
```

---

## Built-in Providers

| Provider | `provider:` value | Install extra | Chat | Embed |
|---|---|---|---|---|
| OpenAI | `openai` | `pip install lmcore[openai]` | ✓ | ✓ |
| Anthropic | `anthropic` | `pip install lmcore[anthropic]` | ✓ | — |
| Ollama | `ollama` | `pip install lmcore[ollama]` | ✓ | ✓ |
