Skip to main content

disclarion

AI disclosure logging SDK. Wraps your LLM provider responses and reports standardized disclosure/labeling metadata to Disclarion.

Install

pip install disclarion

Usage

from disclarion import Disclarion
import openai

client = openai.OpenAI()
dc = Disclarion(api_key="dcl_live_...")

response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": "hello"}],
)
dc.track(response, session_id="session-123")

track() never modifies response by default — it attaches a disclarion_meta dict to it instead, and your own UI decides what to show:

tracked = dc.track(response, session_id="session-123")
tracked.disclarion_meta
# {"ai_generated": True, "is_first_message": True, "disclosed": False, "logged_at": "..."}

is_first_message is what a host-app disclosure widget should key off of — show a one-time blocking notice on True, keep a small persistent badge up for the rest of the session on False. A ready React and vanilla-JS implementation of exactly that pattern ships inside every install — find it with:

python -c "import disclarion; print(disclarion.widget_dir())"

This is the disclosure_mode="modal_once" default, set on the client, not per call:

  • modal_once (default) — never touches response content; only disclarion_meta changes, as above.
  • inline_every_message — the SDK's original behavior, kept for backward compatibility. Despite the name, it still only rewrites the response's own content on a session's first message (never later ones) to append/prepend disclosure_text; disclarion_meta["disclosed"] reflects whether that happened.
  • off — pure logging, no disclosure signal at all: disclarion_meta omits is_first_message/disclosed entirely. Use this if your product already has its own unrelated AI-disclosure UI.
dc = Disclarion(api_key="dcl_live_...", disclosure_mode="inline_every_message")

Regardless of disclosure_mode, every track() call is still logged to Disclarion's audit trail (interaction_logs) exactly the same way — the UI mode only changes what happens to response and disclarion_meta, never what gets recorded for compliance.

