Changelog#

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Unreleased#

Added#

  • AGENTS.md — a tool-neutral contributor/agent guide (package layout, CI gates, release process). Read natively by agent tools that support AGENTS.md, and by Claude Code via a thin CLAUDE.md that imports it (@AGENTS.md). Per-machine notes stay in a gitignored CLAUDE.local.md; .gitignore now allows a shared .claude/settings.json while keeping personal .claude/settings.local.json and .claude/worktrees/ out of git.

0.9.0 — 2026-07-09#

Added#

  • Native async transport behind a new [async] extra (pip install saia-python[async], pulls httpx). New saia_python.aio module: AsyncSAIAClient + AsyncChatService / AsyncArcanaService / AsyncModelsService, the httpx.AsyncClient twins of the sync data plane — chat completions and ARCANA RAG chat (streaming + non-streaming), get_rate_limits, and health_check. Use it as an async context manager; from saia_python import AsyncSAIAClient resolves lazily so importing the package never pulls httpx for sync-only users. See ADR-0007.

    • The retry brains are shared, not copied. aexecute / apost_chat_completion reuse RetryPolicy / _plan / _jitter / resolve_retry / parse_rate_limits verbatim, so the async path honours the same 429 policy (ADR-0006) as the sync path and the two cannot drift. The retry keyword works identically (retry=False fails fast; a RetryPolicy tunes it).

    • AsyncSSEStream owns the httpx client.stream(...) context, retrying an initial 429 before the body is exposed (never mid-stream). Two consumption modes: async for chunk in stream (decoded dicts; raises on an error status) or async for line in stream.aiter_lines() (raw lines, no raise — lets a gateway frame upstream errors itself).

  • Informative 429 errors (format_rate_limit_error, exported). When retry is off or the budget is spent, RateLimitError now carries a human-readable message — which window was hit, when it resets, and how to auto-retry — instead of only the bare server string. Shared by both transports (raised from raise_for_status).

  • Pure request builders (saia_python._payloads, re-exported at top level): build_chat_body, apply_arcana_fields, arcana_chat_headers, and the INFERENCE_SERVICE constant. Transport-free, so the sync services, the async services, and external gateways (the AVOR adapter) share one definition of the three-part ARCANA injection invariant (enable-tools + arcana.id body fields + the inference-service header) — defined and unit-tested once.

Changed#

  • Every SAIAError now carries status_code + response_body (previously only APIError did). Lets a caller reframe the exact upstream response. RateLimitError keeps its rate_limits attribute (unchanged signature).

  • raise_for_status is transport-agnostic — it accepts a requests.Response (honouring .ok) or an httpx.Response (deciding success from status_code), so one implementation serves both paths. No behaviour change for sync callers.

  • The test extra now installs [async] too, so CI exercises the async suite.

Notes#

  • Async covers the data plane + read-only control-plane calls. File upload/index/sync, voice, and document conversion stay sync-only on SAIAClient (batch/admin work, blocking file I/O, no concurrency benefit). See ADR-0007 for the scope rationale.

  • Tests: +42 async tests (test_async_transport / _streaming / _arcana / _chat / _client, test_payloads, test_rate_limit_message); the suite grows 198 → 240 and stays mypy- and ruff-clean.

0.8.0 — 2026-06-28#

Added#

  • ArcanaService.delete_files(name, file_names, *, verbose=False, on_result=None) — batch-delete an explicit list of files from an arcana by name, the counterpart to delete_file. Reuses the shared _run_file_batch executor (per-file error capture, progress bar, on_result, uniform {"file", "status", ["error"]} entries), so one failed/timed-out file no longer aborts the rest. File names are validated as flat (no /) up front and the whole batch is refused atomically if any contains a path separator — guarding against a silent wrong-file delete through the Path-based executor.

  • ArcanaService.recreate(name, *, update_toml=False) — wipe an entire arcana in two API calls (delete + create(append_uuid=False)), recreating it empty with the same owner/name-uuid ID so a downstream pin (e.g. a manifest) stays valid. Raises APIError if creation fails after the delete already landed (the arcana is then gone — the message says so explicitly). The minimal-call alternative to deleting thousands of files one delete_file at a time.

