Metadata-Version: 2.4
Name: qunivex
Version: 2.0.0
Summary: Official Python SDK for the Qunivex API — run AI agents, semantic search, and manage projects.
Project-URL: Homepage, https://qunivex.com
Project-URL: Documentation, https://qunivex.com/docs/api
Project-URL: API Keys, https://qunivex.com/api-keys
Author-email: Qunivex <support@qunivex.com>
License: MIT
License-File: LICENSE
Keywords: agents,ai,chatbot,llm,openai-compatible,qunivex,rag,semantic-search,vector-search
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
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 :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.8
Requires-Dist: requests>=2.25.0
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: responses>=0.23; extra == 'dev'
Description-Content-Type: text/markdown

# qunivex

Official Python SDK for the [Qunivex](https://qunivex.com) API — run your AI agents, search your knowledge base, and manage your projects, agents, tools, guardrails and documents from Python.

```bash
pip install qunivex
```

```python
from qunivex import Qunivex

qx = Qunivex(api_key="qvx-live-...")

print(qx.chat("QXA-8MQ7KN2A", "What plans do you offer?").content)
```

Create a key on the [API page](https://qunivex.com/api-keys) in your dashboard. Full reference: **[qunivex.com/docs/api](https://qunivex.com/docs/api)**

---

## `model` is an agent, not a foundation model

This is the one thing to know before you start. A Qunivex **agent** is a whole configured pipeline — system prompt, knowledge base, tools, MCP servers, sub-agents, guardrails, workflow — built in your dashboard. *Which* LLM runs inside it is the agent's own setting, not something the caller picks.

So `model` takes a `QXA-` agent id. A `QXP-` project id also works and resolves to that project's main agent.

| Prefix  | Names                       | Example         |
| ------- | --------------------------- | --------------- |
| `QXP-`  | Project                     | `QXP-1DM2J8K0`  |
| `QXA-`  | Agent (main or sub-agent)   | `QXA-8MQ7KN2A`  |

Both are on your project's Overview page.

## Setup

```python
qx = Qunivex(api_key="qvx-live-...")
```

Or set `QUNIVEX_API_KEY` in the environment and pass nothing — worth preferring, since a key in source is a key in version control.

```python
qx = Qunivex()                                  # reads QUNIVEX_API_KEY
qx = Qunivex(timeout=60, max_retries=4)         # tune transport
with Qunivex() as qx: ...                        # closes the HTTP session
```

Rate limits and transient server errors are retried automatically with exponential backoff and jitter, honouring `Retry-After`. A *quota* 429 is not retried — waiting will not help.

## Chat

```python
reply = qx.chat("QXA-8MQ7KN2A", "What plans do you offer?")

reply.content            # the text
reply.total_tokens       # usage for this turn
reply.blocked            # True if a guardrail stopped it
reply.raw                # the untouched API payload
```

Stream it:

```python
for piece in qx.stream("QXA-8MQ7KN2A", "Tell me about Pro"):
    print(piece, end="", flush=True)
```

### Multi-turn

The API is **stateless** — every request carries the full history, exactly like OpenAI's. That makes each call reproducible and leaves the context window under your control.

Manage the transcript yourself:

```python
history = [{"role": "user", "content": "What plans do you offer?"}]
r = qx.chat(AGENT, history[-1]["content"])
history.append({"role": "assistant", "content": r.content})
```

…or let a `Conversation` do it:

```python
convo = qx.conversation("QXA-8MQ7KN2A", system="Be concise.")

convo.send("What plans do you offer?")
convo.send("Which suits two people?")     # remembers the first turn

convo.messages          # the running transcript — mutable, trim it if you like
convo.reset()           # start over, keeping the system message
```

A `Conversation` also generates one `conversation_id` and sends it with every turn, so the whole exchange is grouped together in your dashboard's Logs.

### Your own functions

Your agent's own tools — web search, custom tools, MCP servers — run on our side as part of the turn. This is the other kind: a function that only exists in *your* code. Looking up an order in your database, charging a card, checking stock.

Declare it with `tools` exactly as you would with OpenAI. When the model calls it, the turn stops and hands you the call:

```python
GET_ORDER = {"type": "function", "function": {
    "name": "get_order",
    "description": "Look up an order by its number.",
    "parameters": {"type": "object",
                   "properties": {"order_id": {"type": "string"}},
                   "required": ["order_id"]},
}}

reply = qx.chat(AGENT, "Where is order A-4471?", tools=[GET_ORDER])

if reply.needs_tools:
    call = reply.tool_calls[0]
    print(call.name, call.arguments)      # get_order {'order_id': 'A-4471'}
```

Run it, hand the result back, and the model answers with it:

```python
messages = [{"role": "user", "content": "Where is order A-4471?"}]
reply = qx.chat(AGENT, messages, tools=[GET_ORDER])

while reply.needs_tools:
    messages.append(reply.message)                 # the assistant's request
    for call in reply.tool_calls:
        messages.append(call.result(my_lookup(**call.arguments)))
    reply = qx.chat(AGENT, None, history=messages, tools=[GET_ORDER])

print(reply.content)
```

Or skip the loop entirely — give a `Conversation` some handlers:

```python
convo = qx.conversation(AGENT, tools=[GET_ORDER],
                        handlers={"get_order": my_lookup})

print(convo.send("Where is order A-4471?").content)
```

A handler that raises is reported back to the model rather than propagating — it can recover by answering without the tool, and raising would strand a transcript holding an unanswered call. The loop is bounded by `max_tool_rounds` (default 5).

**We never run your function.** You send a schema, not code; the call comes back to you and the turn ends there.

### Raw OpenAI shape

When you need a field the wrappers do not surface:

```python
resp = qx.completions.create(
    model="QXA-8MQ7KN2A",
    messages=[{"role": "user", "content": "Hello"}],
    temperature=0.2,
)
resp["choices"][0]["message"]["content"]
```

## Semantic search

Retrieval only — no model call, so it is far cheaper and faster than a completion. Ideal for adding search to your own site over content already in Qunivex.

```python
for hit in qx.search("QXP-1DM2J8K0", "refund policy", top_k=3):
    print(round(hit.score, 3), hit.source, hit.text[:100])
```

`score` is a relevance figure in `(0, 1]` — higher is better. Pass `agent=` to search a sub-agent's own knowledge base instead of the project's.

If the embedding provider is down this raises `ServiceUnavailableError` rather than returning an empty list, so "nothing matched" and "search is broken" never look the same.

## Reads are properties, not calls

`qx.models`, `qx.usage`, `qx.tools`, `project.documents`, `reply.blocked`, `doc.ready` — no parentheses. Parentheses in this SDK mean the call does work you might want to control: `.list(limit=…)`, `.upload(path)`, `.create(…)`.

## Projects

```python
for p in qx.projects:                  # iterating pages automatically
    print(p.id, p.name)

page = qx.projects.list(limit=10)      # one page: a list, plus .has_more
everything = qx.projects.all           # all of them, as a list

project = qx.projects.create("Acme Support", website_url="https://acme.com")
project = qx.projects.retrieve("QXP-1DM2J8K0")

project.name                        # 'Acme Support'
project.main_agent.system_prompt
project.sub_agents                  # [Agent, ...]
project.knowledge.documents         # 12

project.update(
    name="Acme Support",
    main_agent={"system_prompt": "Be concise and always cite a source.",
                "temperature": 0.3},
)

project.chat("What plans do you offer?")     # via the main agent
project.search("refund policy")
project.delete()                             # not recoverable
```

`create` gives you a working project in one call: a main agent with a widget key already minted, the default workflow wired up, and a knowledge base ready to fill. Optional fields: `model`, `agent_name`, `system_prompt`, `temperature`, `max_tokens`, `website_url`, `website_name`, `website_description`, `target_audience`, `tone_style`, `routes`, `widget_enabled`.

Every list resource follows the same shape — `.list(limit, offset)`, `.all`, and plain iteration.

## Agents

A project has one **main agent** (what your widget talks to) and any number of **sub-agents** it can delegate to.

```python
for a in project.agents:
    print(a.id, a.name, "main" if a.is_main_agent else "sub")

billing = project.agents.create(
    "Billing",
    description="Invoices, payment methods, refunds and plan changes.",
    system_prompt="You answer billing questions precisely.",
)

project.main_agent.update(
    system_prompt="Be concise and always cite a source.",
    tools={"builtin": ["web_search"], "custom": [tool.id]},
    sub_agents=[billing.id],
    guardrails=[rail.id],
    allowed_domains=["acme.com"],
)

billing.chat("How do refunds work?")
billing.delete()
```

`description` matters more than it looks: the main agent reads it to decide when a question belongs to this specialist.

The capability lists (`builtin_tools`, `custom_tools`, `mcp_servers`, `sub_agents`, `guardrails`) come from the agent's saved **workflow**, so what you read is what will actually run. Setting a non-empty `sub_agents` list turns delegation on.

## Knowledge base

A knowledge base holds two kinds of thing: **documents** you upload, and the **Website Info** Qunivex writes by reading your site. Both are embedded into the same store and retrieved the same way.

```python
docs = project.documents

docs.upload("handbook.pdf")
docs.add_text("faq.txt", "Q: Do you ship internationally? A: Yes…")

for d in docs.list():
    print(d.filename, d.status, d.chunks, d.tokens)

docs.delete(doc_id)
```

Indexing happens before the response returns, so the `Document` you get back already carries its final `status` and chunk count — nothing to poll. Supported: PDF, DOCX, TXT, MD, HTML, JSON, XML, RTF, YAML and other plain-text formats.

For a file whose extraction might outlive your HTTP timeout, use `wait=False` and poll:

```python
doc = docs.upload("500-page-manual.pdf", wait=False)
while doc.processing:
    time.sleep(2)
    doc = docs.retrieve(doc.id)
```

Uploads count against your plan's knowledge-base token budget.

### The AI-fetched docs

Point Qunivex at your site and it reads the pages you choose, writing a factual knowledge-base section from each. For most agents this ends up being the largest single thing they know.

```python
info = project.website_info

info.generate(website_url="https://acme.com", routes=["/", "/pricing", "/faq"])
info.status                 # 'generating' → 'ready'

# …or block until it finishes
info.generate(routes=["/", "/pricing"], wait=True)
print(info.content)

info.set("# Acme\n\nAcme sells widgets.")   # write your own instead
info.clear()
```

It is not a file, so it does not appear in `documents.list()` by default. Ask for it explicitly:

```python
for d in docs.list(include_website_info=True):
    print(d.kind, d.filename, d.tokens)     # 'website_info' / 'file'
```

At most 5 routes are read per run; extra ones are ignored rather than rejected.

### Sub-agent knowledge bases

Every sub-agent has its own, separate from the project's:

```python
billing.documents.upload("invoicing-policy.pdf")
billing.website_info.set("# Billing\n\nWe bill monthly in arrears.")

qx.search(project.id, "when are invoices sent", agent=billing.id)
```

## Tools, guardrails & MCP

What an agent can *do*. Create these on the project, then switch them on for an agent — creating one does not enable it anywhere, which is what lets you build and test before anything goes live.

```python
for t in qx.tools:                       # the built-in tools we ship
    print(t.name, "—", t.description)

# A tool you write, run on our side. Unlike your own functions above, this is
# part of the agent everywhere it is deployed — including your website widget,
# where there is no code of yours to call.
stock = project.tools.create(
    "check-stock", type="http",
    description="Checks whether a product is in stock.",
    config={"method": "GET",
            "url": "https://api.acme.com/stock/{{sku}}",
            "parameters": [{"name": "sku", "type": "string", "required": True}]},
)

rail = project.guardrails.create(
    "No card numbers", subtype="regex",
    position="input", action="block",
    message="Please don't share card details here.",
    config={"subtype": "regex", "pattern": r"\b(?:\d[ -]*?){13,16}\b"},
)

server = project.mcp_servers.create(
    "Internal Docs", "https://mcp.acme.com/v1",
    transport="http", auth_type="bearer", auth_token="…",
)
print(server.connected, server.tool_count)

project.main_agent.update(tools={"custom": [stock.id], "mcp_servers": [server.id]},
                          guardrails=[rail.id])
```

A blocked message comes back as a normal reply with `reply.blocked` set, carrying the guardrail's `message` — not an error.

MCP credentials are **write-only**: `auth_token` and custom headers are accepted but never read back. `server.has_auth` tells you whether one is stored, and omitting `auth_token` on an update keeps the existing one. `project.mcp_servers.refresh(id)` re-discovers a server's tools, which is needed when a server *adds* one — specs are cached so chat opens no connection to build them.

## Activity logs

Every chat turn, tool call and error your agents produced — the same record the dashboard's Logs page reads.

```python
for row in project.logs.list(source="public", type="chat", limit=20):
    print(row.created_at, row.total_tokens, row.summary)

for row in project.logs.conversation("conv-7f21"):   # oldest first
    print(row.type, row.summary)
```

Filters: `source` (`public` / `test` / `api`, or a list), `type`, `status`, `agent`, `conversation_id`, `since` (a string or a `datetime`).

`tokens_input` counts the whole prompt the model saw — system prompt, tool schemas, retrieved knowledge, history — not just the message, so it is usually far larger than `tokens_output`.

## Discovery & usage

```python
for m in qx.models:
    print(m.id, m.name, m.project_name, m.model_name)

u = qx.usage
print(f"{u.api_calls_used}/{u.api_calls_limit} calls, {u.api_calls_remaining} left")

qx.rate_limit
# {'limit': 120, 'remaining': 118, 'reset': 43,
#  'quota_limit': 25000, 'quota_remaining': 24870}
```

`models`, `tools` and `usage` do not count against your quota. `rate_limit` reflects the last response, so you can pace ahead of the burst limit instead of discovering it by being refused — the two budgets are on different clocks, and waiting only helps with the burst one.

## Errors

```python
from qunivex import (
    QunivexError, AuthenticationError, PermissionDeniedError,
    NotFoundError, RateLimitError, QuotaExceededError,
)

try:
    qx.chat(AGENT, "hello")
except QuotaExceededError:
    ...                     # monthly allowance spent — upgrade or wait
except RateLimitError as e:
    ...                     # burst limit; e.retry_after has the wait
except AuthenticationError:
    ...                     # bad or revoked key
except QunivexError as e:
    print(e.status, e.code, e.message)
```

Every error subclasses `QunivexError`, so that last clause is a complete catch.

`NotFoundError` is also what you get for a project this key was **scoped away from** — the API does not distinguish the two, so key scoping cannot be used to probe what sits behind it.

`ConflictError` (409) means the resource exists but is in the wrong state — a disabled agent, or an attempt to delete a project's main agent.

## Upgrading from 1.x

Three breaking changes, all in the same direction — reads became properties:

| 1.x            | 2.x          |
| -------------- | ------------ |
| `qx.models()`  | `qx.models`  |
| `qx.usage()`   | `qx.usage`   |
| `AgentInfo`    | `Agent` (the old name still imports and is the same class) |

Everything else is additive: `.list()` now returns a `Page` that is still a plain list, and `project.documents.list()` returns the same documents it always did unless you ask for `include_website_info=True`.

## Keys & security

- Keys look like `qvx-live-` plus 28 characters, and the full value is shown **once**, when you create it. Only a hash is stored, so it cannot be recovered — lose it, revoke it, mint a new one.
- A key belongs to your **account**, and can optionally be narrowed to specific projects (or to none) when you create or edit it.
- A key carries the full rights of your account within its scope, including editing an agent's prompt and deleting documents. **Never ship one to a browser, a mobile app, or a public repo.** For a chat widget on a website, use the embed snippet on your project's Deployments page — that uses a separate, domain-locked public key designed to be visible.

## Requirements

Python 3.8+ and `requests`. That is the whole dependency list.

## License

MIT
