Metadata-Version: 2.5
Name: tati-langchain
Version: 0.3.1
Summary: Framework-agnostic AI/LangChain engine: OpenAI/Anthropic/Bedrock chat models, Pydantic structured outputs, tool-calling agents, and pure cost calculators.
Project-URL: Homepage, https://github.com/TatiSoftware/tati-langchain
Project-URL: Changelog, https://github.com/TatiSoftware/tati-langchain/blob/main/CHANGELOG.md
Project-URL: Repository, https://github.com/TatiSoftware/tati-langchain
Author: Tati Software Pty Ltd
License: Proprietary — Internal use only (Tati Software Pty Ltd)
License-File: LICENSE
Keywords: agent,ai,anthropic,aws,bedrock,chatbot,langchain,llm,openai,pydantic,structured-output
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: Other/Proprietary License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.10
Requires-Dist: langchain-anthropic>=0.3
Requires-Dist: langchain-openai>=0.3
Requires-Dist: langchain>=0.3
Requires-Dist: openai>=1.0
Requires-Dist: pydantic>=2.0
Provides-Extra: bedrock
Requires-Dist: langchain-aws>=0.2; extra == 'bedrock'
Provides-Extra: dev
Requires-Dist: build>=1.0; extra == 'dev'
Requires-Dist: hatch>=1.0; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# tati-langchain

A framework-agnostic AI/LangChain engine you can `pip install` into **any**
Python project (Django, FastAPI, Flask, a script, …).

Covers the pieces every AI app ends up re-building:

| Capability | Entry point |
| --- | --- |
| OpenAI / Anthropic / Bedrock providers | `Provider`, `ModelSpec`, `ProviderStack`, `build_chat_model` |
| Text generation | `generate_text` |
| Image generation | built-in `generate_image` tool (OpenAI Images API) |
| Long-form writing / research | high `max_output_tokens` + native web search |
| Cost extraction per run | `extract_usage`, `calculate_message_cost`, `cost_for_agent_result` |
| Structured outputs (Pydantic) | `generate_structured`, `with_structured_output` |
| Agentic tool loop | `run_tool_loop` |
| Custom tools | `define_tool` / `@tool`, `bind_extra_tools` |

Every Django/ORM/settings dependency from the source project has been swapped
for plain dataclasses and explicit function arguments.

## Install