0.7.0 — 2026-06-22#

Added#

  • Tokenizer support for the GWDG open-weight models, behind a new optional [tokenizer] extra (pip install saia-python[tokenizer]: transformers + huggingface-hub + tiktoken + sentencepiece). New saia_python.tokenizer module and client.tokenizers service:

    • GWDG_MODEL_REPOS / resolve_repo() / repo_url() map a GWDG model id (or display name, or a full org/name repo) to its Hugging Face tokenizer repository. The catalogue is required because GET /models does not expose a repository link — its per-model payload is id/name/input/output/status/demand only — so client.tokenizers.available_repos() annotates the live model list from the catalogue (proprietary external models map to None). The registry includes the embedding model qwen3-embedding-4b (the model ARCANA’s RAG pipeline uses internally).

    • download_tokenizer() / download_all_tokenizers() fetch only the tokenizer files (never the weights) into ~/saia_python/tokenizers/ by default — overridable per call or via the SAIA_TOKENIZER_DIR env var — and tolerate per-model failures. load_tokenizer() loads via transformers.AutoTokenizer (process-cached). load_hf_token() resolves a Hugging Face token from HF_TOKEN (and aliases) or a .env / .saia_env file, applied automatically to downloads for higher rate limits and gated repos. A gated repo (e.g. google/medgemma-27b-it) raises an expressive GatedRepoAccessError from download_tokenizer() / load_tokenizer() — carrying the licence URL and setup steps — and is recorded as None (with a verbose=True hint) in a download_all_tokenizers() batch.

    • chat_template_tokens() applies a model’s chat template to a role/content conversation (the system prompt, and optionally the user turn, may be read from a .txt/.md file) and returns a ChatTokenCount with the templated length, the raw-text length, the special/structural-token overhead (absolute, plus relative to the text and as a share of the full prompt), and the subword fertility. It is tolerant: a missing user turn (or any shape a strict template rejects) degrades to a best-effort count with a recorded warning rather than raising. chat_template_length(), special_token_overhead() and subword_fertility(include_special=…) are thin wrappers; the flag selects whether fertility counts the template’s special/structural tokens.

    • token_distribution() walks a directory recursively, tokenizes every text file as raw content and estimates a token cost for each image, and returns a TokenDistribution (per-file FileTokenCount rows plus aggregate stats / histogram) — for sizing a RAG corpus against a model’s tokenizer.

    • count_tiktoken_tokens() covers the externally hosted OpenAI models (GPT-5.x, o3, …), which have no downloadable tokenizer, via tiktoken.

    • The heavy libraries are imported lazily, so importing saia_python never requires the extra; the offline test suite exercises the counting logic against an injected fake tokenizer.

    • Worked example: examples/tokenizer_features.ipynb — a notebook that exercises every public entry point of the feature, with an idempotent install cell for the [tokenizer] extra.

  • examples/arcana_frontmatter_repro.py — self-contained, library-independent reproduction (for the GWDG ARCANA team) showing that the YAML front matter of an uploaded markdown file does not survive retrieval: the References: block returns the chunk body verbatim but strips the metadata header, and no response field carries it. The test header mirrors the documented Docling “Markdown Plus” metadata-header example field for field. Verified 2026-06-10 against ARCANA API 0.4.18 via both the OpenAI SDK and a plain HTTP POST; the stored file itself — see the /download endpoint — still contains the front matter. The raw responses are saved as examples/arcana_frontmatter_response_*.json (committed as frozen evidence), and the companion notebook examples/arcana_frontmatter_repro.ipynb walks the same steps interactively with the full response JSON shown per step.

0.6.0 — 2026-06-02#

Added#

  • Opt-in-by-default rate-limit handling: SAIAClient(retry=...) and the new exported RetryPolicy retry HTTP 429 at the session dispatch seam for idempotent calls (chat, ARCANA chat, documents.convert, voice, and ARCANA control-plane reads). They wait out a server reset within max_waiting_time (default 60 s) and fail fast on longer windows, with a bounded blind fallback (31 s ×2) when no reset header is present. Streaming retries only the opening 429, never mid-stream. Per-call override via retry= (False disables, a RetryPolicy tunes). See ADR-0006 and docs/proposals/rate-limit-handling.md.

