Metadata-Version: 2.5
Name: intura-ai
Version: 3.0.0
Summary: Download the lightweight fine-tuned Intura AI models you own and run them on your own CPU: one call to load, one to predict.
Project-URL: Homepage, https://ai.intura.co
Project-URL: Documentation, https://ai.intura.co/docs
Project-URL: Repository, https://github.com/intura-co/intura-ai
Project-URL: Bug Tracker, https://github.com/intura-co/intura-ai/issues
Project-URL: Account, https://ai.intura.co/account
Project-URL: Help, https://ai.intura.co/help
Author-email: Intura <developer@intura.co>
License: MIT
License-File: LICENSE
Keywords: cpu-inference,fine-tuned-models,indonesia,intent-classification,onnx,phishing,pii,prompt-injection,text-classification,token-classification
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Natural Language :: English
Classifier: Natural Language :: Indonesian
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
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: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: Security
Classifier: Topic :: Text Processing :: Linguistic
Classifier: Typing :: Typed
Requires-Python: <3.14,>=3.10
Requires-Dist: httpx<1,>=0.27
Requires-Dist: numpy>=1.26
Requires-Dist: onnxruntime<1.24,>=1.18; python_version < '3.11'
Requires-Dist: onnxruntime>=1.18; python_version >= '3.11'
Requires-Dist: tokenizers>=0.19
Provides-Extra: local
Description-Content-Type: text/markdown

![intura-banner](./assets/images/intura.jpg)

# intura-ai

