Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ jobs:
# what is coming.
python: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
cache: pip
Expand All @@ -32,8 +32,8 @@ jobs:
runs-on: ubuntu-latest
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ jobs:
# provenance on the Node SDK.
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- run: pip install -e ".[dev]" build
Expand Down
2 changes: 1 addition & 1 deletion CODE_OF_CONDUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ professional setting.

## Enforcement

Report unacceptable behaviour to **conduct@klavi.ai**. All complaints will be
Report unacceptable behaviour to **conduct@klv.ai**. All complaints will be
reviewed and investigated promptly and fairly, and the privacy and security of
the reporter will be respected.

Expand Down
50 changes: 36 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ with experts.conversations.send(conversation.uid, "Hello") as stream:
match event.type:
case "token": print(event.content, end="", flush=True)
case "thinking": ... # reasoning tokens, when the model exposes them
case "action": ... # "rag_search", "tool_call" — for a status line
case "queued": ... # waiting in line for another mesh server's model
case "done": print(event.usage, event.source_docs)
```

Expand All @@ -80,12 +80,16 @@ result.source_docs # documents retrieved, with similarity scores
result.tools_used # e.g. ["web_search"]
result.usage.total_ms # milliseconds (the API speaks nanoseconds)
result.error # set when generation failed inside a 200 — always check
result.model # the model that answered
result.data_artifacts # tables, charts and sheet edits a tool produced
```