Changed#

  • A 429 on an idempotent call is now retried by default instead of immediately raising RateLimitError (amends ADR-0002’s passive default; opt out with SAIAClient(retry=False)). RateLimitError is still raised when retry is disabled, the budget is exhausted, or a long window is exhausted.

  • Internal: unified the per-file batch executor. sync_directory’s apply pass now reuses the same _run_file_batch loop as upload_directory / upload_files / delete_directory instead of a parallel copy; the categorized sync report is derived as a grouped view of the shared per-file outcomes. No public API change. Two verbose=True-only output tweaks: skipped files are now listed during a sync, and per-file failures now include the error message across all batch helpers. See docs/proposals/batch-executor-unification.md.

0.5.1 — 2026-06-02#

Fixed#

  • Document-conversion images are now retrievable. ConversionResult.images documented the base64 payload under key data, but /documents/convert returns it under image, so callers following the docs hit a KeyError. The payload is now decoded at the API boundary.

Changed#

  • ConversionResult.images is now a list of typed ConversionImage (filename, data: bytes, type) instead of raw API dicts; data holds the already-decoded image bytes. (Breaking: switch from dict-key access to attribute access.)

Added#

  • ConversionResult.save_images(directory) and save_all(directory) write the extracted images (and, for save_all, the content) to disk — previously save() only wrote the text content.

  • ConversionImage is exported from the top-level package.

0.5.0 — 2026-06-02#

Fixed#

  • ArcanaService HTTP calls no longer hang forever when the server accepts a request but never sends a response (common while an arcana is locked mid-(re)index). Every ARCANA management request now carries a default (connect, read) timeout of (10, 60) seconds, so a stalled call fails fast with requests.Timeout — which propagates to the caller (batch helpers record it per file and continue) — instead of blocking on the socket read. Previously only heartbeat and generate_index passed a timeout; list, get, create, delete, list_files, delete_file, download_file, upload, and the delete_directory loop could each wedge a batch operation (e.g. wiping a knowledge base before re-ingest) indefinitely.

  • The same no-timeout guard now also covers the other quick control-plane calls on the shared session: ModelsService.list() / list_raw() (GET /models) and the SAIAClient.get_rate_limits() probe (GET /chat/completions), which could otherwise hang the same way.

  • Type-checking: _streaming.iter_sse normalizes iter_lines output to str (also robust to the bytes the requests stub declares), and the list_tool_capable probe body is typed dict[str, Any] — silencing two errors a newer mypy / types-requests flags. No behavior change.

Added#

  • timeout parameter on SAIAClient(...), ArcanaService(...), and ModelsService(...) (default (10, 60); a single float applies to both the connect and read phases, None disables) to tune or opt out of the control-plane request timeouts above. The long-running data-plane paths (chat completions, voice, document conversion) are intentionally left uncapped.

  • on_result per-file callback on upload_files, upload_directory, delete_directory, and sync_directory — invoked as on_result(local_path, entry) as each file is processed, so callers can record per-file provenance (e.g. a git SHA) or a transaction-log entry inline, without reimplementing the upload loop. (For sync_directory, fired for local uploads/replaces/skips, not for prune deletions.)

Changed#

  • generate_index(wait=True) now tolerates a transient transport error (requests.Timeout / ConnectionError) on an individual poll and retries on the next interval instead of aborting a long, still-progressing reindex; only the overall timeout deadline ends the wait. Hardens the new default request timeout against long index waits. On deadline exhaustion the raised TimeoutError now includes the last-seen status and/or the last poll transport error, so a poll that kept timing out is diagnosable.

  • Documented that ARCANA indexing is incremental — generate_index skips files already INDEXED, so “upload only the changed files, then index once” re-embeds just those. sync_directory’s single index pass and the library’s recommended workflow rely on this server behavior.

  • New “Extensions” docs section (between API Reference and Development), opening with an “ARCANA incremental sync” how-to (docs/arcana_incremental_sync.rst): two recipes — sync_directory with a SHA-256 select, and an explicit upload_files + single generate_index — plus the idempotent-upload, transport-drop, and one-reindex-at-a-time operational notes.