Private GitHub repo: [TatiSoftware/tati-langchain](https://github.com/TatiSoftware/tati-langchain).
You need a GitHub PAT with `contents:read` on this repo. Export it first so
`${GITHUB_TOKEN}` expands in the install URL:

```bash
export GITHUB_TOKEN="ghp_xxxxxxxxxxxxxxxxxxxx"
```

### From a consuming project (`requirements.in`)

Add a pinned git dependency to your project's `requirements.in`:

```text
tati-langchain @ git+https://tati-digital:${GITHUB_TOKEN}@github.com/TatiSoftware/tati-langchain.git@v0.2.0
# or with Bedrock:
# tati-langchain[bedrock] @ git+https://tati-digital:${GITHUB_TOKEN}@github.com/TatiSoftware/tati-langchain.git@v0.2.0
```

Then install:

```bash
export GITHUB_TOKEN="ghp_xxxxxxxxxxxxxxxxxxxx"
pip install -r requirements.in
```

(If you use pip-tools: `pip-compile requirements.in && pip-sync`, with the
token exported in the same shell so the git URL can authenticate.)

### Direct `pip install`

```bash
export GITHUB_TOKEN="ghp_xxxxxxxxxxxxxxxxxxxx"

# core (OpenAI + Anthropic) — pin to a released tag
pip install "tati-langchain @ git+https://tati-digital:${GITHUB_TOKEN}@github.com/TatiSoftware/tati-langchain.git@v0.2.0"

# + AWS Bedrock
pip install "tati-langchain[bedrock] @ git+https://tati-digital:${GITHUB_TOKEN}@github.com/TatiSoftware/tati-langchain.git@v0.2.0"
```

`tati-digital` is just the username placeholder in the URL (GitHub ignores it
when a token is present; keep it for clarity in CI).

Set credentials the normal LangChain way:

- OpenAI → `OPENAI_API_KEY`
- Anthropic → `ANTHROPIC_API_KEY`
- Bedrock → standard AWS credentials (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_REGION`, or an instance role)

## 1. Pick a provider and build a model

```python
from decimal import Decimal
from tati_langchain import ModelSpec, ProviderStack, Provider, build_chat_model

# --- OpenAI ---
openai_stack = ProviderStack(
    provider=Provider.OPENAI,
    display_name="OpenAI",
    chat_model=ModelSpec(
        provider=Provider.OPENAI,
        name="gpt-5.4-mini",
        supports_vision=True,
        supports_tools=True,
        max_output_tokens=4096,
        input_cost_per_1m_tokens=Decimal("0.15"),
        output_cost_per_1m_tokens=Decimal("0.60"),
    ),
    supports_web_search=True,
)
openai_bundle = build_chat_model(openai_stack)

# --- Anthropic ---
anthropic_stack = ProviderStack(
    provider=Provider.ANTHROPIC,
    display_name="Anthropic",
    chat_model=ModelSpec(
        provider=Provider.ANTHROPIC,
        name="claude-haiku-4-5",
        max_output_tokens=4096,
        input_cost_per_1m_tokens=Decimal("0.80"),
        output_cost_per_1m_tokens=Decimal("4.00"),
    ),
    supports_web_search=True,
)
anthropic_bundle = build_chat_model(anthropic_stack)

# --- AWS Bedrock (Converse API — best for tool calling) ---
# requires: pip install "tati-langchain[bedrock]"
bedrock_stack = ProviderStack(
    provider=Provider.BEDROCK_CONVERSE,
    display_name="Bedrock",
    chat_model=ModelSpec(
        provider=Provider.BEDROCK_CONVERSE,
        name="anthropic.claude-3-5-sonnet-20241022-v2:0",
        max_output_tokens=4096,
        extra_params={"region_name": "eu-west-1"},  # forwarded to ChatBedrockConverse
    ),
    supports_web_search=False,  # no native Bedrock web-search tool in this package
)
bedrock_bundle = build_chat_model(bedrock_stack, include_default_tools=False)
```

`build_chat_model` returns a `ChatModelBundle`:

- `bundle.chat_llm` — model with tools bound (use with `run_tool_loop`)
- `bundle.raw_llm` — unbound model (use with `generate_text` / `generate_structured`)
- `bundle.tools_by_name` — local tools the agent loop can execute

## 2. Text generation

```python
from langchain_core.messages import HumanMessage, SystemMessage
from tati_langchain import generate_text

result = generate_text(
    openai_bundle.raw_llm,
    [
        SystemMessage("You are a concise assistant."),
        HumanMessage("Explain vector databases in two sentences."),
    ],
    model=openai_stack.chat_model,  # optional — enables result.cost
)
print(result.text)
print(result.usage)          # {"input_tokens", "output_tokens", "cached_input_tokens"}
print(result.cost.total_cost if result.cost else None)
```

## 3. Long-form writing & research

Long-form = high `max_output_tokens`. Research = turn on native web search
(OpenAI / Anthropic) and ask the model to cite sources.

```python
from langchain_core.messages import HumanMessage, SystemMessage
from tati_langchain import ModelSpec, Provider, ProviderStack, build_chat_model, run_tool_loop

research_stack = ProviderStack(
    provider=Provider.OPENAI,
    display_name="Research",
    chat_model=ModelSpec(
        provider=Provider.OPENAI,
        name="gpt-5.4",
        max_output_tokens=16000,  # long-form headroom
    ),
    supports_web_search=True,     # binds the provider-native web_search tool
)
bundle = build_chat_model(research_stack)

messages = [
    SystemMessage(
        "You are a research analyst. Use web search. Write a structured brief "
        "with a summary, key findings, and cited sources."
    ),
    HumanMessage("What changed in EU AI Act enforcement in the last 6 months?"),
]
result = run_tool_loop(bundle.chat_llm, messages, bundle.tools_by_name)
print(result.ai_message.content)
```

## 4. Image generation

Built-in `generate_image` tool (OpenAI Images API). Works even on an Anthropic
/ Bedrock chat stack if you point `image_model` at an OpenAI image model.

```python
from decimal import Decimal
from langchain_core.messages import HumanMessage
from tati_langchain import (
    ImageModelSpec, ModelSpec, Provider, ProviderStack,
    build_chat_model, run_tool_loop, calculate_image_cost,
)

stack = ProviderStack(
    provider=Provider.OPENAI,
    display_name="Creative",
    chat_model=ModelSpec(provider=Provider.OPENAI, name="gpt-5.4-mini"),
    image_model=ImageModelSpec(
        provider=Provider.OPENAI,
        name="gpt-image-1-mini",
        text_input_cost_per_1m=Decimal("5.00"),
        image_output_cost_per_1m=Decimal("40.00"),
    ),
)
bundle = build_chat_model(stack)
result = run_tool_loop(
    bundle.chat_llm,
    [HumanMessage("Draw a red fox wearing sunglasses")],
    bundle.tools_by_name,
    on_progress=print,  # optional: "🎨 Image generation triggered..."
)

for att in result.attachments:
    open("fox.png", "wb").write(att.data)

for usage in result.image_usages:
    print(calculate_image_cost(model=stack.image_model, **usage["tokens"]))
```

## 5. Cost extraction (what a run actually cost)

```python
from tati_langchain import extract_usage, calculate_message_cost, cost_for_agent_result

# Plain text turn
usage = extract_usage(result.ai_message)
breakdown = calculate_message_cost(model=stack.chat_model, **usage)
print(breakdown.total_cost, breakdown.currency)

# Full agent turn (chat tokens + any image tool usages)
message_cost, image_costs = cost_for_agent_result(
    ai_message=result.ai_message,
    model=stack.chat_model,
    image_usages=result.image_usages,
)
print(message_cost.total_cost, [c.total_cost for c in image_costs])
```

Nothing is persisted — you decide whether that becomes a DB row, a log line,
or a metrics counter.

## 6. Structured outputs with Pydantic

```python
from pydantic import BaseModel, Field
from langchain_core.messages import HumanMessage
from tati_langchain import generate_structured, calculate_message_cost

class BookRec(BaseModel):
    title: str
    author: str
    reason: str = Field(description="One-sentence why this fits")

structured = generate_structured(
    openai_bundle.raw_llm,
    [HumanMessage("Recommend one sci-fi book for a beginner.")],
    BookRec,
)
print(structured.parsed.title, structured.parsed.author)
print(structured.usage)
if structured.raw_message is not None:
    print(calculate_message_cost(model=openai_stack.chat_model, **structured.usage))
```

Or bind once and reuse:

```python
from tati_langchain import with_structured_output

llm = with_structured_output(openai_bundle.raw_llm, BookRec)
rec = llm.invoke([HumanMessage("Recommend a mystery novel.")])
```

## 7. Agentic design + custom tools

```python
from langchain_core.messages import HumanMessage
from tati_langchain import define_tool, build_chat_model, run_tool_loop, bind_extra_tools

@define_tool
def lookup_order(order_id: str) -> str:
    """Look up an order by id and return its status."""
    return f"Order {order_id}: shipped"

# Option A — pass extra tools at build time
bundle = build_chat_model(openai_stack, extra_tools=[lookup_order])

# Option B — rebind onto an existing bundle
bundle = bind_extra_tools(bundle, [lookup_order])

result = run_tool_loop(
    bundle.chat_llm,
    [HumanMessage("Where is order A-100?")],
    bundle.tools_by_name,
)
print(result.ai_message.content)
```

`run_tool_loop` is provider-agnostic: it invokes the model, executes any
**local** tool calls registered in `tools_by_name`, feeds results back, and
stops after a small iteration cap (or when a tool signals `forced_reply` /
`limit_reached`). Provider-native tools (e.g. web search) never appear in
`tool_calls` — the provider resolves them server-side.

### Tool with an explicit Pydantic args schema

```python
from pydantic import BaseModel, Field
from tati_langchain import define_tool

class SearchArgs(BaseModel):
    query: str
    limit: int = Field(default=5, ge=1, le=20)

@define_tool(args_schema=SearchArgs)
def search_docs(query: str, limit: int = 5) -> str:
    """Search the internal docs corpus."""
    return f"top {limit} hits for {query!r}"
```

## 8. Document generation

`generate_document` degrades gracefully (returns an "unavailable" message to
the model, doesn't raise) until the optional `tati-docgen` package is also
installed — at which point it starts working with no code change.

## Design principles

- **You own persistence, config, and "what's active."** This package never
  reads a global settings object and never writes to a database.
- **Pure cost math, no side effects.** Cost helpers return dataclasses; you
  decide how to store them.
- **No messaging dependency.** Tool attachments come back as this package's
  own `ToolAttachment` — map to WhatsApp/email/etc. at the call site.

## What's *not* in this package (by design)

- Model-catalog / "which stack is active" storage
- Conversation history storage
- Free-trial / usage-limit gating (tools may still signal `limit_reached`)
- Sending replies to WhatsApp/email (see `tati-whatsapp`)
- i18n for progress strings — override via `progress_text_builders=`

## Development

```bash
python3 -m venv .venv && source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install hatch build
pip install -e ".[dev]"
# optional Bedrock extra while developing against it:
# pip install -e ".[dev,bedrock]"
pytest --cov=tati_langchain --cov-report=term-missing
ruff check .
```

The package version lives in a single place:
[`src/tati_langchain/__about__.py`](src/tati_langchain/__about__.py).
`pyproject.toml` reads it via Hatch (`dynamic = ["version"]`).

### Version bumping

Hatch owns semantic version bumps:

| Command | From → To (example) |
| --- | --- |
| `hatch version patch` | `0.1.0` → `0.1.1` |
| `hatch version minor` | `0.1.1` → `0.2.0` |
| `hatch version major` | `0.2.0` → `1.0.0` |

```bash
# see current version
hatch version

# bump (edits __about__.py)
hatch version patch   # or: minor / major
```

### Releasing (git tag)

We ship by **git tag**. After bumping:

```bash
# 1) bump version (example: first public-ish release)
hatch version 0.1.0          # set explicitly, or use patch/minor/major

# 2) commit + tag + push
git add .
git commit -m "Release v$(hatch version)"
git tag "v$(hatch version)"
git push -u origin main
git push origin "v$(hatch version)"
```

Consumers then install that tag from the private repo, e.g.:

```bash
export GITHUB_TOKEN="ghp_xxxxxxxxxxxxxxxxxxxx"
pip install -r requirements.in
# or directly:
pip install "tati-langchain @ git+https://tati-digital:${GITHUB_TOKEN}@github.com/TatiSoftware/tati-langchain.git@v0.1.0"
```

### GitHub Actions (manual only)

Both workflows are **dispatch-only** — they never run on push/PR automatically.

| Workflow | File | What it does |
| --- | --- | --- |
| **Tests** | `.github/workflows/tests.yml` | Ruff + pytest on Python 3.10 / 3.11 / 3.12 |
| **Publish to PyPI** | `.github/workflows/publish-pypi.yml` | Build sdist + wheel and upload to PyPI |

Run them from GitHub → **Actions** → pick the workflow → **Run workflow**.

### Publishing to PyPI

The publish workflow uploads **whatever version is in the repo at checkout**
(from `__about__.py`). It does not invent a version or create a git tag.

**One-time setup**

1. Create a [pypi.org](https://pypi.org) account and API token (`pypi-...`).
2. In the GitHub repo: **Settings → Secrets and variables → Actions → New repository secret**
   - Name: `PYPI_API_TOKEN`
   - Value: your PyPI API token

**Release steps**

1. Bump, commit, tag, and push locally (see above).
2. GitHub → **Actions → Publish to PyPI → Run workflow**.
3. In the confirm box, type exactly: `publish`  
   (anything else skips the job).

**What the Action does**

1. Checks out the repo  
2. Installs `build` + `hatch`  
3. Prints the version (`hatch version`)  
4. Runs `python -m build` → sdist + wheel under `dist/`  
5. Uploads to **https://pypi.org** via `pypa/gh-action-pypi-publish` using `PYPI_API_TOKEN`

**After it’s live**

```bash
pip install tati-langchain==0.2.0
```

(use the version you published)

**What it does *not* do**

- Does not run on push/tag automatically — only on dispatch  
- Does not create the git tag for you — bump + tag + push first  
- Does not publish to TestPyPI (only real PyPI)

**Note:** A proprietary “internal use only” license can still sit on public
PyPI and be installable by anyone who knows the package name. Prefer the
private GitHub install path if you only want Tati machines to pull it.

## License

Proprietary — **internal use only** within Tati Software Pty Ltd.
See [`LICENSE`](LICENSE).