On an [Experts Mesh](https://klv.ai/mesh), an answer can come from another server's model. `result.served_by` names that server; `result.mesh_fallback` says when the expert's own model was out of reach and this server's answered instead. While a busy server's model has the request in line, the stream sends `queued` events (`event.ahead`, `event.name`) and nothing else. Raise `stream_timeout` on the client if yours wait longer than five minutes.

### Stopping

```python
stream.cancel() # stops the model
stream.cancel() # stops the model; True if the server found it running
stream.detach() # stop reading; let it finish and persist
```

Expand All @@ -105,38 +109,53 @@ return {"token": session.token}

The browser then talks to the install directly, using [`@klv-ai/experts/browser`](https://www.npmjs.com/package/@klv-ai/experts) or plain `fetch`. Your server is involved once, to mint — you are not proxying every token.

**The expert must be guest-visible.** A session token carries the guest role, and an expert above that floor resolves to nothing server-side — the chat then answers on the site's default model with no persona and no knowledge, silently, with a normal 200. `sessions.create()` refuses such an expert up front and tells you how to fix it. `experts.experts.list_guest_visible()` gives you the ones that will work.
**The expert must be guest-visible**: minimum role Guest, not private, and not limited to org profiles. A session token carries the guest role, so `sessions.create()` refuses any other expert up front and tells you how to fix it. `experts.experts.list_guest_visible()` gives you the ones that will work.

**On an Experts Mesh, the browser talks to the server you minted on.** A session lives on the server that issued it; another member of the mesh does not know it and answers 401.

## Knowledge

```python
collections = experts.knowledge.list_collections()

with open("policy.pdf", "rb") as f:
doc = experts.knowledge.upload(f, collection=collections[0].uid, filename="policy.pdf")
job = experts.knowledge.upload(f, collection=collections[0].uid, filename="policy.pdf")

# Uploading is asynchronous: the file is accepted, then split, embedded and
# indexed by a worker. It is NOT searchable until that finishes.
experts.knowledge.wait_for_processing(doc.uid)
# Uploading is asynchronous: the file is accepted into an indexing job, then
# split, embedded and indexed by a worker. It is NOT searchable until that
# finishes — and the job is where the document's uid and any failure appear.
status = experts.knowledge.wait_for_processing(job)
if not status:
print(status.failed) # e.g. a password-protected PDF, and why

hits = experts.knowledge.search("refund policy")
for hit in experts.knowledge.search("refund policy", collections=[collections[0].uid]):
print(hit.document_name, hit.similarity, hit.text)
```

`search()` returns passages, closest first, each with the document it came from. `list_documents(collection=..., search=...)` lists and filters documents by name, title or summary; `delete_document(uid)` removes one. Allow longer than the two-minute default for large files, and much longer for recordings, which are transcribed before they become documents.

## Errors

```python
from klavi_experts import RateLimitError, LicenseError
from klavi_experts import ExpertNotAvailableError, LicenseError, RateLimitError

try:
experts.conversations.ask("hi").text()
experts.conversations.ask("hi", expert=EXPERT_UID).text()
except RateLimitError as e:
time.sleep(e.retry_after or 5)
except LicenseError:
# The INSTALL's licence has lapsed. Nothing about your request is wrong and
# no retry will help — its administrator has to renew.
except ExpertNotAvailableError:
# This key's user may not use that expert (its role, privacy or org
# profiles). Pick another from experts.experts.list().
...
except LicenseError as e:
# The INSTALL's licence is not valid. Nothing about your request is wrong
# and no retry will help — its administrator has to act. e.read_only is
# True for a recently expired licence: reads still work, writes do not.
...
```

For 30 days after its licence expires, an install is read-only: anything sent as a GET still works, and everything else raises `LicenseReadOnlyError` (a `LicenseError`). That includes sending a message and `knowledge.search()`, which read but go out as a POST.

429 and 5xx are retried automatically with backoff that honours `Retry-After`. A 4xx never is. Neither is a chat turn — retrying one bills twice and can produce two answers.

## Things worth knowing
Expand All @@ -152,6 +171,9 @@ These are the parts of the API that surprise people. The SDK handles each of the
| **404 can mean "forbidden"** | A conversation you may not see answers 404 so a uid probe reveals nothing. The SDK does not guess which it was. |
| **Casing is mixed by design** | Conversation rows are camelCase; message and expert rows are snake_case; knowledge responses are wrapped in an envelope. All normalised. |
| **Streaming is plain HTTP** | NDJSON over `POST`. No WebSocket is required for chat, contrary to some older notes. |
| **A refused turn leaves a placeholder behind** | `/chat` updates a message row you created first. When it refuses to start, nothing settles that row, and the conversation shows the reply "typing" forever. The SDK settles it with the reason. |
| **An expert's instructions are for its managers** | Since 1.1, `Expert.instructions` is only filled in for the expert's owner and administrators. |
| **Knowledge says "forbidden" as 401** | The knowledge service answers a document you may not read with 401. The SDK raises `ExpertsPermissionError`, not `AuthError`: the key is fine. |

## OpenAI-compatible endpoint

Expand All @@ -176,7 +198,7 @@ Retrieval still happens; source documents come back under a `klavi` key on the f

## Requirements

Python 3.10 or later, and an install running build pack **1.0.49** or later.
Python 3.10 or later, and an install running build pack **1.0.49** or later. Tested against **1.1**; the mesh fields and the read-only licence error are only sent by a 1.1 install.

## Contributing

Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Reporting a vulnerability

Please report security issues privately to **security@klavi.ai**, or through
Please report security issues privately to **security@klv.ai**, or through
GitHub's private vulnerability reporting on this repository. Do not open a
public issue.

Expand Down
32 changes: 21 additions & 11 deletions examples/django_ingest/ingest.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""Feed a corpus into an expert's knowledge, and wait for it to be searchable.

Uploading is ASYNCHRONOUS. The file is accepted, then split, embedded and
indexed by a worker — so a document that has just uploaded is not retrievable
yet, and querying immediately returns nothing with no error to explain it.
Uploading is ASYNCHRONOUS. The file is accepted into an indexing job, then
split, embedded and indexed by a worker — so a document that has just uploaded
is not retrievable yet, and querying immediately returns nothing with no error
to explain it. The job is also where a file that could not be read says why.

python ingest.py ./policies/*.pdf
"""
Expand Down Expand Up @@ -36,22 +37,31 @@ def main() -> int:
collection = collections[0].uid
print(f"Using collection {collections[0].name} ({collection})")

pending = []
jobs = []
for path in paths:
with path.open("rb") as handle:
doc = experts.knowledge.upload(
job = experts.knowledge.upload(
handle, collection=collection, filename=path.name
)
print(f"uploaded {path.name} -> {doc.uid}")
pending.append(doc)
print(f"uploaded {path.name} (job {job.job_id})")
jobs.append(job)

# Accepted is not the same as searchable.
for doc in pending:
ready = experts.knowledge.wait_for_processing(doc.uid, timeout=300)
print(f"{'indexed ' if ready else 'TIMEOUT '} {doc.name}")
failures = 0
for job in jobs:
status = experts.knowledge.wait_for_processing(job, timeout=300)
for file in status.files:
if file.searchable:
print(f"indexed {file.name} -> {file.document_uid}")
elif file.status == "failed":
failures += 1
print(f"FAILED {file.name}: {file.error}")
else:
failures += 1
print(f"TIMEOUT {file.name} (still {file.status})")

experts.close()
return 0
return 1 if failures else 0


if __name__ == "__main__":
Expand Down
8 changes: 3 additions & 5 deletions examples/fastapi_chatbox/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,9 @@

1. Register the origin against your API key in the install's admin. That
registration is also what supplies CORS for the surface.
2. Make the expert guest-visible — minimum role Guest, not private. A session
token IS a guest, and an expert above that floor resolves to nothing
server-side: the chat would answer on the site default model with no
persona and no knowledge, silently, with a 200. sessions.create() refuses
such an expert and says so.
2. Make the expert guest-visible — minimum role Guest, not private, and not
limited to org profiles. A session token IS a guest, so sessions.create()
refuses any other expert and says so.
"""

from __future__ import annotations
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "klavi-experts"
version = "0.1.0"
version = "0.2.0"
description = "Official Python SDK for the Klavi Experts API — conversations, streaming, experts, and knowledge."
readme = "README.md"
license = "MIT"
Expand Down Expand Up @@ -51,6 +51,9 @@ testpaths = ["tests"]
# The live suite needs a running install and a real key; it is opt-in.
addopts = "--ignore=tests/live"
asyncio_mode = "auto"
# Explicit, because pytest-asyncio 0.24+ warns when it is unset and the
# filterwarnings below turns that warning into a configuration error.
asyncio_default_fixture_loop_scope = "function"
filterwarnings = ["error"]

[tool.ruff]
Expand Down
Loading
Loading