0.4.1 — 2026-06-01#

First release published to PyPI as saia-python, together with open-source project tooling and incremental-upload helpers for ARCANA.

Added#

  • ArcanaService.upload_files(name, paths, *, overwrite=True, verbose=False) — upload an explicit, caller-chosen list of files. The selection of what to (re)upload is the caller’s (e.g. from a checksum/manifest comparison); reuses the same per-file batch reporting as upload_directory.

  • ArcanaService.sync_directory(name, directory, *, select, pattern="*", recursive=False, prune=False, index=True, index_wait=True, verbose=False) — sync a local directory under a caller-supplied select(local_path, remote_or_None) -> "upload" | "replace" | "skip" policy, then trigger a single generate_index only when something changed. Keeps change-detection (e.g. SHA-256 vs. your own manifest) outside the package, since the ARCANA API exposes no content hash. Optional prune deletes remote files with no local counterpart.

  • PyPI packaging metadata in pyproject.toml: readme, license-files, keywords, trove classifiers, and an expanded [project.urls] table (Homepage, Documentation, Changelog, Issues) so the project page renders the README and is discoverable.

  • CITATION.cff (Citation File Format 1.2.0) — enables GitHub’s “Cite this repository” button and import into reference managers.

  • saia_python/py.typed — PEP 561 marker so downstream type checkers consume the package’s inline type hints; advertised via the Typing :: Typed classifier.

  • .github/workflows/publish.yml — builds the sdist + wheel, runs twine check --strict, and publishes to PyPI via OIDC Trusted Publishing when a GitHub Release is published (no stored API token).

  • README status badges (PyPI, Python versions, license, CI, docs) and a commented Zenodo DOI badge placeholder.

  • Linting and type-checking: ruff (lint + format) and mypy configuration in pyproject.toml, a lint optional-dependency group, a .pre-commit-config.yaml, and a Quality CI workflow (ruff check + ruff format --check + mypy).

  • Coverage reporting via pytest-cov; the Tests workflow now runs pytest --cov.

Changed#

  • ArcanaService.list_files() now documents the full API FileOutSchema (name, size, owner_user_name, created_at, updated_at, index_info with per-file index_status / chunks_indexed, and related_files).

  • Corrected the license field from the deprecated SPDX identifier AGPL-3.0 to AGPL-3.0-only. The license terms are unchanged; only the SPDX expression is now valid for PEP 639 / PyPI. Bumped the build requirement to setuptools>=77 (PEP 639 support).

  • Applied ruff autofixes and the formatter across the package and tests (Optional[X]X | None, import sorting, consistent formatting) plus minor mypy type-annotation fixes. No behavior change — all 92 tests pass. Known typing gaps (tomlkit’s Item | Container in auth.py; the .list method shadowing the builtin in models.py / arcana.py) are scoped via documented per-module mypy overrides.

0.4.0 — 2026-05-30#

Added#

  • examples/openai_compatible_proxy.ipynb — a documented notebook that builds a small OpenAI-compatible proxy in front of SAIA from the library’s building blocks (models.list_raw(), arcana_references, the native client), turning GWDG ARCANA’s verbose References: dump into clean, numbered citations for any OpenAI-SDK client. Example only — not shipped in the package.

  • ArcanaService.version() and ArcanaService.heartbeat() — ARCANA service-health checks now live on the service that owns the ARCANA URL path and auth scheme (client.arcana.version() / client.arcana.heartbeat()). SAIAClient.arcana_version() / arcana_heartbeat() are kept as thin delegators, so existing calls are unchanged.

