Metadata-Version: 2.5
Name: thinqit-dex
Version: 0.1.1
Summary: Python client for thinQit Dex, the EU-hosted decision API: typed questions in, calibrated answers out.
Project-URL: Homepage, https://thinqit.ai
Project-URL: Documentation, https://thinqit.ai/docs
Project-URL: Source, https://github.com/Anandthakur87/dex/tree/main/sdk/python
Author: thinQit
License-Expression: Apache-2.0
License-File: LICENSE
License-File: NOTICE
Keywords: api,classification,decision,dex,eu
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: anyio>=3.7
Requires-Dist: httpx<1,>=0.25
Requires-Dist: pydantic<3,>=2.5
Requires-Dist: typing-extensions>=4.7
Description-Content-Type: text/markdown

# thinqit-dex

The Python client for **thinQit Dex**, the EU-hosted decision API. You send a state (text or JSON) and typed
questions; Dex returns typed answers with calibrated probabilities. The same request to the same GPU model version
always gets the same answer, and all processing happens in the EU.

- Three question types: `pick` (one of up to 255 options), `rate` (2 to 10 ordered levels) and `check` (yes or no).
- Built-in abstention: set `min_confidence` and an uncertain answer comes back with `abstained=True`.
- Sync and async clients, typed models, automatic retries that never charge twice.

Python 3.10 or newer. Dependencies: `httpx`, `pydantic` 2 and `anyio`.

## Install

```bash
pip install thinqit-dex
```

