This document describes the internal architecture of the ferrolabsai SDK, how the pieces fit together, and the design decisions behind them. It is written against ai-gateway v1.4.5; the contract suite (tests/contract/) keeps it honest.
The SDK acts as a thin HTTP client that sits between application code and a running Ferro Labs AI Gateway instance. It provides an OpenAI-compatible surface so users can switch from openai.OpenAI to ferrolabsai.FerroClient with minimal code changes, while gaining access to 30 LLM providers, smart routing, and gateway management APIs.
┌─────────────────────────────────────────────────────────────┐
│ Application Code │
│ │
│ client.chat.completions.create(model="gpt-4o", ...) │
│ client.embeddings.create(model="text-embedding-3-small") │
│ client.admin.config.update({...}) │
└──────────────────────────┬──────────────────────────────────┘
│
┌────────────▼────────────────┐
│ ferrolabsai SDK │
│ │
│ FerroClient / AsyncFerro │
│ ├── chat.completions │
│ ├── embeddings │
│ ├── images │
│ ├── models │
│ ├── responses │
│ ├── moderations │
│ ├── rerank() / probes │
│ └── admin │
│ ├── keys │
│ ├── config │
│ ├── logs │
│ ├── providers │
│ ├── plugins │
│ └── audit │
└────────────┬────────────────┘
│ HTTP (httpx)
┌────────────▼────────────────┐
│ Ferro Labs AI Gateway │
│ /v1/* /admin/* /health │
│ │
│ Routing · Fallback · Cache │
│ Budgets · Logging · OTel │
└────────────┬────────────────┘
│
┌───────────────┼───────────────┐
│ │ │
┌────▼────┐ ┌────▼─────┐ ┌────▼────┐
│ OpenAI │ │Anthropic │ │ Groq │ ... 30 providers
└─────────┘ └──────────┘ └─────────┘
ferrolabsai/
├── __init__.py # Public API surface & __all__
├── _version.py # __version__ constant (kept in sync with pyproject.toml)
├── client.py # FerroClient, AsyncFerroClient, retry policy, _raise_api_error
├── streaming.py # Stream / AsyncStream SSE wrappers
├── types.py # Dataclass response models
├── types_responses.py # Response (Responses API), re-exported from types
├── exceptions/__init__.py # FerroError hierarchy
├── completions/ # chat.completions (resource.py + async_resource.py)
├── embeddings/ # embeddings
├── images/ # images.generate
├── models/ # model catalog (client-side lookup/filter)
├── responses/ # Responses API
├── moderations/ # moderations
└── admin/ # Admin, _KeysResource, _ConfigResource, _LogsResource,
# _ProvidersResource, _PluginsResource, _AuditResource
Every resource sub-package has a sync resource.py and an async_resource.py; the async module imports the request-body builders and path constants from the sync one so the wire format is defined once.
All response models are standard @dataclass classes with a from_dict() classmethod factory. This keeps the dependency tree minimal (only httpx) and avoids version conflicts with pydantic v1 vs v2 in user projects.
@dataclass
class ChatCompletion:
id: str
model: str
choices: list[Choice]
usage: Usage | None = None
# Gateway extensions
trace_id: str | None = None # X-Request-ID header
provider: str | None = None # body `provider` / X-Gateway-Provider header
gateway_overhead_ms: float | None = None # X-Gateway-Overhead-Ms header
provider_metadata: dict[str, Any] | None = NoneEach API surface is a resource class that receives the client via __init__(self, client: FerroClient) (typed under TYPE_CHECKING to avoid an import cycle), calls self._client._request(method, path, ...) for HTTP, and returns typed dataclasses (or the raw dict where the gateway's shape is loosely structured — admin lists, rerank, moderations).
FerroClient
├── _http: httpx.Client # connection pool + auth headers
├── _request(method, path, ...) # central HTTP with retry + error mapping
├── _open_stream(path, body) # SSE: no retry, returns the live response
├── health() / ready() / live() # → /health, /readyz, /livez
├── capabilities() # → /v1/capabilities
├── rerank(...) # → /v1/rerank
│
├── chat.completions: Completions # → /v1/chat/completions
├── embeddings: Embeddings # → /v1/embeddings
├── images: Images # → /v1/images/generations
├── models: Models # → /v1/models (client-side filter/lookup)
├── responses: Responses # → /v1/responses[/{id}]
├── moderations: Moderations # → /v1/moderations
└── admin: Admin # → /admin/*
├── keys / config / logs / providers / plugins / audit
All non-streaming traffic flows through _request() (sync) or AsyncFerroClient._request(). It centralizes:
- Authentication —
Authorization: Bearer {api_key}header on thehttpxclient. - Retry policy — see below.
- Error mapping —
_raise_api_error()translates the gateway's error envelope into typed exceptions. - Response parsing — JSON, 204 handling, and header metadata injection for inference paths only (
/v1/chat/completions,/v1/completions,/v1/embeddings,/v1/images/generations,/v1/responses,/v1/rerank,/v1/moderations). Catalog, probe, and admin bodies are returned untouched. - Probe semantics —
/healthand/readyzanswer503with a JSON body when degraded;_request(..., allow=(503,))returns that body instead of raising.
Shared by sync and async; streaming requests are never retried.
| Trigger | Retried? |
|---|---|
HTTP 429 |
yes, every method (the gateway did not process the request) |
httpx.ConnectError, httpx.ConnectTimeout |
yes, every method (the request never left) |
HTTP 408, 5xx |
idempotent methods only (GET, HEAD, PUT, DELETE, OPTIONS) |
other httpx.TimeoutException (read / write / pool) |
idempotent methods only — a POST may already have been processed |
any other 4xx |
no — raised immediately |
streaming (_open_stream) |
never; transport errors map to FerroConnectionError |
The decision lives in _should_retry(method, status=…) / _should_retry(method, exc=…), shared by both loops.
Delay before retry n: Retry-After seconds when present (capped at 30 s — the same cap the gateway applies to its own upstream retries), else uniform(0, min(0.5 · 2^(n-1), 8)) (full jitter). max_retries defaults to 2 and is validated at construction.
FerroError (base)
├── FerroAPIError (any non-2xx HTTP response; .status_code .code .message .request_id)
│ ├── FerroAuthError (401)
│ ├── FerroBudgetExceededError (402 insufficient_quota)
│ ├── FerroPermissionError (403 insufficient_scope)
│ ├── FerroNotFoundError (404 model_not_found / not_found — also raised locally by models.retrieve)
│ ├── FerroRateLimitError (429; .retry_after from the Retry-After header)
│ └── FerroServerError (5xx)
├── FerroConnectionError (network / timeout after all retries)
└── FerroStreamError (malformed SSE frame, or a gateway error frame; .code)
.request_id is the X-Request-ID of the failed response (falls back to request_id / trace_id in the body).
chat.completions.create(stream=True) returns a Stream (async: AsyncStream) rather than a bare generator so the HTTP response — and therefore its headers — stays reachable:
stream.trace_id(X-Request-ID) andstream.provider(X-Gateway-Provider, not set on SSE as of v1.4.5) are available before the first chunk and copied onto everyChatCompletionChunk.- Frames are parsed line-wise:
data: {...}→ChatCompletionChunk;data: [DONE]ends the stream;{"error": {...}}→FerroStreamError(code=...); anything unparsable →FerroStreamError("Malformed SSE chunk ..."). usageappears on the terminal chunk. The gateway always asks the upstream for usage (for metering) and forwards that chunk unless the client sentstream_options={"include_usage": False}.- The response is closed when the stream is exhausted, on error, on
close(), or when thewithblock exits.
FerroClient (httpx.Client) and AsyncFerroClient (httpx.AsyncClient) expose the same namespaces; every resource has an async twin. The async client's create(stream=True) is awaited once (opening the stream, so HTTP errors surface there) and returns an AsyncStream.
| OpenAI | ferrolabsai |
|---|---|
from openai import OpenAI |
from ferrolabsai import FerroClient |
client.chat.completions.create |
client.chat.completions.create |
client.embeddings.create |
client.embeddings.create |
client.images.generate |
client.images.generate |
client.models.list / retrieve |
client.models.list / retrieve (client-side) |
client.responses.create |
client.responses.create |
OPENAI_API_KEY |
FERRO_API_KEY (falls back to OPENAI_API_KEY) |
The _ChatNamespace class exists solely to provide the client.chat.completions accessor, matching OpenAI's nested layout.
1. User calls: client.chat.completions.create(model="gpt-4o", messages=[...])
│
2. Completions.create()
├── build_body(): model, messages, stream + non-None optionals + **kwargs
├── Non-streaming: self._client._request("POST", "/v1/chat/completions", json=body)
└── Streaming: Stream(self._client._open_stream(path, body))
│
3. FerroClient._request()
├── Sends via self._http (httpx.Client)
├── 2xx: parse JSON; inference path → merge X-Request-ID / X-Gateway-Provider /
│ X-Gateway-Overhead-Ms into the dict (body fields win)
├── 408/429/5xx or connect/timeout: sleep (Retry-After or jittered backoff), retry
└── Other non-2xx or retries exhausted: _raise_api_error() → typed FerroXxxError
│
4. Completions.create() → ChatCompletion.from_dict(data)
│
5. User receives ChatCompletion with:
├── .content → shortcut to first choice
├── .provider → which backend answered (body `provider`)
├── .trace_id → X-Request-ID; joins with admin.logs / OTel
├── .gateway_overhead_ms → gateway's own processing time
├── .provider_metadata → provider-specific extras
└── .usage → tokens (+ reasoning / cache counters when reported)
| Header | Set by the gateway on | SDK field |
|---|---|---|
X-Request-ID |
every response (32 lowercase hex, equals the OTel trace id) | trace_id, Stream.trace_id, chunk trace_id, FerroAPIError.request_id |
X-Gateway-Provider |
/v1/responses, /v1/* pass-through, legacy /v1/completions — not non-streaming chat (body provider instead), not SSE |
provider (only when the body has none) |
X-Gateway-Overhead-Ms |
non-streaming /v1/chat/completions, when > 0 |
gateway_overhead_ms |
Retry-After |
every gateway-originated 429 ("1"), upstream 429 (upstream value) |
retry delay; FerroRateLimitError.retry_after |
No Ferro-branded, cost, latency, or cache-hit response header exists at any gateway version; the SDK reads none.
| Field | Where | SDK field |
|---|---|---|
provider, provider_metadata |
non-streaming chat completion | ChatCompletion.provider / .provider_metadata |
usage.reasoning_tokens, cache_read_tokens, cache_write_tokens |
chat usage (omitted when zero) | Usage.* |
message.reasoning_content, delta.reasoning_content |
chat message / stream delta | ChatMessage.reasoning_content, StreamDelta.reasoning_content |
{"error": {message, type, code}} |
every non-2xx and mid-stream error frame | exception .message / .code |
GET /v1/models returns EnrichedModelInfo (ai-gateway/internal/handler/models.go): id, object, created, owned_by, plus mode, context_window, max_output_tokens, capabilities[], status, deprecated when the catalog knows the model. Query parameters are ignored and GET /v1/models/{id} is not a native route (it would fall through to the /v1/* pass-through with the operator's credential), so list(provider=, capability=), search(), and retrieve() all work client-side over one fetch.
The admin namespace maps 1:1 to the gateway's /admin/* routes (ai-gateway/internal/admin/handlers/server.go, Handlers.Routes).
| SDK Method | HTTP Route | Scope |
|---|---|---|
admin.dashboard() |
GET /admin/dashboard |
read_only |
admin.health() |
GET /admin/health |
read_only |
admin.keys.list() |
GET /admin/keys |
read_only |
admin.keys.retrieve(id) |
GET /admin/keys/{id} |
read_only |
admin.keys.create(name=...) |
POST /admin/keys |
admin |
admin.keys.update(id, ...) |
PUT /admin/keys/{id} |
admin |
admin.keys.delete(id) |
DELETE /admin/keys/{id} |
admin |
admin.keys.revoke(id) |
POST /admin/keys/{id}/revoke |
admin |
admin.keys.rotate(id) |
POST /admin/keys/{id}/rotate |
admin |
admin.keys.usage(limit=...) |
GET /admin/keys/usage |
read_only |
admin.config.get() |
GET /admin/config |
read_only |
admin.config.create(config) |
POST /admin/config |
admin |
admin.config.update(config) |
PUT /admin/config |
admin |
admin.config.delete() |
DELETE /admin/config |
admin |
admin.config.history() |
GET /admin/config/history |
read_only |
admin.config.rollback(version) |
POST /admin/config/rollback/{v} |
admin |
admin.logs.list(stage=, api_key_id=, ...) |
GET /admin/logs |
read_only |
admin.logs.stats(buckets=) |
GET /admin/logs/stats |
read_only |
admin.logs.delete(before=...) |
DELETE /admin/logs |
admin |
admin.providers.list() |
GET /admin/providers |
read_only |
admin.providers.catalog() |
GET /admin/providers/catalog |
read_only |
admin.plugins.list() |
GET /admin/plugins |
read_only |
admin.plugins.catalog() |
GET /admin/plugins/catalog |
read_only |
admin.audit.list(action=, actor_id=, outcome=, since=) |
GET /admin/audit |
read_only |
Not wrapped on purpose: /admin/session(s) (dashboard-only), /metrics, /debug/vars. Gateway rules worth knowing: the last admin key record cannot be revoked or deleted (409), read_only writes get 403 insufficient_scope, and GET /admin/config masks secrets so it does not round-trip into PUT.
HTTP response received
│
├── 2xx → parse JSON → return dict / dataclass
├── 503 on /health, /readyz → return the JSON body (degraded is an answer)
│
├── 408 / 429 / 5xx → retry (Retry-After or jittered backoff) … then map as below
│
├── 401 → FerroAuthError
├── 402 → FerroBudgetExceededError
├── 403 → FerroPermissionError
├── 404 → FerroNotFoundError
├── 429 → FerroRateLimitError(retry_after=...)
├── 5xx → FerroServerError
├── other 4xx (400, 405, 409, 413, 501, …) → FerroAPIError(code=...)
│
├── ConnectError / TimeoutException → retry up to max_retries → FerroConnectionError
└── SSE: HTTP error before the first byte → same mapping; error frame → FerroStreamError
FerroClient(
api_key="...", # or FERRO_API_KEY / OPENAI_API_KEY env var
base_url="...", # or FERRO_BASE_URL env var (default: http://localhost:8080)
timeout=120.0, # httpx timeout in seconds
max_retries=2, # retries for connect/timeout/408/429/5xx (default: 2)
default_headers={...}, # merged into every request
http_client=my_httpx, # bring your own httpx.Client
)Auth resolution order: api_key parameter → FERRO_API_KEY → OPENAI_API_KEY (migration fallback). If none is found, FerroAuthError is raised at construction time.
ferrolabsai (this SDK)
└── httpx ≥ 0.24.0 # the ONLY runtime dependency
Dev only:
├── pytest ≥ 7.0
├── pytest-asyncio ≥ 0.21
├── pytest-httpx ≥ 0.22
├── mypy ≥ 1.0
└── ruff ≥ 0.1.0
- Unit (
tests/test_client.py,test_chat.py,test_resources.py,test_admin.py): HTTP fully mocked viapytest-httpx; covers construction, auth resolution, error mapping, the retry policy, header metadata, streaming (happy path, usage chunk, error frames), every resource and admin route, sync and async. - Contract (
tests/contract/): runs only whenFERRO_CONTRACT_BASE_URLis set.scripts/with-gateway.shbuildsferrogwfrom an ai-gateway checkout, startstests/contract/stub_upstream.pyas a fake OpenAI, points the gateway at it, and asserts the header/body/error contract described above against the real server. CI runs it against the pinned gateway tag (required) andmain(advisory). - Async tests use
pytest-asynciowithasyncio_mode = "auto".