Changed#

  • Internal de-duplication (no behavior change): the chat-completion POST (shared by ChatService.completions and ArcanaService.chat), the directory upload/delete batch loop, the optional-tqdm progress wrapper, the background-thread Session helper, and API-key / base-URL resolution (resolve_credentials, shared by SAIAClient and create_openai_client) each now have a single implementation. ARCANA’s URL path and auth scheme are no longer duplicated in SAIAClient.

  • Moved base-URL / credential resolution into the configuration module: resolve_base_url, resolve_credentials, and DEFAULT_BASE_URL now live in saia_python.auth (beside load_api_key / load_config) rather than saia_python.client. The package-level saia_python.resolve_base_url import path is unchanged; only the saia_python.client.resolve_base_url submodule path is affected (breaking for code that imported from saia_python.client).

0.3.0 — 2026-05-30#

Added#

  • ModelsService.list_raw() — returns the raw GET /models response envelope (the OpenAI {"object": "list", "data": [...]} dict) without unwrapping, for callers that re-serve the full OpenAI-compatible payload (e.g. an adapter proxying GET /v1/models). list() now delegates to it. New tests/test_models.py.

  • Test coverage for previously-untested 0.2.0 behavior: get_rate_limits() raising AuthenticationError on 401/403 (tests/test_client.py), and chat streaming/non-streaming rate-limit parity (tests/test_chat.py).

  • saia_python.arcana_references — a pure, dependency-free module that parses GWDG ARCANA’s appended References: block into structured data: parse_arcana_references(), parse_reference_entries(), is_arcana_event(), and the ArcanaReference / ParsedReferences dataclasses. No HTTP/I-O, so it imports cleanly into async servers. Lets external SAIA consumers (e.g. an OpenAI-compatible adapter) drop their own copy of the GWDG reference regex. See ADR-0005. New tests/test_arcana_references.py.

0.2.0 — 2026-05-29#

Added#

  • VoiceService.transcribe() / translate() with wait=False now return a concurrent.futures.Future[str] (run on a dedicated background Session) instead of discarding the result. Resolve with .result() (re-raises on error), poll with .done(), or attach .add_done_callback(). New tests/test_voice.py.

  • RateLimitInfo.to_dict() — a plain, JSON-serializable view of the parsed rate-limit headers.

  • Optional openai extra (pip install saia-python[openai]). The openai package is now imported lazily inside create_openai_client(), so the core package installs and imports without it; using the OpenAI-compat layer without the extra raises a clear ImportError with install instructions.

  • SSEStream — streaming chat / ARCANA calls now return an iterable wrapper whose .rate_limits attribute exposes the rate-limit headers as a JSON-serializable dict (parity with the non-streaming _rate_limits key). Exported from the package; iterating it yields the same chunks as before.

  • Architecture Decision Records under docs/adr/ (MADR format), linked from the documentation: recording the ADR process itself, the optional openai extra, rate-limit metadata exposure, pyproject.toml as the single source of dependency truth, and non-blocking operations via Futures + dedicated Sessions.

Changed#

  • ModelsService.list() now uses GET /models (was POST), matching the OpenAI-compatible endpoint and the OpenAI SDK.

  • Non-streaming Chat and ARCANA responses attach _rate_limits as a plain, JSON-serializable dict (was a RateLimitInfo instance) so the whole response can be json.dumps-ed.

  • ArcanaService.generate_index(wait=False) fires the background trigger on its own requests.Session (a Session is not safe to share across threads with the caller’s polling calls).

  • SAIAClient.get_rate_limits() now raises AuthenticationError on 401/403 instead of silently returning an empty RateLimitInfo.

  • openai moved from core dependencies to the optional [openai] extra; the test extra pulls it in so CI is unaffected.

  • CI: bump actions/checkout@v4 @v5 and actions/setup-python@v5 @v6 across both the docs and tests workflows. Addresses the Node.js 20 deprecation warning (Node 20 removed from GitHub runners September 2026).

Removed#

  • requirements.txtpyproject.toml is the single source of truth for dependencies. The file was incomplete (missing openai/tomlkit) and referenced only by the README; CI installs via pip install -e ".[test|docs]".