The package is coming soon to PyPI. Until then, install the interim build from the website (https://thinqit.ai/docs/reference/sdks/#downloads):
`pip install https://thinqit.ai/downloads/thinqit_dex-<version>-py3-none-any.whl`.

## Quickstart

Create a test key in the console (`dex_test_...`, free and rate-limited), then:

```bash
export DEX_API_KEY=dex_test_...
```

Route a support ticket: which team, how fast to reply, and is the writer angry? This is the request from the
[Quickstart](https://thinqit.ai/docs/quickstart/). The API contract's mock server answers it by default, so you can
also run it against a local mock (see "Try it against the mock server" below).

```python
from thinqit_dex import Client, check, pick, rate

client = Client()  # reads DEX_API_KEY

decision = client.decide(
    state={
        "ticket": {
            "channel": "email",
            "subject": "Charged twice, still no answer",
            "body": "This is the third time I am writing. You charged my card twice "
            "for order 4471 and nobody replies. Fix it today or I cancel my subscription.",
        }
    },
    questions={
        "team": pick(
            "Which team should handle {{ticket.body}}?",
            {
                "billing": "Payments, refunds and invoices",
                "technical": "Bugs, outages and sign-in problems",
                "shipping": "Delivery and returns",
                "other": None,
            },
            min_confidence=0.5,
        ),
        "urgency": rate(
            "How fast must we reply to {{ticket.body}}?",
            ["Can wait", "This week", "Within two working days", "Today"],
        ),
        "angry": check("Is the writer of {{ticket.body}} angry?", min_confidence=0.6),
    },
)

team = decision.pick("team")
if team.abstained:  # confidence below your min_confidence: let a person decide
    print("needs a person:", team.probabilities)
else:
    print("route to", team.choice)

print("urgency (0 = can wait, 3 = today):", decision.rate("urgency").rating)
print("angry:", decision.check("angry").probability)
print("served by", decision.served_by, decision.model, "request", decision.id)
```

`{{ticket.body}}` points the question at a field of the state. Dex does not paste the value into the question,
so the state is billed once however many questions share it.

### Try it against the mock server

The published API contract runs as a mock server with Prism (you need Node.js). It checks each request against the
contract and answers with the contract's example, which is the Quickstart ticket above:

```bash
npx @stoplight/prism-cli mock https://thinqit.ai/openapi.yaml
export DEX_BASE_URL=http://127.0.0.1:4010 DEX_API_KEY=dex_test_mock
```

Then run the quickstart: it prints `route to billing`. The mock does not run a model, so it gives the same answer
whatever you send; use it to test your request shape and your response handling.

## Questions

Each builder returns a plain dict in the wire format, so you can also write questions by hand or load them from
JSON.

```python
from thinqit_dex import check, escape_braces, pick, rate, ref

# pick: a mapping of label to description (or None), or a list of labels and (label, description) pairs.
# Order matters: the model sees options in this order and ties go to the earlier option.
stars = pick("How many stars does this review deserve?", ["5", "4", "3", "2", "1"])
team = pick("Which team?", [("billing", "Payments and refunds"), "technical", "other"])

# rate: levels from lowest (index 0) to highest.
urgency = rate("How urgent is this?", ["low", "normal", "high"], criteria="An outage for many users is high.")

# check: yes or no. The answer is the probability of yes.
spam = check("Is this message spam?", criteria=["Newsletters the user signed up for are not spam."])

# Field references into a JSON state, with brackets for keys that need them.
print(ref("ticket", "messages", 0, "text"))  # {{ticket.messages[0].text}}
print(ref("customer", "full name"))  # {{customer["full name"]}}
print(escape_braces("Treat {{this}} as text"))  # Treat \{{this}} as text
```

**Abstention.** When you set `min_confidence` and the answer's confidence is below it, the answer has
`abstained=True`. It still carries every field so you can log it, but code that acts on an abstained answer is a
bug. Send those cases to a person or a fallback rule.

## Answers

`decision.answers` maps each question id to its answer, in your question order. The typed accessors
`decision.pick(id)`, `decision.rate(id)` and `decision.check(id)` return the right model (and raise `KeyError` for
an unknown id or `TypeError` for another type).

| Type | Fields |
| --- | --- |
| `PickAnswer` | `abstained`, `choice`, `probabilities` (label to probability, summing to 1), `confidence` |
| `RateAnswer` | `abstained`, `rating` (probability-weighted level index), `levels`, `probabilities`, `confidence` |
| `CheckAnswer` | `abstained`, `probability` (of yes), `confidence` |

Every decision also names the exact `model` that answered, `served_by` (`gpu` or `fallback`), the `calibration`
version and the `usage` (billed input tokens and `charge_micro_cents`; 1 EUR is 100,000,000 micro-cents).

**Same answer every time.** On the GPU path, the same request to the same exact version returns the same answers.
The hosted fallback (`served_by="fallback"`) is not bit-identical. To stay on the GPU path, pin an exact version
(`model="dex-1.0.0"`) or pass `fallback="never"`.

## Sending a request body you already have

`decide` takes the same fields as the `POST /v1/decide` body, so a stored request works as it is:

```python
import json

body = json.loads(
    '{"state": "Mijn pakket is nog niet bezorgd.",'
    ' "questions": {"late": {"type": "check", "instructions": "Is het pakket te laat?"}}}'
)
decision = client.decide(**body)
print(decision.check("late").probability)
```

## Models, usage, balance and your key

```python
from datetime import date, timedelta

models = client.list_models()
print([m.id for m in models.data])

today = date.today()
report = client.get_usage(today - timedelta(days=7), today, granularity="day")
print(report.totals.input_tokens, report.totals.charge_micro_cents)

balance = client.get_balance()
print(balance.balance_eur, balance.tier)

key = client.get_key()
print(key.prefix, key.mode, key.scopes, key.account_id)
```

`get_key()` returns the API key making the call (`GET /v1/key`): its id, display prefix such as `dex_live_Q7mK`,
mode (`live` or `test`), scopes, account and creation time. It never returns the secret. Any valid key may call it,
so it needs no scope. Use it to check which key and account your code runs as.

## Configuration

| Argument | Environment variable | Default |
| --- | --- | --- |
| `api_key` | `DEX_API_KEY` | none (required) |
| `base_url` | `DEX_BASE_URL` | `https://api.thinqit.ai` |
| `timeout` | | 30 seconds per attempt (a number or an `httpx.Timeout`) |
| `max_retries` | | 3 |
| `default_headers` | | none |
| `http_client` | | a new `httpx.Client` (pass your own for proxies or custom TLS) |

The base URL must use `https://`, so your key is never sent in clear text. `http://` is accepted only for
`localhost`, `127.0.0.1` and `[::1]`, for a local mock server; any other `http://` address raises
`ConfigurationError`.

Every method (`decide`, `list_models`, `get_usage`, `get_balance` and `get_key`) also takes `timeout`,
`max_retries`, `extra_headers` and `client_request_id` per call. `decide` also takes `idempotency_key`.

`client_request_id` is your own correlation id, such as a ticket number (1 to 128 printable ASCII characters). The
client sends it as `x-client-request-id`, the API logs it with the request and sends it back, and you read it in
`result.http.client_request_id`:

```python
balance = client.get_balance(client_request_id="nightly-report-1182")
print(balance.http.client_request_id, balance.http.request_id)
```

## Retries and idempotency

The client retries 429, 500, 502, 503, 504, 409 `idempotency_in_progress` and network errors up to 3 times. It
does not retry 429 `test_daily_quota`: the free test tokens come back at 00:00 UTC, and `err.retry_after` says how
many seconds that is. It waits as long as `retry-after` says (at most 60 seconds), or otherwise uses exponential backoff with full jitter
from 250 ms. Every `decide` call sends one idempotency key (yours, or a new UUID) and reuses it on every retry, so
a retry is never charged twice. A replay of a completed request has `decision.http.idempotent_replayed == True`
and is not charged.

## Errors

Every error is a `DexError`. Errors from the API are `APIStatusError` subclasses, one per error type:

| HTTP | `err.type` | Class |
| --- | --- | --- |
| 400 | `invalid_request` | `InvalidRequestError` |
| 401 | `authentication` | `AuthenticationError` |
| 402 | `insufficient_balance` | `InsufficientBalanceError` (with `balance_micro_cents`, `required_micro_cents`) |
| 403 | `permission` | `PermissionDeniedError` |
| 404 | `not_found` | `NotFoundError` |
| 409 | `conflict` | `ConflictError` |
| 413 | `too_large` | `TooLargeError` |
| 422 | `validation` | `RequestValidationError` |
| 429 | `rate_limited` | `RateLimitError` |
| 500 | `internal` | `InternalServerError` |
| 503 | `unavailable` | `UnavailableError` |

Each one carries `status`, `type`, `code` (the stable reason), `message` (for people; do not parse it), `param`
(the field that caused it), `request_id`, `retry_after` and, for `decide`, the `idempotency_key` it used. Network
failures raise `APIConnectionError` (`APITimeoutError` for timeouts). A success that cannot be read raises
`ResponseParseError`.

```python
from thinqit_dex import DexError, RequestValidationError

try:
    client.decide("Is dit spam?", {"spam": check("Is dit spam?")})
except RequestValidationError as err:
    print("fix the request:", err.param, err.code, err.request_id)
except DexError as err:
    print("failed:", err)
```

Quote the request id (`decision.id`, `decision.http.request_id` or `err.request_id`) when you contact support.

## Async

`AsyncClient` has the same arguments and methods; await them. It works with asyncio and trio.

```python
from thinqit_dex import AsyncClient, check

async with AsyncClient() as dex:
    decision = await dex.decide("Where is my parcel?", {"late": check("Is the parcel late?")})
    print(decision.check("late").probability)
```

## Logging and your key

The client logs retries and responses on the `thinqit_dex` logger at DEBUG level. It never logs headers or request
content, and the API key does not appear in logs, error messages, `repr()` output or tracebacks.

## Names

"thinQit Dex", the `thinqit-dex` package, the `thinqit_dex` import name and `https://api.thinqit.ai` are decided,
but stay subject to the trademark check (launch item L-01). The package name is defined only in `pyproject.toml`,
the import name only by the `src/thinqit_dex` directory, and the default base URL only in
`src/thinqit_dex/_config.py`.

## Development

```bash
python3 -m venv .venv
.venv/bin/pip install -e . --group dev
.venv/bin/pytest                      # unit and contract-mock tests
.venv/bin/mypy src tests/typing_usage.py
.venv/bin/ruff check src tests

# Contract suite against Prism, started from api/openapi.yaml
npx @stoplight/prism-cli@5 mock ../../api/openapi.yaml --errors -p 4010 &
DEX_PRISM_URL=http://127.0.0.1:4010 .venv/bin/pytest tests/test_prism.py

# Live smoke tests (test keys only)
DEX_LIVE_BASE_URL=https://... DEX_LIVE_TEST_KEY=dex_test_... .venv/bin/pytest tests/test_live.py
```

## Licence

Apache-2.0, copyright 2026 Thinqit B.V. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