track() makes one fast synchronous attempt (that's what makes is_first_message available immediately) and never retries inline — a slow or erroring Disclarion backend can never turn into multi-second latency on your own request path. Anything worth retrying (a network error, a 5xx, a transient 429) is handed to a background thread with a bounded queue (up to 1000 pending logs, oldest dropped first if your process is generating logs faster than Disclarion can accept them) that retries with capped, jittered exponential backoff, honoring a 429's Retry-After header when present. The queue is flushed on normal process exit, SIGINT, and SIGTERM (bounded to a couple of seconds, so shutdown is never blocked indefinitely by a backend that's still down).

A 429 that means "you've exceeded your monthly plan quota" is handled differently from a transient rate limit: retrying it is pointless until next period, so the SDK opens a circuit breaker instead — every track() call skips the network entirely (sub-millisecond) until an hourly (then exponentially longer, capped at 24h) cooldown elapses and one probe request checks whether it's resolved. One logging.warning marks each state change (opened/resolved), not one per call, so a month-long outage doesn't spam your logs. Call dc.get_stats() any time for the queue size, circuit state, and delivery counters.

An invalid or revoked API key is different from all of the above: the backend is reachable and is explicitly rejecting it, which is a setup mistake you need to see, so that raises disclarion.AuthenticationError immediately — no retry, no circuit-breaking, no silent fallback. An inactive subscription (trial ended, payment failed) is treated the same way: it raises disclarion.SubscriptionInactiveError immediately, for the same reason (new in 0.3.0 — before this, a 402 fell through the generic failure path above and was silently swallowed at DEBUG level, which meant a lapsed subscription could leave a real, permanent gap in your audit trail with no visible symptom; this is an intentional breaking change, see CHANGELOG.md). Unsupported response types raise a ValueError immediately for the same reason: all three are programming/ account-state errors to fix, not a runtime condition to swallow.

OpenAI (ChatCompletion), Anthropic (Message), and Gemini (GenerateContentResponse) responses are all supported — see disclarion/adapters/.

Jurisdiction and interaction type

track() also takes jurisdiction (default "EU") and interaction_type (default "chat"):

dc.track(response, session_id="session-123", jurisdiction="US-CA", interaction_type="chat")

These feed Disclarion's backend Obligation Engine, which decides which obligations (e.g. disclosure_modal, audit_log) actually apply to this pair — the SDK has no built-in notion of "EU Article 50" or any other specific regulation, it only acts on whatever required_actions the backend returns. The defaults ("EU", "chat") match the original rule, so every caller from before this parameter existed sees identical behavior without passing these. Any jurisdiction/interaction type combination without a configured rule is still logged in full (the audit trail is never gated by obligations), it just comes back with no obligations — disclarion_meta omits is_first_message/disclosed for that call, the same shape as disclosure_mode="off".

Check what a pair currently requires without logging anything, with the same API key:

curl "https://api.disclarion.com/v1/obligations?jurisdiction=EU&interaction_type=chat" \
  -H "Authorization: Bearer dcl_live_..."
# {"data": {"jurisdiction": "EU", "interaction_type": "chat", "required_actions": ["disclosure_modal", "audit_log"]}, "error": null}

There's no SDK wrapper for this endpoint yet — it's a plain side-effect-free GET, same auth as everything else.

Labeling AI-generated content

disclosure_modal is about chatbots: a live conversation where a user needs a heads-up they're talking to an AI. Not everything track() logs is a conversation — a product description, a social media post, or an article drafted with an LLM is AI-generated content published somewhere, with no "session" a disclosure modal would ever attach to. For that, pass interaction_type="generated_content":

dc.track(response, session_id="post-42", interaction_type="generated_content")

Today ("EU", "generated_content") is the one configured rule for this, and it returns a different obligation, content_label, not disclosure_modal. Unlike disclosure, this isn't a live UI component and isn't gated by disclosure_mode at all — when it applies, disclarion_meta just gets a plain signal:

tracked = dc.track(response, session_id="post-42", interaction_type="generated_content")
tracked.disclarion_meta
# {"ai_generated": True, "logged_at": "...", "content_label_required": True}

There's no widget for this (yet — see the roadmap): content_label_required is a flag, not a component. It's up to your own content template to decide what that looks like — an "AI-generated" caption under a product description, an aria-label, a byline note on a blog post. The interaction is logged exactly the same way regardless.

Non-text content (images, video, audio): track_content()

dc.track(response, ...) only works with a response object one of its adapters can introspect — OpenAI ChatCompletion, Anthropic Message, Gemini GenerateContentResponse. That's fine for text drafted via a chat completion (pass it to track() as above), but an image/video/audio generation call returns something else entirely, with none of the fields those adapters read (checked against OpenAI's own ImagesResponse: no .id, no .model). For those, use track_content() instead — it takes provider/model_name directly rather than trying to detect them from a response, since you already know both from your own generation call:

image = openai_client.images.generate(model="dall-e-3", prompt="...")

meta = dc.track_content(
    provider="openai",
    model_name="dall-e-3",
    session_id="product-photo-42",
    content_type="image",  # free text — "image", "video", "audio", "text", ...
)
meta
# {"ai_generated": True, "logged_at": "...", "content_label_required": True}

Always logs as interaction_type="generated_content" (it isn't a parameter here — there's no chat-vs-content ambiguity for this method). Returns the disclarion_meta dict directly, since there's no response object to attach it to. content_type is optional and purely descriptive today (it doesn't change which obligations apply, only what's recorded for later reporting) — pass whatever describes the content, there's no fixed list.

The standard Disclarion label

track_content() also produces a fixed, non-customizable bottom-left visual label for the content you just logged — a small "AI-generated"/ "AI-assisted" badge plus a "Labeled by Disclarion" credit. It satisfies the content_label obligation above with a ready-made mark instead of you designing your own. There's no API to restyle it: a label a customer could reskin to look like something else would defeat the point of a standard, recognizable one.

It comes back in two forms — use whichever matches what you're labeling:

meta = dc.track_content(
    provider="openai",
    model_name="dall-e-3",
    session_id="product-photo-42",
    content_type="image",
    image=image_bytes,  # optional: raw PNG/JPEG/WebP bytes to watermark
)

meta["badge_html"]         # HTML/CSS snippet, for text/article content
meta["watermarked_image"]  # bytes, only present when `image=` was passed
meta["content_labeled"]    # True iff a label was actually applied
  • badge_html — an HTML/CSS snippet for text or article content. Place it as the last child inside the container wrapping your content. That container needs position: relative (or any non-static position) — the one thing this snippet can't set for you, since position: absolute positions against the nearest positioned ancestor, and without one that's an arbitrary scroll ancestor, not your content block. Its "Labeled by Disclarion" credit is a real, clickable link to https://www.disclarion.com/verify/{id} — a public page confirming this specific record is real (timestamp, provider, content type; never the content itself), not just a generic link to disclarion.com.
  • watermarked_image — only produced when you pass image=<raw bytes> (PNG, JPEG, or WebP). The same badge baked into the image's own pixels via Pillow, so it travels with the file through a re-upload or re-host. Because it's pixels, not markup, its credit line is plain text, not a link — a raster image has no clickable regions, so this is the one thing the pixel form genuinely can't do that badge_html can. A very small image (shorter side under ~320px) can't fit a legible badge: watermarking is skipped rather than shrunk into unreadability, watermarked_image is left out of the dict, and you instead get watermark_skipped: True with watermark_skip_reason: "image_too_small".

content_labeled — also written to the audit trail and shown in the dashboard's Logs table — reflects whether a label was actually applied to this piece of content: always True for the HTML badge (no size constraint), and for images, True only when the watermark wasn't skipped.

Two more parameters, both optional:

  • ai_involvement: "generated" (default, shows "AI-generated") or "assisted" (shows "AI-assisted", for content a human substantially edited/directed). No other values.
  • include_disclarion_link: True by default; pass False to drop the "Labeled by Disclarion" credit line from both forms and keep only the required AI-disclosure text.

Known limitation, stated plainly: this is a visible label, not tamper-proofing. A crop that excludes the bottom-left corner removes it, the same way it would remove a printed photo credit — it is never described as unbreakable or unremovable.

Using this with an async framework (FastAPI, aiohttp, Starlette, ...)

track() is synchronous under the hood (it uses requests) and blocks for up to FAST_PATH_TIMEOUT_SECONDS (currently 1.5s) even on a healthy backend. Called directly inside an async def handler, that blocks the whole worker's event loop for that long — stalling every other concurrent request on the same worker, not just the one that called track(). Get it off the event loop:

# FastAPI / Starlette
from starlette.concurrency import run_in_threadpool

tracked = await run_in_threadpool(dc.track, response, session_id=session_id)
# Any asyncio app, no Starlette dependency
import asyncio

tracked = await asyncio.to_thread(dc.track, response, session_id=session_id)

This applies even if the backend you're calling is your own service — a synchronous track() call made from inside an async handler can, in the worst case, block that same process against itself.

Testing without a live backend

To exercise _send_log() end to end without hitting the real api.disclarion.com:

python scripts/mock_server.py        # terminal 1 - fake /v1/logs endpoint
python scripts/manual_test.py        # terminal 2 - sends a fake ChatCompletion

The mock server prints every received payload and the Authorization header so you can confirm the normalized log shape is correct. See scripts/README.md for details, or tests/ for the automated unit tests (pytest) which mock the HTTP layer instead of needing a running server.

Metadata

Release files for disclarion 0.5.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for disclarion 0.5.1
File Size Uploaded
disclarion-0.5.1.tar.gz 184.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for disclarion 0.5.1
File Interpreter ABI Platform
disclarion-0.5.1-py3-none-any.whl Python 3 none any Details

Total release size: 220.8 kB

Release files / disclarion-0.5.1.tar.gz

Download URL disclarion-0.5.1.tar.gz
Size 184.3 kB
Tags Source
SHA-256 checksum
How to use checksums
9b10cb2383dfb078e89ddcb457d53937fb26cef41ba5895a62696a6ec25127d4
BLAKE2b-256 checksum
How to use checksums
0488c679fd343f744fa569a9659de1fd3dad3d5a023221df6a4698653da37f9f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.15

Release files / disclarion-0.5.1-py3-none-any.whl

Download URL disclarion-0.5.1-py3-none-any.whl
Size 36.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9465b46145e3de324673834bfa8a3335e59aca2d1771adb65763c3e4af581338
BLAKE2b-256 checksum
How to use checksums
de79510019a244f27d6d357dc76a38b89eec5a93c1d69cadf66d6856818eb50d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.15

Release history Release notifications | RSS feed

0.8.0

2 release files

0.7.0

2 release files

This release

0.5.1 This release

2 release files

0.5.0

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.2

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.4

2 release files

0.2.3

2 release files

0.2.2

2 release files

0.2.1

2 release files

0.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page