[![PyPI version](https://badge.fury.io/py/intura-ai.svg)](https://badge.fury.io/py/intura-ai)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](./LICENSE)

Run the [Intura AI](https://ai.intura.co) models you own on your own CPU.

Intura makes lightweight fine-tuned models for specific tasks, like routing a
customer-support ticket to the right team or tagging what a social post is
asking for. You buy a model once, which gets you every version
of it. This package downloads the model and runs it in your process. After the
download there is no network call, no per-request price, and your text never
leaves your machine.

```python
from intura_ai import Intura

model = Intura().models.load("chat-customer-support-ticket-en")
model.predict("I was charged twice, please refund me")
# {'labels': ['billing_payment', 'refund_return'],
#  'scores': {'billing_payment': 0.6956, 'refund_return': 0.8482, ...}}
```

## Install

```bash
pip install intura-ai
# or
uv add intura-ai
```

Python 3.10–3.13. This installs everything a prediction needs: `onnxruntime`
(CPU), `tokenizers`, `numpy` and `httpx` for the download. The old `local`
extra still resolves, so `pip install "intura-ai[local]"` keeps working, but it
adds nothing.

## Get a download key

Get a model from <https://ai.intura.co/models>, then create a **download key**
on your [account page](https://ai.intura.co/account). Give it to the client as
an argument, or in `INTURA_API_KEY`:

```bash
export INTURA_API_KEY=sk_...
```

```python
from intura_ai import Intura

client = Intura()                  # reads INTURA_API_KEY
client = Intura(api_key="sk_...")  # or pass it explicitly
```

The key is only used to download. `Intura()` works without one, and so does
loading a model that is already downloaded. The first call that needs the API
raises `MissingAPIKey` and tells you where to get a key.

## Load a model, then predict

```python
model = client.models.load("chat-customer-support-ticket-id")

model.predict("kak, saya sudah bayar tapi pesanan belum dikirim")
# {'labels': ['order_delivery'], 'scores': {'billing_payment': 0.0056, 'order_delivery': 0.9932, ...}}

[r["labels"] for r in model.predict_batch(["tidak bisa login, kode OTP tidak masuk", "terima kasih kak"])]
# [['account_access'], []]
```

On first use, `load` downloads the model into a local cache. After that it
loads from disk in well under a second. Load once per process and keep the
model. A loaded model holds no connection and no key, and you can share it
across threads.

`predict_batch(texts)` gives the same result as `[model.predict(t) for t in texts]`,
in the same order. It runs **one text per forward pass on purpose.** The
published models are INT8 graphs with dynamic quantization, which scales each
activation tensor by its own range. A padded batch is one tensor, so batching
would shift every item's logits, sometimes by more than a whole logit, and that
can flip a decision near a cut. For throughput, call `predict_batch` from
several threads (onnxruntime releases the GIL while it computes).

You can find each model's slug in `client.library.list()`, on its page on the
website, or by running `intura-ai library`.

## What `predict` returns

Each model carries its own decision rule and cut-offs in its `serving.json`.
`predict` applies them, so a new version that moves a threshold gives the right
answer as soon as it is downloaded. These are the same five rules as the
plain-Python script on each model page:

| Rule (`decision`) | Models | `predict(text)` returns |
|---|---|---|
| Multi-label (`sigmoid-per-label`) | customer-support ticket (id, en), Threads post intent | `{"labels", "scores"}`. Every label whose own sigmoid reaches `threshold` (or that label's entry in `thresholds`, when the model has one), plus a score for each label. The scores do not sum to one. No label past the cut gives `"labels": []`: for a support ticket, a message that asks for nothing. |
| Argmax with fallback (`argmax-with-fallback`, `argmax`) | — | `{"label", "score"}`. The top class and its probability, or the model's fallback label (e.g. `"other"`) when the top class scores below `threshold`. |
| One positive class (`positive-class`) | — | `{"label", "score"}`. `score` is always the positive class's probability. `label` is the positive class at or above `threshold`, otherwise the other label. |
| Risk sum with bands (`risk-sum-with-bands`) | — | `{"label", "decision", "score"}`. `score` is the summed probability of the risk classes. `decision` is `"block"` at or above `threshold`, `"review"` at or above `review_threshold`, otherwise `"allow"`. |
| Token classification | — | A list of `{"type", "start", "end", "value"}` spans, in text order. `text[start:end] == value`. |

The rule comes from the model's own files, never from its name, so a model
released after your version of this package still works. A `decision` name the
package has never seen is read from the keys the model carries instead
(`positive_label`, `risk_labels`, otherwise the top class).

Scores are rounded to four decimals. To apply a rule of your own, such as a
stricter cut for one label, `model.scores(text)` gives every label's unrounded
probability with no cut applied. `model.labels`, `model.serving` (the whole
`serving.json`), `model.task` and `model.version` are there too.

```python
posts = client.models.load("chat-threads-post-intent-id")
posts.predict("kecewa banget, paket udah seminggu belum sampai")["labels"]
# ['complaint', 'review_experience']

scores = posts.scores("rekomendasi sunscreen buat kulit berminyak dong")
[label for label, p in scores.items() if p >= 0.9]
# ['ask_recommendation']
```

## Versions and the cache

Buying a model gets you every version of it. `load` decides which one to use
like this:

| Call | What it loads | Network |
|---|---|---|
| `load(slug)` | The newest version already in the cache. If none is cached, the recommended version, downloaded first. | Only when nothing is cached |
| `load(slug, version="v1.0")` | Exactly that version, downloaded if it is missing | Only when that version is missing |
| `load(slug, refresh=True)` | Asks which version is recommended now and downloads it if you don't have it | Always (one small call, plus the download if the version is new) |

So once a model is cached, `load(slug)` works offline and without a key.
Nothing updates behind your back. Run with `refresh=True` (or
`intura-ai predict --refresh`) when you want the latest. Older versions stay in
the cache, so you can pin back to one with `version=`.

The cache is laid out `<cache>/<slug>/<version>/`. Its location is the first of
these that is set:

1. `load(..., cache_dir="./models")`
2. `$INTURA_CACHE_DIR`
3. `$XDG_CACHE_HOME/intura`
4. `~/.cache/intura`

A partly downloaded model is never mistaken for a complete one. Every download
goes into a hidden staging folder next to its destination. That folder is
renamed to `<version>/` only after every file has arrived at the size the bucket
reports. If a download is interrupted (dropped connection, Ctrl-C, full disk),
no version folder is left behind, and the next `load` starts again. To free
space, delete a version folder, or the whole cache.

To put a model in a folder of your own choosing, for example in a container
image or on a machine with no internet access, use `download`, then load that
folder:

```python
from intura_ai import LocalModel

path = client.models.download("chat-customer-support-ticket-id", "./models", version="v1.0")
# ./models/chat-customer-support-ticket-id/v1.0/
model = LocalModel.load(path)
```

`download` is atomic in the same way. It keeps a complete bundle that is already
there, unless you pass `overwrite=True`. `client.models.links(slug)` returns the
signed URLs without downloading anything, if you want to use your own
downloader.

The download key only signs the links. It authenticates one call to
`POST /v1/platform/downloads/models/{slug}/download`, which answers with GET URLs
on Cloud Storage that are valid for 15 minutes. The files come straight from
Cloud Storage, and the key is never sent there. If a link expires partway
through a download, it is re-signed once.

## Your library

```python
for item in client.library.list():
    print(item.code, item.kind, item.current_version, item.versions)
# chat-customer-support-ticket-id  model     v1.0  ('v1.0',)
# template-support-reply       template  None        ()
```

This is the same list as the library page on the website: models, templates,
courses and apps. For a model, `code` is the slug you pass to `load`, and
`versions` lists every build you can pin. Everything the service sent is also
on `item.data`. `client.models.list()` lists the downloadable models, with the
recommended `version` of each.

## Async

```python
from intura_ai import AsyncIntura

async with AsyncIntura() as client:
    model = await client.models.load("chat-customer-support-ticket-id")

model.predict("ongkirnya berapa?")
```

Only the API calls and the download are awaited. Reading the model from disk
happens once and is synchronous, and `predict` is the same CPU-bound
`LocalModel` method as in the sync client. Inside an event loop, run a large
batch with `await asyncio.to_thread(model.predict_batch, texts)`.

## CLI

```bash
$ export INTURA_API_KEY=sk_...
$ intura-ai library                                   # what your account owns
$ intura-ai predict chat-customer-support-ticket-en "I was charged twice, please refund me"
$ intura-ai predict chat-customer-support-ticket-id "paket belum sampai" "mau refund" --field labels
$ cat posts.txt | intura-ai predict chat-threads-post-intent-id --field labels
$ intura-ai predict ./models/chat-customer-support-ticket-id/v1.0 "..."  # a downloaded folder
$ intura-ai predict chat-threads-post-intent-id "..." --refresh          # fetch the newest
$ intura-ai download chat-customer-support-ticket-id --dir ./models --version v1.0
$ intura-ai models                                    # what you may download
```

`predict` loads the model the same way `client.models.load` does, so the first
run downloads it and later runs work offline. With one text, it prints the
result as JSON. With several texts, given as arguments or one per line on stdin,
it prints one compact JSON value per line. `--field labels` (or `label`,
`decision`, `0.type`, ...) prints a single value instead. Errors exit non-zero with one line
on stderr.

## Errors

Everything this package raises descends from `InturaError`. Once a model is
loaded, `predict` raises nothing from this list, because it runs in your
process.

| Exception | When |
|---|---|
| `MissingAPIKey` | A download was needed and no key is set |
| `AuthenticationError` | The key is wrong or revoked |
| `NotFound` | A model you don't own, or a version that doesn't exist |
| `RateLimited` | Too many calls in a short time. Retried for you first, and `retry_after` says when to try again |
| `TransferError` | Cloud Storage refused or dropped a file. Nothing was cached, so it's safe to retry |
| `APIConnectionError` / `APITimeoutError` | The API could not be reached. A model already in the cache still loads |

`LocalModel.load` raises `FileNotFoundError` for a folder that isn't a
downloaded model.

## Configuration

| Setting | Argument | Environment |
|---|---|---|
| Download key | `Intura(api_key=...)` | `INTURA_API_KEY` |
| Cache folder | `load(..., cache_dir=...)` | `INTURA_CACHE_DIR`, then `XDG_CACHE_HOME` |
| API root | `Intura(base_url=...)` | `INTURA_BASE_URL` (default `https://ai.intura.co/api`) |

For a proxy or a custom transport, bring your own httpx client. The package sets
the URL and headers on each request, and never closes a client it did not open:

```python
client = Intura(http_client=httpx.Client(proxy="http://proxy:3128"))
```

## Upgrading from 2.x

3.0 is only "download a model you own and run it". **The hosted endpoints are
removed**, along with their result types:

| 2.x | 3.0 |
|---|---|
| `client.guardrails.pii_detection.mask(text)` | `client.models.load("guardrails-pii-detection-id").predict(text)` returns the spans; mask them yourself |
| `client.guardrails.prompt_injection.detect(text=...)` | `client.models.load("guardrails-prompt-injection-id").predict(text)` |
| `client.fraud.chat_scam.detect(message)` / `email_phishing.detect(body)` | `load("fraud-chat-scam-id")` / `load("fraud-email-phishing-en")`, then `.predict(text)` |
| `client.chat.commerce_intent.classify(message)` | `client.models.load("chat-commerce-intent-id").predict(message)` |
| `client.documents.*`, `client.storage.upload` | removed, no local successor yet |
| `Inference`, `PromptInjectionVerdict`, `PIIResult`, `PhishingVerdict`, `ScamVerdict`, `IntentResult`, `DocumentVerdict`, `Extraction`, `EntitySpan` | removed. `predict` returns plain dicts (see [What `predict` returns](#what-predict-returns)) |
| `verdict.thresholds`, `thresholds=` per call | The cut-offs are in `model.serving`; apply your own rule to `model.labels` if you need different ones |
| `client.verify_key()`, `client.info()`, `client.health()`, `Intura.endpoints()` | removed. `client.library.list()` checks a key |
| `QuotaExceeded`, `PayloadTooLarge`, `UnsupportedMediaType` | removed |
| `Intura()` without a key raised | constructs. The first call that needs the key raises |
| `pip install "intura-ai[local]"` | `pip install intura-ai` (the extra is now empty) |
| CLI `run`, `info`, `health`, `verify`, `endpoints` | `intura-ai predict`, `library`, `models`, `download` |

`client.models.list()`, `links()`, `download()`, `client.library.list()` and
`LocalModel.load(path).predict(text)` work as they did in 2.2. Pin
`intura-ai<3` if you still need the 2.x client while you move.

## Links

- Models: <https://ai.intura.co/models>
- Account and download keys: <https://ai.intura.co/account>
- Issues: <https://github.com/intura-co/intura-ai/issues>

## Help

- Help center: <https://ai.intura.co/help>
- Discord community: <https://discord.gg/mztkZSmJuF>
- Email: hello@intura.co

MIT licensed.
