Skip to main content

iis-access

Python auth library for IIS tools ecosystem. Provides optional authentication, provider resolution, managed key retrieval, and usage reporting for Python-based IIS CLI tools (aria, sonar, iris).

Installation

pip install iis-access

Quick Start

from iis_access import resolve_provider, report_usage

# Resolve the best available provider for a task
resolution = await resolve_provider("tts", preferred="elevenlabs")
print(resolution.provider, resolution.method)

# Report usage (fire-and-forget, never raises)
await report_usage("aria", credits=1.5)

report_usage sends the logged-in user's access token. The account service accepts it only for that user's own account and only for local-runtime capabilities (runtimeTag local or local_gpu in the capability registry, e.g. aria, sonar, iris); any other service is refused, and a refused report is logged as a WARNING on the iis_access logger (account#135).

Quota and credit decisions (0.3.0)

Turn an account answer into one of eight decisions, then into a status, headers, an RFC 9457 body, a CLI line, an exit code or an MCP tool error. The contract is docs/consumers/quotas-and-credits.md; its fixture docs/consumers/fixtures/access-decisions.json is run against this package and against @iistools/access.

from iis_access import (
    classify_access, status_for, retry_after_seconds, quota_headers, denial_body,
    cli_message, exit_code_for, mcp_error,
)

body = ...  # the JSON of POST /access/check, as a dict
decision = classify_access(body)  # ok | quota_exceeded | not_entitled | account_suspended | ...
if decision.decision != "ok":
    print(cli_message(decision, "de", service="aria"), file=sys.stderr)
    sys.exit(exit_code_for(decision))
Helper Returns
classify_access(body_or_exc, now=None) AccessDecision: decision, retryable, binding (the window headers and messages speak about), windows, exceeded, violated_policies, credits, credits_low, reason
status_for(decision) HTTP status: ok 200, quota_exceeded 429, insufficient_credits 402, not_entitled / account_suspended / account_unavailable 403, session_revoked 401, unreachable 503
retry_after_seconds(decision, now=None) quota_exceeded: seconds to the reset, at least 1; unreachable: 30; else None
quota_headers(decision, header_style='both', *, now=None) X-Quota-*, X-Credits-Balance, Retry-After (429 and 503), and the IETF RateLimit-Policy (gating windows only) / RateLimit pair with q and r floored (a draft that expires 2026-11-24; no binding, no pair; 'legacy' drops it, 'ietf' drops X-Quota-*)
denial_body(decision, locale='en', *, now=None) {status, headers, body}; body is an RFC 9457 problem, title / detail in en or de; retryAfter is the reset (quota) or now + 30 s (unreachable), else None
cli_message(decision, locale='en', *, service=None, tz=None) one stderr line in en or de; the reset time is local unless tz is given
exit_code_for(decision) ok 0, quota_exceeded 4, unreachable 5, not_entitled / account_suspended / session_revoked / account_unavailable 3, insufficient_credits 1
mcp_error(decision, locale='en', *, service=None) {isError: True, content: [...], structuredContent: {code, retryable, quota?, credits?}}; ok raises ValueError

Rules worth knowing: classification is by reason, never by the deprecated suspended; an unknown allowed: false reason is not_entitled with the raw reason kept; a pre-v2 body (no windows) is classified from its quota block; a soft window never binds. insufficient_credits exits 1 because the IIS CLI contract has no "payment required" code: a script keys on code: INSUFFICIENT_CREDITS. Call quota_headers / denial_body when the response is written (the reset countdown is computed from the clock then) and send the headers with Vary: Authorization: they describe one caller.

Charging credits

from iis_access import deduct_credits, refund_credits, InsufficientCredits, CreditsRequestError

result = await deduct_credits(
    service_key=KEY, account_id=account_id, service="aria", operation="tts",
    amount=1.5, idempotency_key=job_id,
)
await refund_credits(
    service_key=KEY, account_id=account_id, service="aria", operation="tts",
    amount=0.5, related_transaction_id=result.transaction_id, idempotency_key=f"{job_id}-refund",
)

The key must be a service key bound to service (or holding service:any) and holding the credits:write scope; the balance is account-global, so the binding alone is not enough. Mint the scope with iis-account keys tag --add-scope credits:write. amount is required and positive on both calls (a replay with the same idempotency_key answers duplicate=True).

Money calls fail loudly; nothing is swallowed (report_usage is the fire-and-forget one):

account answers raises classify_access
402 InsufficientCredits(balance_after) insufficient_credits (exit 1, credits_low)
429, 5xx, timeout, network failure, unparseable 200 CreditsUnreachable unreachable (retryable, 503, Retry-After: 30)
400, 401, 403 (key not bound or no credits:write), 404 CreditsRequestError(status, code) re-raised: the consumer's key or request is wrong, so it is no decision. Answer it with your generic error path (a CLI exits 1, an MCP tool reports its generic tool error)

report_usage is unchanged.

Credit holds (0.4.0)

Reserve credits before long or variable-cost work, then charge the actual cost. Prefer this over deduct-then-refund.

from iis_access import hold_credits, settle_credits, release_credits

hold = await hold_credits(
    service_key=KEY, account_id=account_id, service="aria", operation="tts",
    amount=estimate, idempotency_key=f"{job_id}:hold", ttl_seconds=900,   # InsufficientCredits on a 402
)
try:
    await settle_credits(service_key=KEY, hold_id=hold.hold_id, amount=actual, idempotency_key=f"{job_id}:settle")
except Exception:
    await release_credits(service_key=KEY, hold_id=hold.hold_id, idempotency_key=f"{job_id}:release")
    raise

settle_credits takes an amount of 0 or more (at most the hold; 0 writes no deduction and transaction_id is None). Same key as deduct_credits (credits:write), same errors: InsufficientCredits (a hold's 402, classifies as insufficient_credits), CreditsUnreachable, and CreditsRequestError for 400, 401, 403, 404 (credits.hold_not_found) and 409 (credits.hold_expired, credits.idempotency_conflict). One idempotency_key per call: a key spent on another credit call is 409.

Reporting usage v2 (0.4.0)

from iis_access import UsageEvent, UsageUnreachable, report_usage_batch

try:
    result = await report_usage_batch(
        "aria",
        [UsageEvent("aria.minutes", line.minutes, line.at, line.id) for line in lines],
        service_key=KEY, account_id=account_id,       # or omit both: the signed-in user's token and account
        on_dropped=lambda d: log.warning("usage dropped %s: %s", d.event.idempotency_key, d.code),
    )
    journal.remove(lines)            # recorded, duplicate and dropped lines are all done
except UsageUnreachable:
    pass                             # transient: keep every line, retry the batch later

Chunks of 500. Events older than 6 d 23 h are dropped client-side (usage.too_old, never sent). So is a malformed event (usage.invalid_event: unparseable occurred_at, non-finite or non-number quantity, a meter or idempotency_key outside 1-128 characters, operation or provider over 128): it lands in result.dropped, the valid events of the call are still recorded, and nothing raises. A 200 with rejected[] returns those events in result.dropped (and on_dropped) with the server code; they are permanent, the others are recorded. 429, 5xx, a timeout, a network failure, a 2xx that is not the v2 answer (an older account service) or no signed-in user raise UsageUnreachable and report nothing as dropped: retry the whole batch (counted events answer as duplicates). 400, 401, 403 and other 4xx raise UsageRequestError(status, code). report_usage stays the fire-and-forget v1 call.

Declaring meters (0.4.0)

declare_meters(account_url, service_key, service, meters) and validate_service_key(account_url, key, meters=[{"meter": "aria.minutes", "unit": "minute", "kind": "counter"}]) declare a service's meters (idempotent, restate on every boot); the registry rows land in info.meters. A meter in use cannot change its unit or kind: MeterDeclarationError (409 meters.conflict, .meters names the ids). An outage while declaring is MeterDeclarationUnreachable (ServiceKeyCheckUnreachableError from validate_service_key).

meters on the check: this package has no /access/check client (classify_access takes the body you fetched). To name meters, send "meters": ["search.research_deep"] in the check body yourself and key your own cache on the sorted set, so a named-meter denial is never served to an un-named call.

Development

Use uv — do not pip install into an ad-hoc venv, it drifts out of sync with pyproject.toml (missing deps like PyJWT after account#53 added it).

cd packages/access-py
uv sync --locked --extra dev   # creates .venv/, installs the package + dev deps from uv.lock
PYTHONPATH=src uv run pytest tests/ -v

uv sync --locked reads uv.lock (committed) for reproducible resolution and fails if it is stale — the same command CI runs (.github/workflows/test-access-py.yml).

CI pins uv 0.10.10 in .github/workflows/test-access-py.yml; bump it when re-locking with a newer uv.

aioresponses fork (account#69): the dev extra pins aioresponses-ng, not upstream aioresponses. Upstream 0.7.9 (the latest release) does not intercept aiohttp>=3.14 — aiohttp.ClientResponse.__init__() gained a required stream_writer keyword that upstream doesn't pass, so every aioresponses()-mocked test fails with TypeError: ClientResponse.__init__() missing 1 required keyword-only argument: 'stream_writer' instead of intercepting the request. The fix exists as an unmerged upstream PR (pnuckowski/aioresponses#288); aioresponses-ng is a maintained fork that ships it. It installs under the same aioresponses import path, so no test code changes are needed — only the dependency in pyproject.toml. If upstream ever merges and releases the fix, this pin can be dropped back to aioresponses.

Release

No workflow publishes iis-access to PyPI; publishing is a manual step. The Test access-py workflow (.github/workflows/test-access-py.yml, job test) is otherwise informational: develop is unprotected and no job needs: it, so a red run blocks nothing by itself (account#139). The release gate is therefore this check, made by whoever publishes:

The release SHA must have a green Test access-py run. Before building and uploading, run:

SHA=$(git rev-parse HEAD)   # the commit you are about to publish
gh run list --workflow test-access-py.yml --commit "$SHA" --json conclusion,status --jq '.[0]'

Proceed only if it prints {"conclusion":"success","status":"completed"}. Anything else (failure, in_progress, cancelled, or no output) means do not publish.

Empty output is not a pass. The workflow runs only on changes under packages/access-py/** or the workflow file, and a push triggers it for the tip commit only. A release SHA that did not touch the package, or that sat in the middle of a multi-commit push, has no run of its own. Then gate on the newest run whose commit is an ancestor of the release SHA, provided nothing under the package changed between that commit and the release SHA:

RUN=$(gh run list --workflow test-access-py.yml --branch develop --json headSha,conclusion --jq '.[0]')   # use --branch main for a main release
RUN_SHA=$(jq -r .headSha <<<"$RUN")
git merge-base --is-ancestor "$RUN_SHA" "$SHA" \
  && git diff --quiet "$RUN_SHA" "$SHA" -- packages/access-py .github/workflows/test-access-py.yml \
  && jq -r .conclusion <<<"$RUN"   # must print: success

(If $RUN_SHA is not an ancestor, or the diff is non-empty, trigger a run by pushing the change through a PR or branch push and use the first command on that SHA.)

Build into a fresh directory and upload explicit files. packages/access-py/dist/ in the main tree can hold artifacts of earlier releases (iis_access-0.1.1, iistools_access-0.1.1). PyPI filenames can never be reused, so a glob upload would re-send them and fail or mislead. Never run twine upload dist/*. From packages/access-py:

OUT=$(mktemp -d)
uv build --out-dir "$OUT"
twine check "$OUT"/iis_access-<version>.tar.gz "$OUT"/iis_access-<version>-py3-none-any.whl
twine upload "$OUT"/iis_access-<version>.tar.gz "$OUT"/iis_access-<version>-py3-none-any.whl

<version> is the pyproject.toml version (for example 0.2.1); the directory must hold exactly these two files. The sdist ships only src/iis_access, README.md and pyproject.toml, plus the PKG-INFO and .gitignore that hatchling always adds; tar tzf "$OUT"/iis_access-<version>.tar.gz after the build above lists exactly those. No test pins the sdist contents, because publishing is manual; if publishing ever moves to CI, add a pin test modelled on packages/cli/tests/pack-contents.test.ts.

Metadata

Release files for iis-access 0.4.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 iis-access 0.4.1
File Size Uploaded Provenance
iis_access-0.4.1.tar.gz 35.4 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for iis-access 0.4.1
File Interpreter ABI Platform
iis_access-0.4.1-py3-none-any.whl Python 3 none any Details

Total release size: 77.7 kB

Release files / iis_access-0.4.1.tar.gz

Download URL iis_access-0.4.1.tar.gz
Size 35.4 kB
Tags Source
SHA-256 checksum
How to use checksums
8eb5d7504d45429b1144647667c66f5845f80706b5ea2225883a7f46792fa320
BLAKE2b-256 checksum
How to use checksums
0204cbfa027af681fb1d3236f80e8c352630736acd46329babc268673d753a25
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.13

Release files / iis_access-0.4.1-py3-none-any.whl

Download URL iis_access-0.4.1-py3-none-any.whl
Size 42.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
86100ad914f0dc28f2e7cc2599f1fbc11affad9152e8a0acf821624f16a0fbc8
BLAKE2b-256 checksum
How to use checksums
00916dcd0647918211625178aa95ac7337ef37fb6109b8d83926898c58866a02
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.13

Security for iis-access 0.4.1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

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