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; onlydisclarion_metachanges, 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/prependdisclosure_text;disclarion_meta["disclosed"]reflects whether that happened.off— pure logging, no disclosure signal at all:disclarion_metaomitsis_first_message/disclosedentirely. 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 needsposition: relative(or any non-staticposition) — the one thing this snippet can't set for you, sinceposition: absolutepositions 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 tohttps://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 passimage=<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 thatbadge_htmlcan. A very small image (shorter side under ~320px) can't fit a legible badge: watermarking is skipped rather than shrunk into unreadability,watermarked_imageis left out of the dict, and you instead getwatermark_skipped: Truewithwatermark_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:Trueby default; passFalseto 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.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| disclarion-0.5.0.tar.gz | 48.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| disclarion-0.5.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 85.0 kB
Release files / disclarion-0.5.0.tar.gz
| Download URL | disclarion-0.5.0.tar.gz |
|---|---|
| Size | 48.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
46aea232b1ea0b7a8d65c5d7cd2c77b7f213a619e05d705dd118653679d67a3a
|
|
BLAKE2b-256 checksum How to use checksums |
fc28d404c1886034efb6c3de919a7a0373189971076b62136aac8dd351a77d08
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.9.6
|
Release files / disclarion-0.5.0-py3-none-any.whl
| Download URL | disclarion-0.5.0-py3-none-any.whl |
|---|---|
| Size | 36.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
dc5872f543c266600bb2256c047aef8b8b855f8766006ee378b0c36157e7da4f
|
|
BLAKE2b-256 checksum How to use checksums |
e840f1f635efff0d02f4a735a4f459d4e8b1fa2dbcd7754bce3986a83c3e1b1b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.9.6
|