Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/publish-langchain-ferrolabsai.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ jobs:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: pip install -e ".[dev]"
run: |
pip install -e ../..
pip install -e ".[dev]"

- name: Lint
run: ruff check langchain_ferrolabsai/
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

---

## [Unreleased]

### Added
- Test coverage for `admin.plugins.list()` response shapes.

## [0.2.0] — 2026-05-14

### Added
Expand Down
60 changes: 58 additions & 2 deletions ferrolabsai/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ def _request(
return response
if response.status_code == 204 or not response.content:
return {}
return cast("dict[str, Any]", response.json())
return _with_response_metadata(cast("dict[str, Any]", response.json()), response)
except httpx.HTTPStatusError as e:
_raise_api_error(e)
except httpx.ConnectError as e:
Expand Down Expand Up @@ -306,7 +306,7 @@ async def _request(
response.raise_for_status()
if response.status_code == 204 or not response.content:
return {}
return cast("dict[str, Any]", response.json())
return _with_response_metadata(cast("dict[str, Any]", response.json()), response)
except httpx.HTTPStatusError as e:
_raise_api_error(e)
except httpx.ConnectError as e:
Expand Down Expand Up @@ -345,6 +345,62 @@ def __init__(self, client: AsyncFerroClient) -> None:
# ------------------------------------------------------------------


def _with_response_metadata(data: dict[str, Any], response: httpx.Response) -> dict[str, Any]:
"""Copy gateway metadata headers into parsed response bodies.

Successful SDK calls return dataclasses, so header-only metadata such as
X-Request-ID must be preserved before resource classes construct them.
Body fields stay authoritative when both sources are present.
"""
trace_id = (
response.headers.get("x-request-id")
or response.headers.get("x-trace-id")
or response.headers.get("x-ferro-request-id")
)
if trace_id and "trace_id" not in data and "x_ferro_trace_id" not in data:
data["trace_id"] = trace_id

provider = response.headers.get("x-ferro-provider")
if provider and "provider" not in data and "x_ferro_provider" not in data:
data["provider"] = provider
usage = data.get("usage")
if isinstance(usage, dict) and "provider" not in usage:
usage["provider"] = provider

latency_ms = _header_int(response.headers.get("x-ferro-latency-ms"))
if latency_ms is not None and "latency_ms" not in data and "x_ferro_latency_ms" not in data:
data["x_ferro_latency_ms"] = latency_ms

cost_usd = _header_float(response.headers.get("x-ferro-cost-usd"))
if cost_usd is not None:
usage = data.get("usage")
if not isinstance(usage, dict):
usage = {}
data["usage"] = usage
if "cost_usd" not in usage:
usage["cost_usd"] = cost_usd

return data


def _header_int(value: str | None) -> int | None:
if value is None or value == "":
return None
try:
return int(float(value))
except ValueError:
return None


def _header_float(value: str | None) -> float | None:
if value is None or value == "":
return None
try:
return float(value)
except ValueError:
return None


def _raise_api_error(e: httpx.HTTPStatusError) -> None:
from .exceptions import (
FerroAPIError,
Expand Down
2 changes: 1 addition & 1 deletion ferrolabsai/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ def from_dict(cls, d: dict[str, Any]) -> ChatCompletion:
usage=Usage.from_dict(d["usage"]) if d.get("usage") else None,
trace_id=d.get("x_ferro_trace_id") or d.get("trace_id"),
provider=d.get("x_ferro_provider") or d.get("provider"),
latency_ms=d.get("x_ferro_latency_ms"),
latency_ms=d.get("x_ferro_latency_ms") or d.get("latency_ms"),
)

@property
Expand Down
52 changes: 43 additions & 9 deletions integrations/langchain-ferrolabsai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Planned

- `FerroChatModel` — `langchain_core.language_models.chat_models.BaseChatModel` adapter wrapping `ferrolabsai.FerroClient.chat.completions`.
- `FerroEmbeddings` — `langchain_core.embeddings.Embeddings` adapter wrapping `ferrolabsai.FerroClient.embeddings`.
- `FerroLLM` — completion-style adapter for legacy LangChain chains.
- Streaming + async + tool calling.
- `provider`, `cost_usd`, `latency_ms`, `trace_id` exposed via `response_metadata`.
- Native support for Ferro extras (`route_tag`, `template_id`, `template_variables`).
- pytest-httpx based test suite mirroring the parent SDK's mocking pattern.
- Async surfaces (`_agenerate`, `_astream`, `aembed_documents`, `aembed_query`).
- `with_structured_output()` helper for JSON-mode + Pydantic-schema responses.
- Native multi-modal message support (image inputs) once the gateway exposes a
stable contract.

---

## [0.0.1] — TBD
## [0.1.0] — 2026-05-25

First functional release. Replaces the `0.0.1` placeholder.

### Added

- **`FerroChatModel`** — `langchain_core.language_models.chat_models.BaseChatModel`
adapter wrapping `ferrolabsai.FerroClient.chat.completions`. Supports sync
generation, streaming via `_stream`, and tool binding via `bind_tools`
(compatible with LangGraph agents).
- **`FerroEmbeddings`** — `langchain_core.embeddings.Embeddings` adapter wrapping
`ferrolabsai.FerroClient.embeddings`. `embed_documents` preserves input order
even when the gateway returns embeddings out of order.
- **`FerroLLM`** — completion-style adapter for legacy LangChain chains. Wraps
chat completions with a single user message.
- **`trace_id` surfacing** — every chat response carries `trace_id`, `provider`,
`latency_ms`, `cost_usd`, and `cache_hit` (when present) in
`response_metadata`. `trace_id` is the join key for the v1.2 observability
bridge plugins (LangSmith, Langfuse, Phoenix, …).
- **Native support for Ferro extras** — `route_tag`, `template_id`,
`template_variables`, and `user` are first-class fields on `FerroChatModel`
and forwarded on every request.
- **`pytest-httpx`-based test suite** mirroring the parent SDK's mocking
pattern. No real gateway or network access required to run tests.

### Notes

- Async support is intentionally deferred to a follow-up release to keep the
initial diff reviewable. LangChain's default sync-fallback async behaviour
works in the meantime.
- Streaming surfaces incremental content chunks and OpenAI-style streamed
`delta.tool_calls` as LangChain `tool_call_chunks` for tool-using agents.

Placeholder release to reserve the `langchain-ferrolabsai` name on PyPI. No working implementation; importing the package raises `NotImplementedError` with a link to the roadmap.
---

## [0.0.1] — 2026-05-13

Placeholder release to reserve the `langchain-ferrolabsai` name on PyPI. No
working implementation; importing the package raised `NotImplementedError`
with a link to the roadmap.
80 changes: 70 additions & 10 deletions integrations/langchain-ferrolabsai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@

LangChain integration for [Ferro Labs AI Gateway](https://github.com/ferro-labs/ai-gateway) — route LangChain chat, streaming, tool-calling, and embedding workloads across **30+ LLM providers** through a single OpenAI-compatible endpoint, with automatic fallback, load balancing, cost tracking, and observability.

> **Status: 0.0.1 placeholder.** The full adapter (`FerroChatModel`, `FerroEmbeddings`, `FerroLLM`) is in active development as part of the [OSS Ecosystem Roadmap, Appendix A, Phase C](https://github.com/ferro-labs/ai-gateway-workspace/blob/main/docs/OSS-ECOSYSTEM-ROADMAP.md). The 0.0.1 release exists to reserve the package name on PyPI and signal upcoming work.

---

## Install
Expand All @@ -15,38 +13,100 @@ LangChain integration for [Ferro Labs AI Gateway](https://github.com/ferro-labs/
pip install langchain-ferrolabsai
```

## Planned API
## Quick start

### Chat

```python
from langchain_ferrolabsai import FerroChatModel, FerroEmbeddings
from langchain_ferrolabsai import FerroChatModel
from langchain_core.messages import HumanMessage

llm = FerroChatModel(
model="gpt-4o",
base_url="http://localhost:8080", # any Ferro Labs AI Gateway instance
api_key="sk-ferro-...",
)

response = llm.invoke("Hello, world")
response = llm.invoke([HumanMessage(content="Hello, world")])
print(response.content)
print(response.response_metadata["provider"]) # which provider handled it
print(response.response_metadata["cost_usd"]) # cost for this request
print(response.response_metadata["latency_ms"]) # observed latency
print(response.response_metadata["trace_id"]) # gateway trace ID
print(response.response_metadata["trace_id"]) # gateway trace ID (x-trace-id)
```

Swap providers without changing the model class — Ferro auto-routes by model
name:

```python
claude = FerroChatModel(model="claude-3-5-sonnet-20241022", base_url="...", api_key="...")
gemini = FerroChatModel(model="gemini-1.5-flash", base_url="...", api_key="...")
```

### Streaming

```python
for chunk in llm.stream([HumanMessage(content="Tell me a story")]):
print(chunk.content, end="", flush=True)
```

### Tool calling / LangGraph agents

```python
from langchain_core.tools import tool

@tool
def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b

agent_llm = llm.bind_tools([add])
response = agent_llm.invoke([HumanMessage(content="What is 4 + 7?")])
print(response.tool_calls)
```

### Embeddings

```python
from langchain_ferrolabsai import FerroEmbeddings

embed = FerroEmbeddings(model="text-embedding-3-small", base_url="...", api_key="...")
vectors = embed.embed_documents(["hello", "world"])
query_vec = embed.embed_query("hello")
```

### Legacy `LLM` interface

```python
from langchain_ferrolabsai import FerroLLM

llm = FerroLLM(model="gpt-4o", base_url="...", api_key="...")
print(llm.invoke("Write a haiku about gateways"))
```

## Why use this instead of `ChatOpenAI(base_url=...)`?

`ChatOpenAI` pointed at a Ferro Labs gateway already works as a drop-in. This package adds:
`ChatOpenAI` pointed at a Ferro Labs gateway works as a drop-in. This package adds:

- First-class `provider`, `cost_usd`, `latency_ms`, `trace_id` exposure on `response_metadata`.
- Native support for Ferro extras: `route_tag`, `template_id`, `template_variables`.
- Streaming + async + tool calling that surfaces all 30+ providers transparently.
- Optional LangSmith bridge so non-OpenAI providers (Anthropic, Bedrock, Vertex, etc.) appear in your existing LangSmith dashboards.
- `trace_id` is the **join key** for the v1.2 observability bridge plugins
(LangSmith, Langfuse, Phoenix, Datadog, …) shipping from the
[`ferro-labs/ai-gateway-plugins`](https://github.com/ferro-labs) repo —
any provider's calls become visible in your existing LLMOps backend without
per-provider wiring.

## Status & roadmap

`0.1.0` is the **first functional release** of the adapter. See
[`CHANGELOG.md`](CHANGELOG.md) for what shipped and what's planned. Async
surfaces and `with_structured_output()` are the next two items.

## Related

- [`ferrolabsai`](https://pypi.org/project/ferrolabsai/) — the core Python SDK this package wraps.
- [Ferro Labs AI Gateway](https://github.com/ferro-labs/ai-gateway) — the open-source gateway server.
- [Ferro Labs AI Gateway](https://github.com/ferro-labs/ai-gateway) — the open-source gateway server (v1.1.0+ OTel-native).
- [`ai-gateway-cookbook`](https://github.com/ferro-labs/ai-gateway-cookbook) — runnable recipes (start with `python/02-langgraph-multi-provider-agent`).
- [Documentation](https://docs.ferrolabs.ai)

## License
Expand Down
Original file line number Diff line number Diff line change
@@ -1,31 +1,30 @@
"""LangChain integration for Ferro Labs AI Gateway.

This is a 0.0.1 placeholder release. The full adapter (FerroChatModel,
FerroEmbeddings, FerroLLM) is under active development. Track progress at:
Public API::

https://github.com/ferro-labs/ai-gateway-workspace/blob/main/docs/OSS-ECOSYSTEM-ROADMAP.md
from langchain_ferrolabsai import FerroChatModel, FerroEmbeddings, FerroLLM

Once shipped, the public API will be:
chat = FerroChatModel(model="gpt-4o", api_key="sk-ferro-...")
embed = FerroEmbeddings(model="text-embedding-3-small", api_key="sk-ferro-...")
legacy = FerroLLM(model="gpt-4o", api_key="sk-ferro-...")

from langchain_ferrolabsai import FerroChatModel, FerroEmbeddings, FerroLLM
All three classes route through a Ferro Labs AI Gateway endpoint and expose
the gateway's ``trace_id`` (frozen contract since ``ai-gateway v1.1.0``) via
``response_metadata`` — the join key for the v1.2 observability bridge plugins
(LangSmith, Langfuse, Phoenix, …).
"""

from __future__ import annotations

__version__ = "0.0.1"

__all__ = ["__version__"]


def _not_implemented(name: str) -> None:
raise NotImplementedError(
f"langchain_ferrolabsai.{name} is not implemented in 0.0.1. "
"This release reserves the package name. "
"Track progress at https://github.com/ferro-labs/ai-gateway-workspace."
)
from .chat_models import FerroChatModel
from .embeddings import FerroEmbeddings
from .llms import FerroLLM

__version__ = "0.1.0"

def __getattr__(name: str) -> object:
if name in {"FerroChatModel", "FerroEmbeddings", "FerroLLM"}:
_not_implemented(name)
raise AttributeError(f"module 'langchain_ferrolabsai' has no attribute {name!r}")
__all__ = [
"__version__",
"FerroChatModel",
"FerroEmbeddings",
"FerroLLM",
]
Loading
Loading