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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## [v0.2.0] - 2026-07-02

### Added

- Added a `request_timeout_seconds` constructor parameter to `PipelexAPIClient`, setting a per-instance blocking-execute ceiling for the inherited protocol routes (`execute`, `start`, `validate`, `models`, `version`).

### Changed

- **BREAKING:** Renamed `PipelexAPIClient` constructor parameters and attributes to match the `mthds` base client and the `@pipelex/sdk` JavaScript counterpart: `api_token` → `api_key` and `api_base_url` → `base_url`. *(Migration: update all instantiations and property reads to the new names.)*
- **BREAKING:** Renamed API URL environment variables for workspace-wide consistency: `PIPELEX_API_URL` → `PIPELEX_BASE_URL` and the `mthds` fallback `MTHDS_API_URL` → `MTHDS_BASE_URL`. No read aliases are kept for the old names.
- Bumped the `mthds` dependency from `>=0.6.1` to `>=0.7.0`, and updated config loading to import `load_config` from `mthds.config` (previously `load_credentials` from `mthds.config.credentials`).
- Updated documentation (`README.md`, `CLAUDE.md`, `docs/architecture.md`) and unit tests to reflect the new client signature, environment variables, and `mthds` configuration.

## [v0.1.1] - 2026-07-01

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ It is the **hosted superset**: the five normative MTHDS Protocol routes (inherit
- **One-way dependency: `pipelex-sdk → mthds`.** This package depends on `mthds` and never the reverse.
- **Inheritance, not re-implementation.** `class PipelexAPIClient(MthdsAPIClient)`. Reuse the base transport (`_send`, `_url`), body-builders, the reusable protocol methods, `runner_type`, and the async context-manager. Add lifecycle/product/health on top. The base's single-underscore transport methods are treated as a documented **protected extension surface** — do not rename or fork them.
- **Brand boundary (MTHDS vs Pipelex).** MTHDS = the open standard's brand; Pipelex = the runtime/product brand. Protocol routes and their models belong to `mthds` and keep neutral names; Pipelex-specific surfaces (lifecycle, product routes, implementation envelopes) live here. Name by which brand owns the concept.
- **Credentials.** Resolve `PIPELEX_API_KEY` / `PIPELEX_API_URL` first, then fall back to the `mthds` resolver (`MTHDS_API_KEY` / `MTHDS_API_URL`, `~/.mthds/config`). Token is **optional** (anonymous allowed). Default base URL `https://api.pipelex.com`.
- **Credentials.** Resolve `PIPELEX_API_KEY` / `PIPELEX_BASE_URL` first, then fall back to the `mthds` resolver (`MTHDS_API_KEY` / `MTHDS_BASE_URL`, `~/.mthds/config`). Token is **optional** (anonymous allowed). Default base URL `https://api.pipelex.com`.
- **Async-only.** httpx `AsyncClient`, `async def` throughout. No sync facade in v0.1.
- **No barrel.** `__init__.py` files stay empty — no re-exports, no docstrings. Import via full paths (`from pipelex_sdk.client import PipelexAPIClient`).

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ pip install pipelex-sdk

## Configuration

Credentials resolve, in order: explicit constructor arguments → `PIPELEX_API_KEY` / `PIPELEX_API_URL` → `MTHDS_API_KEY` / `MTHDS_API_URL` (and `~/.mthds/config`) → defaults. The token is **optional** — anonymous access works against the protocol routes (e.g. a local bare runner); the product routes return `401`. The default base URL is `https://api.pipelex.com`. The base URL is host-only (no path/query/fragment); every endpoint composes as `{base}/v1/{endpoint}`.
Credentials resolve, in order: explicit constructor arguments → `PIPELEX_API_KEY` / `PIPELEX_BASE_URL` → `MTHDS_API_KEY` / `MTHDS_BASE_URL` (and `~/.mthds/config`) → defaults. The token is **optional** — anonymous access works against the protocol routes (e.g. a local bare runner); the product routes return `401`. The default base URL is `https://api.pipelex.com`. The base URL is host-only (no path/query/fragment); every endpoint composes as `{base}/v1/{endpoint}`.

The client is **async-only** (httpx `AsyncClient`) and is an async context manager.

Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ The Pipelex narrowing of the `/v1/validate` verdict union is one such implementa

Resolved at construction time:

- `PIPELEX_API_KEY` / `PIPELEX_API_URL` first (brand + JS parity);
- falling back to the `mthds` resolver (`MTHDS_API_KEY` / `MTHDS_API_URL`, `~/.mthds/config`) as a secondary source.
- `PIPELEX_API_KEY` / `PIPELEX_BASE_URL` first (brand + JS parity);
- falling back to the `mthds` resolver (`MTHDS_API_KEY` / `MTHDS_BASE_URL`, `~/.mthds/config`) as a secondary source.

A token is **optional** (anonymous access is allowed; protocol routes work against anonymous bare runners, product routes return `401`). The default base URL is `https://api.pipelex.com`. The base URL is validated host-only (no path/query/fragment/embedded credentials; http/https only).

Expand Down
70 changes: 40 additions & 30 deletions pipelex_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from urllib.parse import quote, urlparse

import httpx
from mthds.config.credentials import load_credentials
from mthds.config import load_config
from mthds.protocol.exceptions import PipelineRequestError
from mthds.runners.api.client import MthdsAPIClient
from pydantic import BaseModel, TypeAdapter, ValidationError
Expand Down Expand Up @@ -89,7 +89,7 @@
from pipelex_sdk.runs import RunResultState
from pipelex_sdk.validation_models import PipelexValidationResult

# The client composes every endpoint from one origin (PIPELEX_API_URL): `{base}/v1/{endpoint}`.
# The client composes every endpoint from one origin (PIPELEX_BASE_URL): `{base}/v1/{endpoint}`.
# The same paths are served by the Pipelex Hosted API (api.pipelex.com) and by a bare
# OSS pipelex-api runner (localhost:8081) — the protocol surface is identical; only the
# hosted extensions (e.g. run polling) differ, detectable via GET /v1/version.
Expand All @@ -109,7 +109,7 @@
_GATEWAY_TIMEOUT_THRESHOLD_SECONDS = 28.0

_PIPELEX_API_KEY_ENV = "PIPELEX_API_KEY"
_PIPELEX_API_URL_ENV = "PIPELEX_API_URL"
_PIPELEX_BASE_URL_ENV = "PIPELEX_BASE_URL"

# `VersionInfo.implementation` of the bare open-source runner (no run store). Anything
# else — the hosted implementation first — is assumed to serve the durable run-lifecycle
Expand Down Expand Up @@ -139,7 +139,7 @@ class MthdsFile(BaseModel):
class PipelexAPIClient(MthdsAPIClient):
"""Client for the Pipelex hosted API — and any MTHDS-compliant runner.

One base URL (`PIPELEX_API_URL`); every endpoint is `<base>/v1/<endpoint>`:
One base URL (`PIPELEX_BASE_URL`); every endpoint is `<base>/v1/<endpoint>`:
- **protocol** (`execute` / `start` / `validate` / `models` / `version`) — inherited
from `MthdsAPIClient`; works against any MTHDS-compliant runner, hosted or bare.
- **run lifecycle** (`get_run_status` / `get_run_result` / `wait_for_result`) — the
Expand All @@ -149,30 +149,36 @@ class PipelexAPIClient(MthdsAPIClient):
on the structured `ApiResponseError.code`, not the HTTP status.

Construction resolves credentials Pipelex-first (`PIPELEX_API_KEY` /
`PIPELEX_API_URL`), falling back to the `mthds` resolver (`MTHDS_API_KEY` /
`MTHDS_API_URL`, `~/.mthds/config`). The token is optional — anonymous access works
`PIPELEX_BASE_URL`), falling back to the `mthds` resolver (`MTHDS_API_KEY` /
`MTHDS_BASE_URL`, `~/.mthds/config`). The token is optional — anonymous access works
against the protocol routes; product routes return `401`. The base URL is validated
host-only (no path/query/fragment/credentials; http/https only).
"""

def __init__(self, api_token: str | None = None, api_base_url: str | None = None) -> None:
credentials = load_credentials()
def __init__(self, api_key: str | None = None, base_url: str | None = None) -> None:
Comment thread
lchoquel marked this conversation as resolved.
Comment thread
lchoquel marked this conversation as resolved.
config = load_config()

# Pipelex-primary, mthds fallback. `credentials` already layers env (MTHDS_*) >
# Pipelex-primary, mthds fallback. `config` already layers env (MTHDS_*) >
# file (~/.mthds/config) > default, so this ladder gives the full precedence:
# explicit arg > PIPELEX_* env > MTHDS_* env > file > default. The token is optional
# explicit arg > PIPELEX_* env > MTHDS_* env > file > default. The key is optional
# and an empty string ("") means anonymous — so the first layer that is *present*
# wins even when it is empty. We test `is not None` (not truthiness) to honor an
# explicit `api_token=""` / `PIPELEX_API_KEY=""`, matching the JS SDK's `??` chain.
self.api_token: str
if api_token is not None:
self.api_token = api_token
elif (pipelex_env_token := os.environ.get(_PIPELEX_API_KEY_ENV)) is not None:
self.api_token = pipelex_env_token
# wins even when it is empty.
#
# DO NOT collapse this into `api_key or os.environ.get(...) or config["api_key"]`.
# `or` treats "" as falsy and would fall through, silently discarding an explicit
# anonymous request (`api_key=""` / `PIPELEX_API_KEY=""`) and reaching for the next
# configured key instead. We test `is not None` (presence, not truthiness) precisely
# to honor the empty string, matching the JS SDK's `??` chain. (The `base_url` line
# below correctly uses `or`: there an empty value has no special meaning.)
self.api_key: str
if api_key is not None:
self.api_key = api_key
elif (pipelex_env_key := os.environ.get(_PIPELEX_API_KEY_ENV)) is not None:
self.api_key = pipelex_env_key
else:
self.api_token = credentials["api_key"]
self.api_key = config["api_key"]

resolved_base_url = api_base_url or os.environ.get(_PIPELEX_API_URL_ENV) or credentials["api_url"] or DEFAULT_API_BASE_URL
resolved_base_url = base_url or os.environ.get(_PIPELEX_BASE_URL_ENV) or config["base_url"] or DEFAULT_API_BASE_URL
Comment thread
lchoquel marked this conversation as resolved.
normalized_base_url = resolved_base_url.rstrip("/")
# The base URL must be host-only: a path-prefixed value (e.g. `.../v1`) would
# compose as `/v1/v1/...` and fail with a misleading endpoint error instead of a
Expand All @@ -185,19 +191,23 @@ def __init__(self, api_token: str | None = None, api_base_url: str | None = None
"Endpoints compose as {base}/v1/{endpoint}."
)
raise PipelineRequestError(msg)
self.api_base_url: str = normalized_base_url
self.base_url: str = normalized_base_url
#: Origin root derived from the base URL — `/health` lives here, not under `/v1`.
self.origin_url: str = _origin_of(normalized_base_url)
#: Per-request timeout the inherited protocol routes (`execute` / `start` / `validate`
#: / `models` / `version`) read — the blocking-execute ceiling. The SDK's own poll and
#: product GETs pass `_POLL_REQUEST_TIMEOUT_SECONDS` explicitly instead.
self.request_timeout_seconds: float = _DEFAULT_REQUEST_TIMEOUT_SECONDS
self.client: httpx.AsyncClient | None = None
#: Cached `/v1/version` handshake outcome — whether the durable lifecycle is served.
self._lifecycle_available: bool | None = None

@override
def start_client(self) -> PipelexAPIClient:
"""Initialize the HTTP client. The Authorization header is sent only when a token
is configured — anonymous access (empty token) omits it, matching the JS SDK.
"""Initialize the HTTP client. The Authorization header is sent only when a key
is configured — anonymous access (empty key) omits it, matching the JS SDK.
"""
headers = {"Authorization": f"Bearer {self.api_token}"} if self.api_token else {}
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
self.client = httpx.AsyncClient(headers=headers)
return self

Expand All @@ -211,12 +221,12 @@ async def _send_or_unreachable(self, method: str, url: str, *, content: bytes |
try:
return await self._send(method, url, content=content, request_timeout=request_timeout)
except httpx.TimeoutException as exc:
msg = f"Could not reach Pipelex API at {self.api_base_url} (timeout)"
raise ApiUnreachableError(msg, api_url=self.api_base_url, code="ABORT_TIMEOUT") from exc
msg = f"Could not reach Pipelex API at {self.base_url} (timeout)"
raise ApiUnreachableError(msg, api_url=self.base_url, code="ABORT_TIMEOUT") from exc
except httpx.TransportError as exc:
code = type(exc).__name__
msg = f"Could not reach Pipelex API at {self.api_base_url} ({code})"
raise ApiUnreachableError(msg, api_url=self.api_base_url, code=code) from exc
msg = f"Could not reach Pipelex API at {self.base_url} ({code})"
raise ApiUnreachableError(msg, api_url=self.base_url, code=code) from exc

async def _request_product(self, method: str, endpoint: str, *, body: object | None = None) -> Any:
"""Issue a Pipelex-product request (`/v1/me`, `/v1/methods`, `/v1/billing/*`, …)
Expand Down Expand Up @@ -256,7 +266,7 @@ def _raise_api_response_error(self, *, method: str, endpoint: str, response: htt
msg = f"API {method} /{_API_PREFIX}/{endpoint} failed ({response.status_code}): {detail}"
raise ApiResponseError(
msg,
api_url=self.api_base_url,
api_url=self.base_url,
status=response.status_code,
status_text=response.reason_phrase,
response_body=body_text,
Expand All @@ -277,9 +287,9 @@ def _raise_if_lifecycle_unavailable(self, response: httpx.Response, url: str) ->
msg = (
f"The durable run lifecycle is not available: {url} returned 404. Run polling is a "
f"hosted-API extension (/{_API_PREFIX}/{_RUNS}/*), not part of the MTHDS Protocol; "
"PIPELEX_API_URL points at a bare runner that does not serve it."
"PIPELEX_BASE_URL points at a bare runner that does not serve it."
)
raise RunLifecycleUnavailableError(msg, api_url=self.api_base_url)
raise RunLifecycleUnavailableError(msg, api_url=self.base_url)

# ── Protocol surface: `execute` override (gateway-timeout translation) ──

Expand Down
2 changes: 1 addition & 1 deletion pipelex_sdk/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ def __init__(self, message: str, run_id: str, timeout_seconds: float) -> None:

class RunLifecycleUnavailableError(PipelineRequestError):
"""Raised when the durable run lifecycle (`/v1/runs/*`) is not served by the
configured `PIPELEX_API_URL`.
configured `PIPELEX_BASE_URL`.

Run polling is a hosted-API extension, not part of the MTHDS Protocol: the
open-source `pipelex-api` runner executes methods but has no run store, so it
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "pipelex-sdk"
version = "0.1.1"
version = "0.2.0"
description = "The Python client for the Pipelex hosted API — the MTHDS Protocol surface plus the durable run lifecycle and the Pipelex product surface, built on the `mthds` protocol base."
authors = [{ name = "Evotis S.A.S.", email = "oss@pipelex.com" }]
maintainers = [{ name = "Pipelex staff", email = "oss@pipelex.com" }]
Expand All @@ -19,7 +19,7 @@ classifiers = [
]

dependencies = [
"mthds>=0.6.1",
"mthds>=0.7.0",
"pydantic>=2.10.6,<3.0.0",
"backports.strenum>=1.3.0 ; python_version < '3.11'",
"typing-extensions>=4.0.0",
Expand Down
Loading
Loading