Fixed#

  • iter_sse() now closes the streaming response in a finally block (it previously leaked the connection until garbage collection).

  • load_api_key(path=...) no longer mis-classifies a raw key containing = (e.g. base64 padding) as dotenv, and no longer IndexErrors on an empty file: the dotenv form is tried first, then the first non-empty, non-comment line.

  • Docs: corrected the clone URL to github.com/fschwar4/saia_python; removed an inaccurate tomllib/tomli note from configuration.rst (the package parses config with tomlkit); fixed the README repo-structure listing (dropped the non-existent backlog.md, added .env.example and CHANGELOG.md).

  • Removed a dead if not resp.ok branch in raise_for_status() and a redundant .env re-read in _resolve_owner_prefix().

  • ArcanaService.setup_from_directory() docstring no longer breaks the docs build under sphinx-build -W. The multi-line inline literal in the original Returns: block is not valid RST and produced "Inline literal start-string without end-string". Rewrote the Returns: block to describe the three-key dict semantically (using :meth: cross-references to :meth:create, :meth:upload_directory, and :meth:generate_index) — same information, valid RST.

0.1.2 — 2026-05-22#

Added#

  • SAIAClient.health_check(verbose=False) — verify connectivity and authentication in one call. Combines the existing arcana_heartbeat() (cheap 204 GET) with an authenticated models.list_ids() so callers can distinguish “service down” from “auth failed”. Returns a bool by default; verbose=True returns a diagnostic dict (ok, base_url, models_ok, model_count, arcana_ok, error) suitable for surfacing in onboarding scripts.

  • saia_python.text_of(response) — module-level helper that extracts the first choice’s assistant content from an OpenAI-style response dict (the shape returned by both ChatService.completions and ArcanaService.chat). Empty choices lists and missing/None content fields return "" with a logged warning so silent regressions surface in logs.

  • ArcanaService.setup_from_directory(name, source_dir, ...) — end-to-end convenience that composes create(), upload_directory(), and generate_index() into a single call. Returns {"arcana": <create-result>, "uploads": <list>, "index": <index-result>} so callers can inspect any step. Uses the UUID-suffixed name from create() for the upload + index calls so the composition stays correct without the caller having to remember the renaming.

  • Test coverage for all three additions: tests/test_health_check.py (6 tests), tests/test_responses.py (6 tests), tests/test_setup_from_directory.py (3 tests). Suite is network-free; HTTP / SDK calls are mocked.

0.1.1 — 2026-05-18#

Fixed#

  • ArcanaService.generate_index(wait=True) no longer re-raises requests.exceptions.ConnectionError when the ARCANA server drops the trigger connection mid-flight. The previous substring filter only matched "504" / "timeout" strings and missed the common RemoteDisconnected case — the server accepts the indexing job, holds the connection while building the embedding queue, then closes it without a response. Transport-level failures (requests.exceptions.Timeout, requests.exceptions.ConnectionError) and APIError(504) from the nginx gateway now fall through to the poll loop, with a sanity GET so a genuinely-down server still fails fast instead of waiting out the full poll timeout.

  • generate_index() no longer raises UnboundLocalError when a very short timeout causes the poll deadline to elapse before any iteration runs. The resulting TimeoutError message now formats cleanly.

Changed#

  • saia_python.arcana imports requests at runtime (was TYPE_CHECKING-only) so the requests.exceptions.* types are available for explicit handling.

Added#

  • Test suite for ArcanaService.generate_index() covering the transport-error paths (ConnectionError, ReadTimeout, APIError(504)), the genuinely-down-server case, propagation of non-504 API errors, the happy path, and the TimeoutError path.

0.1.0 — 2026-04-02#

Added#

  • Initial release of saia-python, a wrapper for the GWDG SAIA platform REST API.

  • Object-oriented SAIAClient composing Chat AI, Voice AI (Whisper transcription and translation), ARCANA (RAG / knowledge bases), Docling document conversion, model listing, and rate-limit inspection.

  • Standalone functional API mirroring the OOP surface (list_model_ids, chat_completion, transcribe, translate, list_arcanas, upload_to_arcana, arcana_chat, convert_document, get_rate_limits).

  • OpenAI SDK compatibility layer via client.openai and client.openai_async.

  • Credential and configuration discovery from environment variables, .saia_api, .env, and config.toml, including ARCANA ID resolution with owner-prefix handling.

  • Sphinx documentation (PyData theme) and a unit test suite.