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 supportAGENTS.md, and by Claude Code via a thinCLAUDE.mdthat imports it (@AGENTS.md). Per-machine notes stay in a gitignoredCLAUDE.local.md;.gitignorenow allows a shared.claude/settings.jsonwhile keeping personal.claude/settings.local.jsonand.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], pullshttpx). Newsaia_python.aiomodule:AsyncSAIAClient+AsyncChatService/AsyncArcanaService/AsyncModelsService, thehttpx.AsyncClienttwins of the sync data plane — chat completions and ARCANA RAGchat(streaming + non-streaming),get_rate_limits, andhealth_check. Use it as an async context manager;from saia_python import AsyncSAIAClientresolves lazily so importing the package never pullshttpxfor sync-only users. See ADR-0007.The retry brains are shared, not copied.
aexecute/apost_chat_completionreuseRetryPolicy/_plan/_jitter/resolve_retry/parse_rate_limitsverbatim, so the async path honours the same 429 policy (ADR-0006) as the sync path and the two cannot drift. Theretrykeyword works identically (retry=Falsefails fast; aRetryPolicytunes it).AsyncSSEStreamowns thehttpxclient.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) orasync 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,RateLimitErrornow 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 fromraise_for_status).Pure request builders (
saia_python._payloads, re-exported at top level):build_chat_body,apply_arcana_fields,arcana_chat_headers, and theINFERENCE_SERVICEconstant. 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.idbody fields + theinference-serviceheader) — defined and unit-tested once.
Changed#
Every
SAIAErrornow carriesstatus_code+response_body(previously onlyAPIErrordid). Lets a caller reframe the exact upstream response.RateLimitErrorkeeps itsrate_limitsattribute (unchanged signature).raise_for_statusis transport-agnostic — it accepts arequests.Response(honouring.ok) or anhttpx.Response(deciding success fromstatus_code), so one implementation serves both paths. No behaviour change for sync callers.The
testextra 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 staysmypy- andruff-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 todelete_file. Reuses the shared_run_file_batchexecutor (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 thePath-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 sameowner/name-uuidID so a downstream pin (e.g. a manifest) stays valid. RaisesAPIErrorif 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 onedelete_fileat 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). Newsaia_python.tokenizermodule andclient.tokenizersservice:GWDG_MODEL_REPOS/resolve_repo()/repo_url()map a GWDG model id (or display name, or a fullorg/namerepo) to its Hugging Face tokenizer repository. The catalogue is required becauseGET /modelsdoes not expose a repository link — its per-model payload isid/name/input/output/status/demandonly — soclient.tokenizers.available_repos()annotates the live model list from the catalogue (proprietary external models map toNone). The registry includes the embedding modelqwen3-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 theSAIA_TOKENIZER_DIRenv var — and tolerate per-model failures.load_tokenizer()loads viatransformers.AutoTokenizer(process-cached).load_hf_token()resolves a Hugging Face token fromHF_TOKEN(and aliases) or a.env/.saia_envfile, applied automatically to downloads for higher rate limits and gated repos. A gated repo (e.g.google/medgemma-27b-it) raises an expressiveGatedRepoAccessErrorfromdownload_tokenizer()/load_tokenizer()— carrying the licence URL and setup steps — and is recorded asNone(with averbose=Truehint) in adownload_all_tokenizers()batch.chat_template_tokens()applies a model’s chat template to arole/contentconversation (the system prompt, and optionally the user turn, may be read from a.txt/.mdfile) and returns aChatTokenCountwith 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()andsubword_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 aTokenDistribution(per-fileFileTokenCountrows 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, viatiktoken.The heavy libraries are imported lazily, so importing
saia_pythonnever 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: theReferences: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/downloadendpoint — still contains the front matter. The raw responses are saved asexamples/arcana_frontmatter_response_*.json(committed as frozen evidence), and the companion notebookexamples/arcana_frontmatter_repro.ipynbwalks 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 exportedRetryPolicyretry 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 withinmax_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 viaretry=(Falsedisables, aRetryPolicytunes). See ADR-0006 anddocs/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 withSAIAClient(retry=False)).RateLimitErroris 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_batchloop asupload_directory/upload_files/delete_directoryinstead of a parallel copy; the categorized sync report is derived as a grouped view of the shared per-file outcomes. No public API change. Twoverbose=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. Seedocs/proposals/batch-executor-unification.md.
0.5.1 — 2026-06-02#
Fixed#
Document-conversion images are now retrievable.
ConversionResult.imagesdocumented the base64 payload under keydata, but/documents/convertreturns it underimage, so callers following the docs hit aKeyError. The payload is now decoded at the API boundary.
Changed#
ConversionResult.imagesis now a list of typedConversionImage(filename,data: bytes,type) instead of raw API dicts;dataholds the already-decoded image bytes. (Breaking: switch from dict-key access to attribute access.)
Added#
ConversionResult.save_images(directory)andsave_all(directory)write the extracted images (and, forsave_all, the content) to disk — previouslysave()only wrote the text content.ConversionImageis exported from the top-level package.
0.5.0 — 2026-06-02#
Fixed#
ArcanaServiceHTTP 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 withrequests.Timeout— which propagates to the caller (batch helpers record it per file and continue) — instead of blocking on the socket read. Previously onlyheartbeatandgenerate_indexpassed a timeout;list,get,create,delete,list_files,delete_file,download_file,upload, and thedelete_directoryloop 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 theSAIAClient.get_rate_limits()probe (GET /chat/completions), which could otherwise hang the same way.Type-checking:
_streaming.iter_ssenormalizesiter_linesoutput tostr(also robust to thebytesthe requests stub declares), and thelist_tool_capableprobe body is typeddict[str, Any]— silencing two errors a newermypy/types-requestsflags. No behavior change.
Added#
timeoutparameter onSAIAClient(...),ArcanaService(...), andModelsService(...)(default(10, 60); a singlefloatapplies to both the connect and read phases,Nonedisables) 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_resultper-file callback onupload_files,upload_directory,delete_directory, andsync_directory— invoked ason_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. (Forsync_directory, fired for local uploads/replaces/skips, not forprunedeletions.)
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 overalltimeoutdeadline ends the wait. Hardens the new default request timeout against long index waits. On deadline exhaustion the raisedTimeoutErrornow 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_indexskips files alreadyINDEXED, 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_directorywith a SHA-256select, and an explicitupload_files+ singlegenerate_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 asupload_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-suppliedselect(local_path, remote_or_None) -> "upload" | "replace" | "skip"policy, then trigger a singlegenerate_indexonly 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. Optionalprunedeletes remote files with no local counterpart.PyPI packaging metadata in
pyproject.toml:readme,license-files,keywords, troveclassifiers, 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 theTyping :: Typedclassifier..github/workflows/publish.yml— builds the sdist + wheel, runstwine 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) andmypyconfiguration inpyproject.toml, alintoptional-dependency group, a.pre-commit-config.yaml, and aQualityCI workflow (ruff check+ruff format --check+mypy).Coverage reporting via
pytest-cov; the Tests workflow now runspytest --cov.
Changed#
ArcanaService.list_files()now documents the full APIFileOutSchema(name,size,owner_user_name,created_at,updated_at,index_infowith per-fileindex_status/chunks_indexed, andrelated_files).Corrected the
licensefield from the deprecated SPDX identifierAGPL-3.0toAGPL-3.0-only. The license terms are unchanged; only the SPDX expression is now valid for PEP 639 / PyPI. Bumped the build requirement tosetuptools>=77(PEP 639 support).Applied
ruffautofixes and the formatter across the package and tests (Optional[X]→X | None, import sorting, consistent formatting) plus minormypytype-annotation fixes. No behavior change — all 92 tests pass. Known typing gaps (tomlkit’sItem | Containerinauth.py; the.listmethod shadowing the builtin inmodels.py/arcana.py) are scoped via documented per-modulemypyoverrides.
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 verboseReferences:dump into clean, numbered citations for any OpenAI-SDK client. Example only — not shipped in the package.ArcanaService.version()andArcanaService.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.completionsandArcanaService.chat), the directory upload/delete batch loop, the optional-tqdmprogress wrapper, the background-threadSessionhelper, and API-key / base-URL resolution (resolve_credentials, shared bySAIAClientandcreate_openai_client) each now have a single implementation. ARCANA’s URL path and auth scheme are no longer duplicated inSAIAClient.Moved base-URL / credential resolution into the configuration module:
resolve_base_url,resolve_credentials, andDEFAULT_BASE_URLnow live insaia_python.auth(besideload_api_key/load_config) rather thansaia_python.client. The package-levelsaia_python.resolve_base_urlimport path is unchanged; only thesaia_python.client.resolve_base_urlsubmodule path is affected (breaking for code that imported fromsaia_python.client).
0.3.0 — 2026-05-30#
Added#
ModelsService.list_raw()— returns the rawGET /modelsresponse envelope (the OpenAI{"object": "list", "data": [...]}dict) without unwrapping, for callers that re-serve the full OpenAI-compatible payload (e.g. an adapter proxyingGET /v1/models).list()now delegates to it. Newtests/test_models.py.Test coverage for previously-untested 0.2.0 behavior:
get_rate_limits()raisingAuthenticationErroron 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 appendedReferences:block into structured data:parse_arcana_references(),parse_reference_entries(),is_arcana_event(), and theArcanaReference/ParsedReferencesdataclasses. 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. Newtests/test_arcana_references.py.
0.2.0 — 2026-05-29#
Added#
VoiceService.transcribe()/translate()withwait=Falsenow return aconcurrent.futures.Future[str](run on a dedicated backgroundSession) instead of discarding the result. Resolve with.result()(re-raises on error), poll with.done(), or attach.add_done_callback(). Newtests/test_voice.py.RateLimitInfo.to_dict()— a plain, JSON-serializable view of the parsed rate-limit headers.Optional
openaiextra (pip install saia-python[openai]). Theopenaipackage is now imported lazily insidecreate_openai_client(), so the core package installs and imports without it; using the OpenAI-compat layer without the extra raises a clearImportErrorwith install instructions.SSEStream— streaming chat / ARCANA calls now return an iterable wrapper whose.rate_limitsattribute exposes the rate-limit headers as a JSON-serializable dict (parity with the non-streaming_rate_limitskey). 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 optionalopenaiextra, rate-limit metadata exposure,pyproject.tomlas the single source of dependency truth, and non-blocking operations via Futures + dedicated Sessions.
Changed#
ModelsService.list()now usesGET /models(wasPOST), matching the OpenAI-compatible endpoint and the OpenAI SDK.Non-streaming Chat and ARCANA responses attach
_rate_limitsas a plain, JSON-serializable dict (was aRateLimitInfoinstance) so the whole response can bejson.dumps-ed.ArcanaService.generate_index(wait=False)fires the background trigger on its ownrequests.Session(aSessionis not safe to share across threads with the caller’s polling calls).SAIAClient.get_rate_limits()now raisesAuthenticationErroron 401/403 instead of silently returning an emptyRateLimitInfo.openaimoved from core dependencies to the optional[openai]extra; thetestextra pulls it in so CI is unaffected.CI: bump
actions/checkout@v4 → @v5andactions/setup-python@v5 → @v6across both the docs and tests workflows. Addresses the Node.js 20 deprecation warning (Node 20 removed from GitHub runners September 2026).
Removed#
requirements.txt—pyproject.tomlis the single source of truth for dependencies. The file was incomplete (missingopenai/tomlkit) and referenced only by the README; CI installs viapip install -e ".[test|docs]".
Fixed#
iter_sse()now closes the streaming response in afinallyblock (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 longerIndexErrors 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 inaccuratetomllib/tomlinote fromconfiguration.rst(the package parses config withtomlkit); fixed the README repo-structure listing (dropped the non-existentbacklog.md, added.env.exampleandCHANGELOG.md).Removed a dead
if not resp.okbranch inraise_for_status()and a redundant.envre-read in_resolve_owner_prefix().ArcanaService.setup_from_directory()docstring no longer breaks the docs build undersphinx-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 existingarcana_heartbeat()(cheap 204 GET) with an authenticatedmodels.list_ids()so callers can distinguish “service down” from “auth failed”. Returns a bool by default;verbose=Truereturns 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 bothChatService.completionsandArcanaService.chat). Emptychoiceslists and missing/Nonecontentfields return""with a logged warning so silent regressions surface in logs.ArcanaService.setup_from_directory(name, source_dir, ...)— end-to-end convenience that composescreate(),upload_directory(), andgenerate_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 fromcreate()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-raisesrequests.exceptions.ConnectionErrorwhen the ARCANA server drops the trigger connection mid-flight. The previous substring filter only matched"504"/"timeout"strings and missed the commonRemoteDisconnectedcase — 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) andAPIError(504)from the nginx gateway now fall through to the poll loop, with a sanityGETso a genuinely-down server still fails fast instead of waiting out the full poll timeout.generate_index()no longer raisesUnboundLocalErrorwhen a very shorttimeoutcauses the poll deadline to elapse before any iteration runs. The resultingTimeoutErrormessage now formats cleanly.
Changed#
saia_python.arcanaimportsrequestsat runtime (wasTYPE_CHECKING-only) so therequests.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 theTimeoutErrorpath.
0.1.0 — 2026-04-02#
Added#
Initial release of
saia-python, a wrapper for the GWDG SAIA platform REST API.Object-oriented
SAIAClientcomposing 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.openaiandclient.openai_async.Credential and configuration discovery from environment variables,
.saia_api,.env, andconfig.toml, including ARCANA ID resolution with owner-prefix handling.Sphinx documentation (PyData theme) and a unit test suite.