omnixon-lib 3.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ .DS_Store
2
+ dist
3
+ __pycache__
4
+ config.py
5
+ temp
6
+ a.py
7
+ a.xml
8
+ .uv-cache/
@@ -0,0 +1,44 @@
1
+ # Tests on every merge request and on main; publish to PyPI on a push to main (a merge into main is a push to main).
2
+ #
3
+ # The version published is the one in pyproject.toml: bump it in the merge request (`uv version X.Y.Z`).
4
+ # A push to main whose version is already on PyPI publishes nothing (`--check-url` skips the files PyPI has), the job still passes.
5
+ #
6
+ # Secret: the CI/CD variable UV_PUBLISH_TOKEN (Settings > CI/CD > Variables): a PyPI API token, masked and protected (main must be a protected branch).
7
+
8
+ workflow:
9
+ rules:
10
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
11
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
12
+
13
+ # The runner is a shell executor (no Docker image): uv is installed into the runner user's home when it is not there yet.
14
+ default:
15
+ before_script:
16
+ - export PATH="$HOME/.local/bin:$PATH"
17
+ - command -v uv >/dev/null || curl -LsSf https://astral.sh/uv/0.8.22/install.sh | sh
18
+ - uv --version
19
+
20
+ variables:
21
+ UV_CACHE_DIR: .uv-cache
22
+
23
+ cache:
24
+ key: uv-$CI_COMMIT_REF_SLUG
25
+ paths:
26
+ - .uv-cache
27
+
28
+ stages:
29
+ - test
30
+ - publish
31
+
32
+ test:
33
+ stage: test
34
+ script:
35
+ - uv run pytest
36
+
37
+ publish:
38
+ stage: publish
39
+ rules:
40
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_PIPELINE_SOURCE == "push"
41
+ needs: [test]
42
+ script:
43
+ - uv build
44
+ - uv publish --check-url https://pypi.org/simple/
@@ -0,0 +1,3 @@
1
+ {
2
+ "python-envs.defaultEnvManager": "ms-python.python:system"
3
+ }
@@ -0,0 +1,132 @@
1
+ # Library (omnixon-library)
2
+
3
+ ## Specifications and plans
4
+
5
+ **`../docs/superpowers/` (the workspace `docs/`, shared by all four projects) holds every specification and plan that was executed**:
6
+ `specs/` (the designs, `<date>-<topic>-design.md`), `plans/` (the step-by-step implementation plans, `<date>-<n>-<topic>.md`) and `results/`
7
+ (reports of what was done and verified). `../docs/TODO.md` lists what is still open. Read the spec and plan of a feature before changing it;
8
+ where a spec and the code disagree, the code is the truth. A new spec or plan goes there too.
9
+
10
+ Briefing for an agent working in this repository. `README.md` is the user-facing reference of the
11
+ methods; this file explains the project, its rules and its traps. The service this library talks to lives in
12
+ the sibling repository **`../omnixon-backend`** (read its `CLAUDE.md` too); a Telegram bot that uses this library
13
+ lives in **`../../unlink-telegram-bot`**.
14
+
15
+ ## What this is
16
+
17
+ `omnixon-lib` is the async Python client for the **Omnixon** service (an API that unifies LLM interaction:
18
+ history, memory, knowledge base, MCP tools, streaming). Published on PyPI as **`omnixon-lib`** (import name
19
+ `omnixon`). It is a thin, typed wrapper: one method per endpoint, pydantic models for every payload.
20
+
21
+ Current version: **3.0.0** (`pyproject.toml`; 3.0.0 on 2026-11-15 synced with the service's default provider: `Model.base_url` may be `None` now, `Defaults` / `DefaultsUpdate` have `default_provider` and `provider_token`; the line restarted at 1.0.0 on 2026-10-18, the 10.x numbers were local only; 2.0.0 on 2026-11-01 took the workflow methods out). Earlier releases are in the Versioning section. **Not on PyPI yet:** PyPI already serves **1.2.1** (seen earlier), so `1.0.0` is lower than it; the version to publish is the user's decision (see Versioning). The bot pins `omnixon-lib>=3.0.0`.
22
+ ## Working agreements with the user (lixelv)
23
+
24
+ - The user writes **Russian**; answer in Russian. Code, comments, commit messages, docs: English.
25
+ - **One commit per step**, clear message, no `Co-Authored-By` trailer. **Never push** unless asked.
26
+ - Ask before building when something is unclear; offer improvement suggestions.
27
+ - Report faithfully what was verified and what was not.
28
+ - Never delete files you did not create (the repo has git-ignored local files: `config.py`, `temp/`,
29
+ `a.py`, `a.xml`, `dist/`).
30
+
31
+ ## Layout
32
+
33
+ ```
34
+ omnixon/
35
+ __init__.py public exports (Client, MessageStream, models)
36
+ main.py Client (all endpoints) and MessageStream (streamed answer)
37
+ schemes.py pydantic models: responses (User, Message, Agent, AgentConfig, Token, NewToken, Model, MCPServer,
38
+ Memory, RAG, MessageResponse) and request bodies (…Create/…Update, MessageRequest)
39
+ exceptions.py UnlinkError and subclasses
40
+ tools.py client tools: `@client.tool` (`tool_from` makes the schema from the signature and the docstring; `RegisteredTool.run` validates the arguments)
41
+ tests/
42
+ test_client.py unit tests: httpx.MockTransport, no network
43
+ test_server_contract.py the models must match the service (see below)
44
+ server_openapi.json SNAPSHOT of the service's OpenAPI schema (generated, do not edit by hand)
45
+ test.py manual smoke script against a live server (needs git-ignored config.py: BASE_URL, TOKEN)
46
+ pyproject.toml, uv.lock uv project; hatchling builds the wheel; dev group = pytest, pytest-asyncio
47
+ .gitlab-ci.yml test on merge requests and main; publish to PyPI on a push to main
48
+ ```
49
+
50
+ ## How the client works
51
+
52
+ - `Client(token, base_url)`; `base_url` is the service root WITHOUT `/api/v1` (the client appends
53
+ `/api/v1/` and `/api/v1/admin/`). The constructor calls `check_connection()` synchronously (so creating
54
+ a client needs a reachable server; a wrong token raises `NoAccess`).
55
+ - Every call goes through `Client._session(timeout)`: by default it opens its own `httpx.AsyncClient`; inside
56
+ `async with Client(...)` (or after `__aenter__`) it reuses ONE shared pool (`self._http`), closed by
57
+ `aclose()`/`__aexit__`; after closing, calls fall back to per-call clients. `_Session` injects the
58
+ per-call default timeout (5 s plain, 120 s `LONG_TIMEOUT` for what waits for a model) so both modes
59
+ behave the same. Never create `httpx.AsyncClient` directly in a method: use `_session`. Only
60
+ `send_message`, `create_rag` and `update_rag` retry (`httpx.ReadError`, 3 times). Streams are **not**
61
+ retried (a retry would restart a half-delivered answer). `send_message`/streams use a 120 s timeout.
62
+ - **Messages**: `send_message(user_id, message, retries=3, attachments=None, trace=False, chat_id=None)`.
63
+ `user_id=None` or `""` makes the service create a user, returned as `response.user`. What is stored and what the model is given are the agent's blocks (no request flags since 5.0.0). Every message call reads `/request-stream`: the model's calls of the client's functions arrive as `event: tool_call`.
64
+ `stream_message(...)` returns a `MessageStream` (async iterator of text chunks; `.user` is set from the
65
+ first `event: user`, so it is known even if the stream later fails; `StreamError` on `event: error`).
66
+ `send_message_stream(...)` is the same as a plain async generator of strings. All three take
67
+ `attachments` (list of `Attachment` or dicts: images/PDF/audio/video as `url` or base64 `data`;
68
+ `Attachment.from_file/from_bytes/from_url`); the model must be able to handle them.
69
+ - **Agents**: made of blocks since 5.0.0: `create_agent(name, model_id=0, blocks=None, comment=None)`, `get_blocks`, `Agent.block/with_block/without_blocks`, and `Block` is generic (a type plus its options as extra fields; an unset option reads `None`). `update_agent` takes `comment` and `expected_version` (Conflict when the agent moved on); it sends `model_dump(exclude_unset=True)`, so a value set to `None` is sent as `null` and resets to the service's default.
70
+ - **Agent versions**: `get_agent_versions`, `get_agent_version`, `diff_agent_versions(agent_id, number, to=None)`,
71
+ `rollback_agent(agent_id, to, comment=None)`; `Message.agent_version`. **Memory**: see 8.0.0 above (`get_memories` is gone). `ready()` asks `/readyz` without a token.
72
+ - **Errors** (all `UnlinkError`, with the service's `detail` as message when it gave one):
73
+ `NoAccess` 401/403, `NotFound` 404, `Conflict` 409, `InvalidRequest` 422, `UpstreamError` 502/504
74
+ (model provider or MCP server failed/timed out; the service is fine), `StreamError`,
75
+ `ConnectionError`. Anything else falls through to `httpx.HTTPStatusError`.
76
+ - Admin methods need a token of a high enough role (user: its own agent; admin: any; owner: tokens of any role); the service refuses the rest with `NoAccess`. `Client(..., act_as_agent=)` sends `X-Act-As-Agent`.
77
+
78
+ ## Tests and the contract with the service
79
+
80
+ ```bash
81
+ uv sync # .venv incl. the dev group
82
+ uv run pytest # 203 tests: client behaviour, client tools, contract
83
+ uv build # dist/*.whl and *.tar.gz
84
+ ```
85
+
86
+ - `test_client.py` replaces `httpx.AsyncClient`/`httpx.Client` in `omnixon.main` with ones using
87
+ `httpx.MockTransport`; add a test for every new method/parameter, including the exact JSON body sent.
88
+ - **`test_server_contract.py` compares every model with `tests/server_openapi.json`**: same fields, same
89
+ types, same required-ness (`KNOWN_DIFFERENCES` lists the deliberate exceptions, e.g. `Agent.config`, which
90
+ the service describes as a plain object but the lib parses into `AgentConfig`). A new service model must
91
+ be added to `MODELS`. **When the service changes a model or route, regenerate the snapshot from the
92
+ service repo and make the lib match:**
93
+ `cd ../omnixon-backend && uv run python scripts/dump_openapi.py ../omnixon-library/tests/server_openapi.json`
94
+ - The unit tests prove the client builds correct requests; they do not prove the service accepts them.
95
+ Verify real releases against a live stack: build the wheel, install it into a **clean venv** (run Python
96
+ from outside the repo so the repo's `omnixon/` does not shadow the wheel), bring up the service's test
97
+ stack (see `../omnixon-backend/CLAUDE.md`, "Testing") and exercise every changed method. That was done for each
98
+ release (user creation without id, flags, streaming + `.user`, memory, config partial updates and
99
+ resets, error mapping, MCP through the stream).
100
+
101
+ ## Versioning and releasing
102
+
103
+ Semantic versioning. Current: **3.0.0** (`pyproject.toml`). History of the line: 3.0.0 (2026-11-15): the default provider and its key in the settings, `Model.base_url` is `None` for a model that follows the provider (it was always filled); 2.0.0 (2026-11-01): the workflow methods, models and the `workflow_id` of a token removed with the workflows of the service (the branch `with-workflows` keeps them); 1.1.0: tool calls in the history, `@client.tool(record=)`; 1.0.0 (2026-10-18): templates, decision models and model lists, the observer role, model calls (`chat_completion`, `embed`, `decide`); 10.0.0 (2026-10-17): workflow requests with attachments, Input and Person, port types; 9.0.0: workflows, workflow tokens, the handoff gone; 8.0.0 / 8.1.0: Letta memory and MemFS (the memories methods gone), evals; 7.0.0: embedding models, connection roles; 6.0.0: client tools (`@client.tool`), every message call streams; 5.0.0: agents made of blocks; 4.0.0: units replaced by tokens with roles, usage, acting as an agent; 3.x: contract tests, uv, `UpstreamError`, agent versions, search, trace; 2.0: models, MCP, streaming. **Open decision for the user:** PyPI already serves 1.2.1, so the next release needs a version the user picks (1.0.0 would be lower than what PyPI serves). Bump with `uv version X.Y.Z` (it
104
+ updates `pyproject.toml` and `uv.lock`). Publishing = merge into `main` (or push to it): `.gitlab-ci.yml` runs `uv run pytest`, then `uv build` and
105
+ `uv publish --check-url https://pypi.org/simple/` with the version in `pyproject.toml` (**bump it in the merge request**; a version PyPI already has publishes nothing and the job passes).
106
+ The secret is the CI/CD variable `UV_PUBLISH_TOKEN` of the GitLab project (a PyPI API token, masked and protected).
107
+ `uv publish --dry-run` checks the artifacts without uploading.
108
+ Gotcha: BSD `sed` has no `\s`, which is why the workflow uses `uv version` instead of `sed`.
109
+
110
+ After publishing, the **bot** needs `uv lock` (it has no `uv.lock` yet because 3.x was not on PyPI).
111
+
112
+ ## Status
113
+
114
+ - `dev` is 26 commits ahead of the local remote-tracking ref `origin/dev` (74af721, fetched 2026-10-10), which may be stale. Nothing is pushed by an agent.
115
+ - **1.0.0** (`pyproject.toml`): 203 tests pass (`uv run pytest`, 2026-10-19). Its wheel was driven live against the stack in the plan's Task 30 (5/5). The later commits
116
+ (`035a25a` Header tools contract, `3c6379c` optional display names and the contract sync) are not wheel-tested.
117
+ - Not published to PyPI from this work; see Versioning for the version conflict with the 1.2.1 already there.
118
+ - `README.md` documents the methods; keep it in step with `main.py`.
119
+
120
+ ## Ideas / not done
121
+
122
+ - No sync wrapper; no automatic retry on `UpstreamError` (a retry may help for 502/504).
123
+ - `test.py` is a manual smoke script, not part of the automated suite.
124
+
125
+ ## Rename (Unilink -> Omnixon)
126
+
127
+ The product is called **Omnixon** now (omni = many services into one, xon = axon, one brain). Renamed in code: the Python package/import `omnixon`
128
+ (PyPI `omnixon-lib`, a NEW PyPI project: nothing of `unilink-lib` carries over), the Prometheus prefix `omnixon_`, env vars (`OMNIXON_*`), compose
129
+ project and container names, the default `APP_NAME`/database names, panel texts and localStorage keys (`omnixon.token`, `omnixon.baseUrl`: everybody is signed out once). NOT renamed on purpose:
130
+ the GitLab project path `unlink/unilink-api` and the GitHub mirror `europrom-injectors/unilink` (the user renames the remotes; the local folders are `omnixon-backend`, `omnixon-library`, `omnixon-frontend` now), the Swarm/CI deploy
131
+ files of the other team member (`.gitlab/`, `docker/docker-compose.*-deploy.yml`: stack `unilink`, service `unilink_api`, job `unilink_promote_job`), the GitHub secret names `UNILINK_TOKEN` / `UNILINK_BASE_URL` of the bot, and
132
+ statements about the user's real `.env` / Docker volume (`unilink-postgres-data`), which are still called as before until they change them.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 lixel>_
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.5
2
+ Name: omnixon-lib
3
+ Version: 3.0.0
4
+ Summary: A small enterprise chatbot library
5
+ Project-URL: Homepage, https://github.com/lixelv/pypi-package-template
6
+ Project-URL: Issues, https://github.com/lixelv/pypi-package-template
7
+ Author-email: lixelv <simeongfremenko@gmail.com>
8
+ License-File: LICENSE
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: httpx
14
+ Requires-Dist: pydantic>=2
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Omnixon Python Client
18
+
19
+ ## Overview
20
+
21
+ This document provides instructions for the Python client used to connect to the Omnixon web service. The Omnixon service receives text requests, processes them through artificial intelligence programs, and returns text responses.
22
+
23
+ ## Connection and Setup
24
+
25
+ To use the client, you must provide an access token and the base web address. Every request requires this access token, which is placed in the request headers automatically by the client.
26
+
27
+ The client verifies the connection upon creation. It returns a 403 error if the token is incorrect. It returns a connection error if the web address is incorrect.
28
+
29
+ ## Client Functions
30
+
31
+ The client provides the following functions to interact with the service:
32
+
33
+ * `send_message(user_id, message, attachments=None, trace=False, chat_id=None)`: Sends a user text request to the service and returns a generated answer. This function has a 120-second timeout. What is stored and what the model is given are decided by the agent's **blocks** (see below), not by the request.
34
+ * `user_id` may be `None` or an empty string. The service then creates a new user for your agent and returns it as `response.user`; keep `response.user.external_id` to continue the conversation. An agent without the Users block is stateless: `response.user` and `response.chat_id` are `None`.
35
+ * `attachments` is a list of files for the model: images, PDFs, text, audio, video. Build them with `Attachment.from_file(path)`, `Attachment.from_bytes(data, "image/png")` or `Attachment.from_url(url)`. The model must be able to handle them (for images it must see); a model that cannot gives `UpstreamError`. The history keeps only a note that a file was there, never the file.
36
+ * `send_message_stream(user_id, message, attachments=None)`: Like `send_message`, but an asynchronous generator that yields the answer in text chunks while it is being generated (`async for chunk in client.send_message_stream(...)`). It raises `StreamError` if the service fails midway, and is not retried automatically.
37
+ * `stream_message(user_id, message, attachments=None)`: The same stream as an object. Iterate it for the chunks; its `user` attribute holds the user (also a newly created one) as soon as the service sends it, before the first chunk (`None` for a stateless agent).
38
+ * `get_history(user_id)`: Returns a list of saved text records for a specific user.
39
+ * `clear_history(user_id)`: Deletes all saved text records for a specific user.
40
+ * `get_user(user_id)`: Retrieves stored information for a specific user. It returns nothing if the user is not found.
41
+ * `create_user(user_id)`: Adds a new user to the database.
42
+ * `update_user(user_id, new_user_id)`: Modifies existing information for a user, such as their identification number.
43
+ * `delete_user(user_id)`: Removes a user from the database.
44
+
45
+ Administrative functions (admin token required):
46
+
47
+ * Chats: a user has several conversations, each its own thread of messages (what the agent remembers about the user is shared). `get_chats(user_id)` (the latest first), `create_chat(user_id, title=None)` (no title: named after the first message), `get_chat`, `rename_chat(user_id, chat_id, title)`, `delete_chat` (with its messages), `get_chat_history(user_id, chat_id)`, `clear_chat_history`. `send_message`, `stream_message` and `send_message_stream` take `chat_id=`; `MessageResponse.chat_id` says where it was written. Without `chat_id` a message goes to the user's default chat, so code that knows nothing about chats works as before; `get_history` / `clear_history` are that chat's.
48
+ * Interrupting: `interrupt(user_id)` stops the answer that is being streamed to that user and returns `Interrupted(interrupted, text)`. The stream ends cleanly (`MessageStream.interrupted` is `True`), what had been said goes to the history (marked `interrupted`). A new request of the same user does the same by itself. Streams can only be reached in the service process that runs them.
49
+ * Client tools: `@client.tool` offers a function of yours to the model with every message (the agent needs the Client tools block; without it `Conflict`):
50
+
51
+ ```python
52
+ client = Client(token, base_url)
53
+
54
+ @client.tool
55
+ async def get_order(order_id: int) -> dict:
56
+ """Order status by its id."""
57
+ return {"status": "shipped"}
58
+
59
+ answer = await client.send_message(None, "Where is order 42?")
60
+ ```
61
+
62
+ The schema comes from the signature (pydantic types) and the first paragraph of the docstring (`@client.tool(name="...", description="...")` overrides). Sync functions run in a thread, async ones are awaited; calls of one turn run at the same time. When the model calls one, the library runs it and gives the model what it returned; an exception, or arguments that do not fit, reach the model as the tool's failure and the answer goes on. `client.tools` lists the tools, `client.remove_tool(name)` drops one. `@client.tool(record="hidden")` says how calls of the function are kept in the history of the chat: `"full"` (the model is given the arguments and the result back), `"hidden"` (it is told only that the call happened; it reads the rest with the agent's Get tool call block) or `"off"` (not kept); unset, the agent's Client tools block decides.
63
+ * Tool calls in the history: an answer of an agent that keeps them has `message.tool_calls` (a list of `ToolCall`: `id` like `"12.1"`, `name`, `arguments`, `result`, `mode`, `failed`); `Defaults.tool_call_args_limit` / `tool_call_result_limit` (settings, 4000 characters each) cut what is kept. Every message call (`send_message`, `stream_message`, `send_message_stream`) reads the service's stream under the hood (that is how the model can ask for your functions), `send_message` joins it: a failure after the service began to answer is `StreamError`, a refusal before it (`RateLimited`, `Conflict`, `NoAccess`, ...) is the same exception as ever.
64
+ * Agents: `get_agents()`, `create_agent(name, model_id=None, blocks=None, comment=None, model_ids=None, decision_model_id=None)` (no model: the chat model of the settings; no blocks: the service's default ones), `get_self_agent()`, `get_agent(agent_id)`, `update_agent(agent_id, name=None, model_id=None, blocks=None, comment=None, expected_version=None, model_ids=None, decision_model_id=...)`, `delete_agent(agent_id)`. An agent answers with a list of chat models (`Agent.model_ids`, 1-10): with more than one, a decision model (`decision_model_id`, `None`: the settings' one) picks the model of each request by its description, and when the provider refuses before any text the next one answers; the trace has a step of `kind="decision"` (`TraceStep.decision`: each model with its probability and outcome).
65
+ * Models: `create_model(name="", request_json=..., base_url=None, use_proxy=True, api_token=None, kind="chat", description="")` — `kind` is `chat`, `embedding` or `decision` (TypeSafe Jev: picks, scores, says how likely); an empty description of an OpenRouter model is filled by the service from OpenRouter's list (its input modalities first). `update_model(..., description=None)`. The settings name three default models: `default_chat_model_id`, `default_embedding_model_id`, `default_decision_model_id`.
66
+ * Models called directly (OpenAI-compatible endpoint, observer / admin / owner tokens; the model name is the id of the record): `chat_completion(model_id, messages, **options)`, `embed(model_id, texts, dimensions=None)`, `decide(model_id, state, questions)`. Any OpenAI SDK works too: `base_url=<omnixon>/api/v1/openai`, the token as the key.
67
+ * Roles: `regular`, `user`, `admin`, `owner`, and `observer` (sees everything, runs any agent, changes nothing; made by admin and owner).
68
+ * Models: `get_models(kind=None)`, `create_model(name="", request_json=..., base_url=None, use_proxy=True, api_token=None, kind="chat")`, `get_model(model_id)`, `update_model(model_id, request_json=None, name=None, base_url=None, use_proxy=None, api_token=None)`, `delete_model(model_id)`. `base_url` points a model at another OpenAI-compatible server (default: OpenRouter), `use_proxy=False` calls it without the service's proxy, `api_token` is the key for that server (default: the key of the service; it is never returned, `Model.has_api_token` says whether there is one; `""` in `update_model` clears `base_url` / `api_token`). A model is an OpenRouter request body that must contain a `"model"` name, for example `{"model": "openai/gpt-4o-mini", "provider": {"order": ["openai"]}}`. A model has a `kind`: `chat` (what an agent answers with) or `embedding` (what makes texts searchable: `create_model("Small", {"model": "openai/text-embedding-3-small", "dimensions": 1536}, kind="embedding")`; the service tries it first and refuses one that cannot be reached or gives another size with `InvalidRequest`; it may live on another server with `base_url`, and is never an agent's model).
69
+ * MCP servers: `get_mcp_servers()`, `create_mcp_server(name, config, agent_id=None)`, `get_mcp_server(id)`, `update_mcp_server(id, config=None, name=None)`, `delete_mcp_server(id)`. The config needs a `"url"` and an optional `"transport"` (`"streamable_http"` by default, or `"sse"`).
70
+ * Blocks: an agent is made of blocks, each one capability with its own options; its presence means "on". A block is `Block(type="history", limit=3)`: a type and the options of that type as attributes (an option that is not set reads as `None`: the service's setting decides). `get_blocks()` is the catalog the service gives (`BlockType`: title, `requires`, `min_role`, `default`, the JSON `schema_` of the options): which types there are, what each needs and which options it has; the library lists none of them, so a type the service adds later works at once. `update_agent(blocks=...)` sends the WHOLE list: what is not in it is removed. Make the list from the agent you read: `agent.with_block(Block(type="history", limit=3))` (replaces or adds it; `many=True` for a type that may be there more than once, such as `mcp`), `agent.without_blocks("rag")`; `agent.block("history")` reads one. A broken list (a block without what it needs, a type twice) raises `InvalidRequest`; a block your role may not change raises `NoAccess`. Options left unset take the service's settings: `get_settings()` and `update_settings(default_rate_limit_per_minute=20, ...)` (admin). The settings also name the embedding model of every store that names none (`default_embedding_model_id`, a model of kind `embedding`). They also name the provider models are called at: `default_provider` (an OpenAI-compatible address, OpenRouter's unless changed) and its key `provider_token` (never returned: `has_provider_token` and `provider_token_source`, `settings` or `env`, say whether one is known; `""` goes back to the environment's value). A model whose `base_url` is `None` follows that provider, whichever it is at the time of the call. Another model makes those embeddings again in the background; `get_embedding_status()` (admin) says the model (`model_id`, `model`), the size and how many are left (`pending` of `total`, and `stores`: the same for `rag`, `memory` and `memfs`). The knowledge base (`rag`), the memory (`memory`) and the files (`memfs`) each have an option `embedding_model_id`: that store is made with that model, with a vector size of its own, and only it is made again when the model changes (`Block(type="rag", embedding_model_id=5)`). Searches of the knowledge base, the memory and the files are hybrid (BM25 over Russian and English words and the nearest vectors, fused by reciprocal rank); `RAG.score` and `RAG.matched` (`bm25`, `vector` or `both`) say how an entry of a search was found. A `rag` block with `writable=True` lets the agent add, change and delete entries.
71
+ * Connections between agents and MCP tools of an agent are blocks too: an `agent_calls` block lists the agents it may ask (`connections=[{"agent_id": 2}]`; it gets the tools `list_agents` and `ask_agent`), an `mcp` block gives it the tools of a server (`get_agent_mcp_servers(agent_id)` lists them; `create_mcp_server(name, config, agent_id=...)` makes a server and adds its block at once, which needs the Tools block).
72
+ * Agent versions: every change of an agent (its blocks, model, even an edit of the model or MCP server record it uses) is a numbered version. `get_agent_versions(agent_id)` (newest first), `get_agent_version(agent_id, number)`, `diff_agent_versions(agent_id, number, to=None)` (what changed, `to` defaults to the latest) and `rollback_agent(agent_id, to, comment=None)` (the agent becomes what that version was; recorded as a new version, nothing is lost). `update_agent(..., expected_version=n)` refuses with `Conflict` if the agent has moved on since version `n`, so two people do not overwrite each other. A message knows the version that produced it (`Message.agent_version`).
73
+ * Evals of versions: `run_eval(agent_id, versions, judge_model_id, dataset, criteria=None)` runs up to 5 versions over a dataset of conversations in the background and has the judge (a decision model; `judge_model_id=None`: the settings' one) score every answer 0-100 against the one expected (how likely the answer says what the expected one says, x 100); the dataset is people -> chats -> messages, `[[[{"role": "user", "content": ...}, {"role": "assistant", "content": <expected>}, ...]]]`. `wait_eval(agent_id, run_id, poll=2.0, timeout=None)` waits until it is done (`TimeoutError` past `timeout`), `get_evals(agent_id)`, `get_eval(agent_id, run_id)` (`EvalRun.scores`: per version the mean score, how many were scored and how many failed; `key_sources`: whose API key each version's model used), `get_eval_results(agent_id, run_id, version=None)` (`EvalResult`: question, expected, answer, score, reason, error), `delete_eval(agent_id, run_id)` (stops a running one).
74
+ * Memory (Letta's three tiers; the block `memory`, whose parts are its flags `core`, `recall`, `archival`, with the options of each: `core_blocks`, `recall_limit`, `archival_limit`, `summarize`, ...): `get_memory_users(agent_id=None, query=None)` (the people that have something in memory); core blocks (text the agent keeps in front of every message and edits itself): `get_core_memory(user_id=None, agent_id=None)`, `set_core_memory(label, value, user_id=None, agent_id=None)`, `delete_core_memory(label, ...)`; archival passages: `search_archival(user_id, query=None, limit=10, offset=0, agent_id=None)` (the newest, or the best match by words and meaning), `add_archival(user_id, content, agent_id=None)`, `add_archival_many(user_id, contents, agent_id=None)` (up to 1024, all or none), `update_archival(passage_id, content)`, `delete_archival(passage_id)`; the conversation: `search_recall(user_id, query=None, role=None, since=None, until=None, limit=None, agent_id=None, offset=None)` (with a query: a search, 20 hits unless `limit`; without one: every message, the newest first, 50 a page after `offset`). A summary kept by the service (Archival's `summarize`) is listed by `get_core_memory` as the read-only block `summary`. What leaves the History window is saved as archival passages (`source == "history"`).
75
+ * Files (MemFS, the block `memfs` with the flags `agent_files` and `person_files` (and `agent_files_writable`, `person_files_writable`)): a tree of the agent and one for each person, every change a revision: `get_memfs_users(agent_id=None)`, `memfs_tree(user_id=None, agent_id=None)` (no `user_id`: the tree of the agent), `memfs_read(path, user_id=None, revision=None, agent_id=None)`, `memfs_write(path, content, description=None, ...)`, `memfs_move(src, dst, ...)`, `memfs_delete(path, ...)`, `memfs_history(path, ...)`, `memfs_revert(path, revision, ...)`, `memfs_search(query, user_id=None, limit=5, agent_id=None)`. Paths are written without the root (`notes/plan.md`).
76
+ * The chain of calls: `send_message(..., trace=True)` puts the steps in `response.trace`; `stream_message(..., trace=True)` collects them in `stream.trace` while the answer arrives. A `TraceStep` is a call to the model (`kind="model"`, with `text`, tokens, `duration_ms`), a tool the model called (`kind="tool"`, with `args`, `result` or `error`).
77
+ * Users made by agents: when an agent asks another, the called one talks to the user `agent_<caller>:<person>`, marked `User.is_agent`; with Chats on both agents its chat belongs to the caller's chat (`Chat.parent_chat_id`).
78
+ * Finding users: `get_recent_users(limit=20)` (who wrote lately: latest first, with `last_active` and `messages`; the window is the service's message TTL), `search_users(query, limit=10)` returns the users of your agent whose external id starts with `query` (at least 3 characters, else `InvalidRequest`; the service has no list of all users), sorted, for suggestions while someone types.
79
+ * Tokens: `get_self_token()`, `get_tokens(agent_id=None)`, `create_token(name, role, agent_id=None)` (the answer's `.token` is the secret, shown once), `update_token(id, name=None, role=None)` (rename and/or change the role), `rename_token(id, name)`, `delete_token(id)`. Roles, least to most: `regular`, `user`, `admin`, `owner`; an owner hands out any, an admin and a user `regular` and `user` (a user only for its own agent). A token is bound to an **agent**; there are no units any more.
80
+ * Usage of the models (no texts): `get_usage(days=30, token_id=None, agent_id=None)` by day, token and model; `get_usage_monthly(...)` for what is older (folded by month).
81
+ * Knowledge base: `search_rag()`, `list_rag()`, `create_rag()`, `create_rag_batch(items, agent_id=None)` (1 to 1024 entries in one request, all or none, answered in order; an item is a string or `{content, embedding_content, metadata}`), `create_rag_many(items, agent_id=None, batch_size=1024, concurrency=4)` (any number: batches that go several at a time, answered in order; a failed batch raises and the ones already made stay), `get_rag()`, `update_rag()`, `delete_rag()`.
82
+
83
+ ## Data Structures
84
+
85
+ The client receives data organized into the following structures:
86
+
87
+ * **Names**: agents, MCP servers and tokens require a `name` on create and accept an optional one on update. Models may omit the name or send an empty one: OpenRouter models use their display name from the catalog, while external models fall back to their model id. Explicitly empty model names and descriptions are refreshed from OpenRouter on update; omitted fields keep their values. Every object read back has a non-empty `name`; old entities without one get a fallback from the service (model -> its model name, agent -> the start of its prompt or `Agent <id>`, `MCP <id>`, `Unit <id>`). `get_self_unit()` and `get_self_agent()` use the main API, so they work without admin rights.
88
+ * **Token**: `role` (`regular|user|admin|owner`), `agent_id`, `is_initial` (the token of the service's `INITIAL_API_KEY`: an owner that cannot be deleted). What each role may do is decided by the service (`NoAccess` when a call is refused). An admin or owner may work as another agent: `Client(token, url, act_as_agent=7)` sends `X-Act-As-Agent` on every call (what is spent is still written on your own token).
89
+ * **User**: the system identification number, the agent's id, the external identification string and a timestamp.
90
+ * **Agent**: its `name`, the `model_id` of its model and its `blocks`.
91
+ * **AgentVersion**: Contains the agent, its number, a `snapshot` (blocks, model, MCP servers as they were), the comment, who made it and a timestamp.
92
+ * **Attachment**: A file for the model: `url` or base64 `data`, with `media_type` and an optional `name`.
93
+ * **CoreBlock**, **ArchivalPassage**, **RecallHit**, **MemoryUser**: the three tiers of memory (a core block: label, scope `person`|`agent`, limit, value; a passage: text, source, timestamp, and in a search `score` and `matched`). **MemfsEntry** / **MemfsFile** / **MemfsRevision** / **MemfsHit** / **MemfsUser**: the files.
94
+ * **Model**: Contains the identification number and the `request_json` sent to the model provider.
95
+ * **MCPServer**: Contains the identification number and the connection `config`.
96
+ * **MessageResponse**: Contains the text response from the artificial intelligence and the user information.
97
+ * **Message**: Contains the message identification number, user identification number, the text content, and a timestamp. The content is automatically converted from text format into a dictionary.
98
+
99
+ ## Connections
100
+
101
+ By default every call opens its own connection. For many calls use the client as an async context manager, which keeps one connection pool:
102
+
103
+ ```python
104
+ async with Client(token, base_url) as client:
105
+ for text in texts:
106
+ await client.send_message(user_id, text)
107
+ ```
108
+
109
+ `await client.aclose()` closes the pool by hand. `await client.ready()` asks the service whether it is up and its database is ready (no token needed).
110
+
111
+ ## Errors
112
+
113
+ All errors derive from `UnlinkError` and carry the explanation given by the service: `NoAccess` (401/403), `NotFound` (404), `Conflict` (409, for example deleting a model that an agent uses), `InvalidRequest` (422, rejected data), `StreamError` (a streamed answer failed midway) and `UpstreamError` (HTTP 502 or 504: the model provider or an MCP server failed or timed out; a retry may help), `RateLimited` (429: the agent's Rate limit; `retry_after` says in how many seconds to try again) and `ConnectionError` (the service cannot be reached).
114
+
115
+ ## Development
116
+
117
+ ```bash
118
+ uv sync # create .venv (with the dev group: pytest)
119
+ uv run pytest # unit tests and the contract test
120
+ uv build # build the wheel and sdist into dist/
121
+ ```
122
+
123
+ `tests/test_server_contract.py` compares the models of this library with `tests/server_openapi.json`, a snapshot of the service's OpenAPI schema. After a model changes in the service, regenerate the snapshot there (`uv run python scripts/dump_openapi.py <path to this file>`) and adapt the library until the tests pass.
@@ -0,0 +1,107 @@
1
+ # Omnixon Python Client
2
+
3
+ ## Overview
4
+
5
+ This document provides instructions for the Python client used to connect to the Omnixon web service. The Omnixon service receives text requests, processes them through artificial intelligence programs, and returns text responses.
6
+
7
+ ## Connection and Setup
8
+
9
+ To use the client, you must provide an access token and the base web address. Every request requires this access token, which is placed in the request headers automatically by the client.
10
+
11
+ The client verifies the connection upon creation. It returns a 403 error if the token is incorrect. It returns a connection error if the web address is incorrect.
12
+
13
+ ## Client Functions
14
+
15
+ The client provides the following functions to interact with the service:
16
+
17
+ * `send_message(user_id, message, attachments=None, trace=False, chat_id=None)`: Sends a user text request to the service and returns a generated answer. This function has a 120-second timeout. What is stored and what the model is given are decided by the agent's **blocks** (see below), not by the request.
18
+ * `user_id` may be `None` or an empty string. The service then creates a new user for your agent and returns it as `response.user`; keep `response.user.external_id` to continue the conversation. An agent without the Users block is stateless: `response.user` and `response.chat_id` are `None`.
19
+ * `attachments` is a list of files for the model: images, PDFs, text, audio, video. Build them with `Attachment.from_file(path)`, `Attachment.from_bytes(data, "image/png")` or `Attachment.from_url(url)`. The model must be able to handle them (for images it must see); a model that cannot gives `UpstreamError`. The history keeps only a note that a file was there, never the file.
20
+ * `send_message_stream(user_id, message, attachments=None)`: Like `send_message`, but an asynchronous generator that yields the answer in text chunks while it is being generated (`async for chunk in client.send_message_stream(...)`). It raises `StreamError` if the service fails midway, and is not retried automatically.
21
+ * `stream_message(user_id, message, attachments=None)`: The same stream as an object. Iterate it for the chunks; its `user` attribute holds the user (also a newly created one) as soon as the service sends it, before the first chunk (`None` for a stateless agent).
22
+ * `get_history(user_id)`: Returns a list of saved text records for a specific user.
23
+ * `clear_history(user_id)`: Deletes all saved text records for a specific user.
24
+ * `get_user(user_id)`: Retrieves stored information for a specific user. It returns nothing if the user is not found.
25
+ * `create_user(user_id)`: Adds a new user to the database.
26
+ * `update_user(user_id, new_user_id)`: Modifies existing information for a user, such as their identification number.
27
+ * `delete_user(user_id)`: Removes a user from the database.
28
+
29
+ Administrative functions (admin token required):
30
+
31
+ * Chats: a user has several conversations, each its own thread of messages (what the agent remembers about the user is shared). `get_chats(user_id)` (the latest first), `create_chat(user_id, title=None)` (no title: named after the first message), `get_chat`, `rename_chat(user_id, chat_id, title)`, `delete_chat` (with its messages), `get_chat_history(user_id, chat_id)`, `clear_chat_history`. `send_message`, `stream_message` and `send_message_stream` take `chat_id=`; `MessageResponse.chat_id` says where it was written. Without `chat_id` a message goes to the user's default chat, so code that knows nothing about chats works as before; `get_history` / `clear_history` are that chat's.
32
+ * Interrupting: `interrupt(user_id)` stops the answer that is being streamed to that user and returns `Interrupted(interrupted, text)`. The stream ends cleanly (`MessageStream.interrupted` is `True`), what had been said goes to the history (marked `interrupted`). A new request of the same user does the same by itself. Streams can only be reached in the service process that runs them.
33
+ * Client tools: `@client.tool` offers a function of yours to the model with every message (the agent needs the Client tools block; without it `Conflict`):
34
+
35
+ ```python
36
+ client = Client(token, base_url)
37
+
38
+ @client.tool
39
+ async def get_order(order_id: int) -> dict:
40
+ """Order status by its id."""
41
+ return {"status": "shipped"}
42
+
43
+ answer = await client.send_message(None, "Where is order 42?")
44
+ ```
45
+
46
+ The schema comes from the signature (pydantic types) and the first paragraph of the docstring (`@client.tool(name="...", description="...")` overrides). Sync functions run in a thread, async ones are awaited; calls of one turn run at the same time. When the model calls one, the library runs it and gives the model what it returned; an exception, or arguments that do not fit, reach the model as the tool's failure and the answer goes on. `client.tools` lists the tools, `client.remove_tool(name)` drops one. `@client.tool(record="hidden")` says how calls of the function are kept in the history of the chat: `"full"` (the model is given the arguments and the result back), `"hidden"` (it is told only that the call happened; it reads the rest with the agent's Get tool call block) or `"off"` (not kept); unset, the agent's Client tools block decides.
47
+ * Tool calls in the history: an answer of an agent that keeps them has `message.tool_calls` (a list of `ToolCall`: `id` like `"12.1"`, `name`, `arguments`, `result`, `mode`, `failed`); `Defaults.tool_call_args_limit` / `tool_call_result_limit` (settings, 4000 characters each) cut what is kept. Every message call (`send_message`, `stream_message`, `send_message_stream`) reads the service's stream under the hood (that is how the model can ask for your functions), `send_message` joins it: a failure after the service began to answer is `StreamError`, a refusal before it (`RateLimited`, `Conflict`, `NoAccess`, ...) is the same exception as ever.
48
+ * Agents: `get_agents()`, `create_agent(name, model_id=None, blocks=None, comment=None, model_ids=None, decision_model_id=None)` (no model: the chat model of the settings; no blocks: the service's default ones), `get_self_agent()`, `get_agent(agent_id)`, `update_agent(agent_id, name=None, model_id=None, blocks=None, comment=None, expected_version=None, model_ids=None, decision_model_id=...)`, `delete_agent(agent_id)`. An agent answers with a list of chat models (`Agent.model_ids`, 1-10): with more than one, a decision model (`decision_model_id`, `None`: the settings' one) picks the model of each request by its description, and when the provider refuses before any text the next one answers; the trace has a step of `kind="decision"` (`TraceStep.decision`: each model with its probability and outcome).
49
+ * Models: `create_model(name="", request_json=..., base_url=None, use_proxy=True, api_token=None, kind="chat", description="")` — `kind` is `chat`, `embedding` or `decision` (TypeSafe Jev: picks, scores, says how likely); an empty description of an OpenRouter model is filled by the service from OpenRouter's list (its input modalities first). `update_model(..., description=None)`. The settings name three default models: `default_chat_model_id`, `default_embedding_model_id`, `default_decision_model_id`.
50
+ * Models called directly (OpenAI-compatible endpoint, observer / admin / owner tokens; the model name is the id of the record): `chat_completion(model_id, messages, **options)`, `embed(model_id, texts, dimensions=None)`, `decide(model_id, state, questions)`. Any OpenAI SDK works too: `base_url=<omnixon>/api/v1/openai`, the token as the key.
51
+ * Roles: `regular`, `user`, `admin`, `owner`, and `observer` (sees everything, runs any agent, changes nothing; made by admin and owner).
52
+ * Models: `get_models(kind=None)`, `create_model(name="", request_json=..., base_url=None, use_proxy=True, api_token=None, kind="chat")`, `get_model(model_id)`, `update_model(model_id, request_json=None, name=None, base_url=None, use_proxy=None, api_token=None)`, `delete_model(model_id)`. `base_url` points a model at another OpenAI-compatible server (default: OpenRouter), `use_proxy=False` calls it without the service's proxy, `api_token` is the key for that server (default: the key of the service; it is never returned, `Model.has_api_token` says whether there is one; `""` in `update_model` clears `base_url` / `api_token`). A model is an OpenRouter request body that must contain a `"model"` name, for example `{"model": "openai/gpt-4o-mini", "provider": {"order": ["openai"]}}`. A model has a `kind`: `chat` (what an agent answers with) or `embedding` (what makes texts searchable: `create_model("Small", {"model": "openai/text-embedding-3-small", "dimensions": 1536}, kind="embedding")`; the service tries it first and refuses one that cannot be reached or gives another size with `InvalidRequest`; it may live on another server with `base_url`, and is never an agent's model).
53
+ * MCP servers: `get_mcp_servers()`, `create_mcp_server(name, config, agent_id=None)`, `get_mcp_server(id)`, `update_mcp_server(id, config=None, name=None)`, `delete_mcp_server(id)`. The config needs a `"url"` and an optional `"transport"` (`"streamable_http"` by default, or `"sse"`).
54
+ * Blocks: an agent is made of blocks, each one capability with its own options; its presence means "on". A block is `Block(type="history", limit=3)`: a type and the options of that type as attributes (an option that is not set reads as `None`: the service's setting decides). `get_blocks()` is the catalog the service gives (`BlockType`: title, `requires`, `min_role`, `default`, the JSON `schema_` of the options): which types there are, what each needs and which options it has; the library lists none of them, so a type the service adds later works at once. `update_agent(blocks=...)` sends the WHOLE list: what is not in it is removed. Make the list from the agent you read: `agent.with_block(Block(type="history", limit=3))` (replaces or adds it; `many=True` for a type that may be there more than once, such as `mcp`), `agent.without_blocks("rag")`; `agent.block("history")` reads one. A broken list (a block without what it needs, a type twice) raises `InvalidRequest`; a block your role may not change raises `NoAccess`. Options left unset take the service's settings: `get_settings()` and `update_settings(default_rate_limit_per_minute=20, ...)` (admin). The settings also name the embedding model of every store that names none (`default_embedding_model_id`, a model of kind `embedding`). They also name the provider models are called at: `default_provider` (an OpenAI-compatible address, OpenRouter's unless changed) and its key `provider_token` (never returned: `has_provider_token` and `provider_token_source`, `settings` or `env`, say whether one is known; `""` goes back to the environment's value). A model whose `base_url` is `None` follows that provider, whichever it is at the time of the call. Another model makes those embeddings again in the background; `get_embedding_status()` (admin) says the model (`model_id`, `model`), the size and how many are left (`pending` of `total`, and `stores`: the same for `rag`, `memory` and `memfs`). The knowledge base (`rag`), the memory (`memory`) and the files (`memfs`) each have an option `embedding_model_id`: that store is made with that model, with a vector size of its own, and only it is made again when the model changes (`Block(type="rag", embedding_model_id=5)`). Searches of the knowledge base, the memory and the files are hybrid (BM25 over Russian and English words and the nearest vectors, fused by reciprocal rank); `RAG.score` and `RAG.matched` (`bm25`, `vector` or `both`) say how an entry of a search was found. A `rag` block with `writable=True` lets the agent add, change and delete entries.
55
+ * Connections between agents and MCP tools of an agent are blocks too: an `agent_calls` block lists the agents it may ask (`connections=[{"agent_id": 2}]`; it gets the tools `list_agents` and `ask_agent`), an `mcp` block gives it the tools of a server (`get_agent_mcp_servers(agent_id)` lists them; `create_mcp_server(name, config, agent_id=...)` makes a server and adds its block at once, which needs the Tools block).
56
+ * Agent versions: every change of an agent (its blocks, model, even an edit of the model or MCP server record it uses) is a numbered version. `get_agent_versions(agent_id)` (newest first), `get_agent_version(agent_id, number)`, `diff_agent_versions(agent_id, number, to=None)` (what changed, `to` defaults to the latest) and `rollback_agent(agent_id, to, comment=None)` (the agent becomes what that version was; recorded as a new version, nothing is lost). `update_agent(..., expected_version=n)` refuses with `Conflict` if the agent has moved on since version `n`, so two people do not overwrite each other. A message knows the version that produced it (`Message.agent_version`).
57
+ * Evals of versions: `run_eval(agent_id, versions, judge_model_id, dataset, criteria=None)` runs up to 5 versions over a dataset of conversations in the background and has the judge (a decision model; `judge_model_id=None`: the settings' one) score every answer 0-100 against the one expected (how likely the answer says what the expected one says, x 100); the dataset is people -> chats -> messages, `[[[{"role": "user", "content": ...}, {"role": "assistant", "content": <expected>}, ...]]]`. `wait_eval(agent_id, run_id, poll=2.0, timeout=None)` waits until it is done (`TimeoutError` past `timeout`), `get_evals(agent_id)`, `get_eval(agent_id, run_id)` (`EvalRun.scores`: per version the mean score, how many were scored and how many failed; `key_sources`: whose API key each version's model used), `get_eval_results(agent_id, run_id, version=None)` (`EvalResult`: question, expected, answer, score, reason, error), `delete_eval(agent_id, run_id)` (stops a running one).
58
+ * Memory (Letta's three tiers; the block `memory`, whose parts are its flags `core`, `recall`, `archival`, with the options of each: `core_blocks`, `recall_limit`, `archival_limit`, `summarize`, ...): `get_memory_users(agent_id=None, query=None)` (the people that have something in memory); core blocks (text the agent keeps in front of every message and edits itself): `get_core_memory(user_id=None, agent_id=None)`, `set_core_memory(label, value, user_id=None, agent_id=None)`, `delete_core_memory(label, ...)`; archival passages: `search_archival(user_id, query=None, limit=10, offset=0, agent_id=None)` (the newest, or the best match by words and meaning), `add_archival(user_id, content, agent_id=None)`, `add_archival_many(user_id, contents, agent_id=None)` (up to 1024, all or none), `update_archival(passage_id, content)`, `delete_archival(passage_id)`; the conversation: `search_recall(user_id, query=None, role=None, since=None, until=None, limit=None, agent_id=None, offset=None)` (with a query: a search, 20 hits unless `limit`; without one: every message, the newest first, 50 a page after `offset`). A summary kept by the service (Archival's `summarize`) is listed by `get_core_memory` as the read-only block `summary`. What leaves the History window is saved as archival passages (`source == "history"`).
59
+ * Files (MemFS, the block `memfs` with the flags `agent_files` and `person_files` (and `agent_files_writable`, `person_files_writable`)): a tree of the agent and one for each person, every change a revision: `get_memfs_users(agent_id=None)`, `memfs_tree(user_id=None, agent_id=None)` (no `user_id`: the tree of the agent), `memfs_read(path, user_id=None, revision=None, agent_id=None)`, `memfs_write(path, content, description=None, ...)`, `memfs_move(src, dst, ...)`, `memfs_delete(path, ...)`, `memfs_history(path, ...)`, `memfs_revert(path, revision, ...)`, `memfs_search(query, user_id=None, limit=5, agent_id=None)`. Paths are written without the root (`notes/plan.md`).
60
+ * The chain of calls: `send_message(..., trace=True)` puts the steps in `response.trace`; `stream_message(..., trace=True)` collects them in `stream.trace` while the answer arrives. A `TraceStep` is a call to the model (`kind="model"`, with `text`, tokens, `duration_ms`), a tool the model called (`kind="tool"`, with `args`, `result` or `error`).
61
+ * Users made by agents: when an agent asks another, the called one talks to the user `agent_<caller>:<person>`, marked `User.is_agent`; with Chats on both agents its chat belongs to the caller's chat (`Chat.parent_chat_id`).
62
+ * Finding users: `get_recent_users(limit=20)` (who wrote lately: latest first, with `last_active` and `messages`; the window is the service's message TTL), `search_users(query, limit=10)` returns the users of your agent whose external id starts with `query` (at least 3 characters, else `InvalidRequest`; the service has no list of all users), sorted, for suggestions while someone types.
63
+ * Tokens: `get_self_token()`, `get_tokens(agent_id=None)`, `create_token(name, role, agent_id=None)` (the answer's `.token` is the secret, shown once), `update_token(id, name=None, role=None)` (rename and/or change the role), `rename_token(id, name)`, `delete_token(id)`. Roles, least to most: `regular`, `user`, `admin`, `owner`; an owner hands out any, an admin and a user `regular` and `user` (a user only for its own agent). A token is bound to an **agent**; there are no units any more.
64
+ * Usage of the models (no texts): `get_usage(days=30, token_id=None, agent_id=None)` by day, token and model; `get_usage_monthly(...)` for what is older (folded by month).
65
+ * Knowledge base: `search_rag()`, `list_rag()`, `create_rag()`, `create_rag_batch(items, agent_id=None)` (1 to 1024 entries in one request, all or none, answered in order; an item is a string or `{content, embedding_content, metadata}`), `create_rag_many(items, agent_id=None, batch_size=1024, concurrency=4)` (any number: batches that go several at a time, answered in order; a failed batch raises and the ones already made stay), `get_rag()`, `update_rag()`, `delete_rag()`.
66
+
67
+ ## Data Structures
68
+
69
+ The client receives data organized into the following structures:
70
+
71
+ * **Names**: agents, MCP servers and tokens require a `name` on create and accept an optional one on update. Models may omit the name or send an empty one: OpenRouter models use their display name from the catalog, while external models fall back to their model id. Explicitly empty model names and descriptions are refreshed from OpenRouter on update; omitted fields keep their values. Every object read back has a non-empty `name`; old entities without one get a fallback from the service (model -> its model name, agent -> the start of its prompt or `Agent <id>`, `MCP <id>`, `Unit <id>`). `get_self_unit()` and `get_self_agent()` use the main API, so they work without admin rights.
72
+ * **Token**: `role` (`regular|user|admin|owner`), `agent_id`, `is_initial` (the token of the service's `INITIAL_API_KEY`: an owner that cannot be deleted). What each role may do is decided by the service (`NoAccess` when a call is refused). An admin or owner may work as another agent: `Client(token, url, act_as_agent=7)` sends `X-Act-As-Agent` on every call (what is spent is still written on your own token).
73
+ * **User**: the system identification number, the agent's id, the external identification string and a timestamp.
74
+ * **Agent**: its `name`, the `model_id` of its model and its `blocks`.
75
+ * **AgentVersion**: Contains the agent, its number, a `snapshot` (blocks, model, MCP servers as they were), the comment, who made it and a timestamp.
76
+ * **Attachment**: A file for the model: `url` or base64 `data`, with `media_type` and an optional `name`.
77
+ * **CoreBlock**, **ArchivalPassage**, **RecallHit**, **MemoryUser**: the three tiers of memory (a core block: label, scope `person`|`agent`, limit, value; a passage: text, source, timestamp, and in a search `score` and `matched`). **MemfsEntry** / **MemfsFile** / **MemfsRevision** / **MemfsHit** / **MemfsUser**: the files.
78
+ * **Model**: Contains the identification number and the `request_json` sent to the model provider.
79
+ * **MCPServer**: Contains the identification number and the connection `config`.
80
+ * **MessageResponse**: Contains the text response from the artificial intelligence and the user information.
81
+ * **Message**: Contains the message identification number, user identification number, the text content, and a timestamp. The content is automatically converted from text format into a dictionary.
82
+
83
+ ## Connections
84
+
85
+ By default every call opens its own connection. For many calls use the client as an async context manager, which keeps one connection pool:
86
+
87
+ ```python
88
+ async with Client(token, base_url) as client:
89
+ for text in texts:
90
+ await client.send_message(user_id, text)
91
+ ```
92
+
93
+ `await client.aclose()` closes the pool by hand. `await client.ready()` asks the service whether it is up and its database is ready (no token needed).
94
+
95
+ ## Errors
96
+
97
+ All errors derive from `UnlinkError` and carry the explanation given by the service: `NoAccess` (401/403), `NotFound` (404), `Conflict` (409, for example deleting a model that an agent uses), `InvalidRequest` (422, rejected data), `StreamError` (a streamed answer failed midway) and `UpstreamError` (HTTP 502 or 504: the model provider or an MCP server failed or timed out; a retry may help), `RateLimited` (429: the agent's Rate limit; `retry_after` says in how many seconds to try again) and `ConnectionError` (the service cannot be reached).
98
+
99
+ ## Development
100
+
101
+ ```bash
102
+ uv sync # create .venv (with the dev group: pytest)
103
+ uv run pytest # unit tests and the contract test
104
+ uv build # build the wheel and sdist into dist/
105
+ ```
106
+
107
+ `tests/test_server_contract.py` compares the models of this library with `tests/server_openapi.json`, a snapshot of the service's OpenAPI schema. After a model changes in the service, regenerate the snapshot there (`uv run python scripts/dump_openapi.py <path to this file>`) and adapt the library until the tests pass.
@@ -0,0 +1,42 @@
1
+ import asyncio
2
+ import os
3
+
4
+ from omnixon import Client
5
+
6
+ client = Client(
7
+ os.environ["OMNIXON_TOKEN"], os.environ.get("OMNIXON_URL", "http://localhost:8083")
8
+ )
9
+
10
+ RATES = {"EUR": 1.0, "USD": 1.08, "RUB": 98.5, "JPY": 162.0}
11
+
12
+
13
+ @client.tool(record="full")
14
+ async def get_weather(city: str) -> dict:
15
+ """The weather in a city today."""
16
+ return {"city": city, "temperature_c": 18, "sky": "partly cloudy"}
17
+
18
+
19
+ @client.tool(record="hidden")
20
+ async def convert_currency(amount: float, source: str, target: str) -> dict:
21
+ """Convert money between currencies (EUR, USD, RUB, JPY)."""
22
+ value = amount / RATES[source.upper()] * RATES[target.upper()]
23
+ return {"amount": round(value, 2), "currency": target.upper()}
24
+
25
+
26
+ @client.tool(record="off")
27
+ async def book_hotel(city: str, nights: int) -> dict:
28
+ """Book a hotel room in a city."""
29
+ return {"booking": f"HTL-{city[:3].upper()}-{nights}", "status": "confirmed"}
30
+
31
+
32
+ async def main() -> None:
33
+ user = "tool-record-demo"
34
+
35
+ async with client:
36
+ while i := input("> "):
37
+ async for chunk in await client.send_message_stream(user, i):
38
+ print(chunk, end="")
39
+ print("\n", end="")
40
+
41
+
42
+ asyncio.run(main())
@@ -0,0 +1,91 @@
1
+ from .main import Client, MessageStream
2
+ from .schemes import (
3
+ User,
4
+ RecentUser,
5
+ Message,
6
+ MessageResponse,
7
+ TraceStep,
8
+ Agent,
9
+ EmbeddingStatus,
10
+ AgentVersion,
11
+ Attachment,
12
+ VersionDiff,
13
+ Block,
14
+ BlockType,
15
+ ClientTool,
16
+ ToolCall,
17
+ ToolRecord,
18
+ ToolResult,
19
+ Defaults,
20
+ Token,
21
+ NewToken,
22
+ Role,
23
+ ROLES,
24
+ DailyUsage,
25
+ MonthlyUsage,
26
+ RAG,
27
+ Model,
28
+ Interrupted,
29
+ Chat,
30
+ MCPServer,
31
+ CoreBlock,
32
+ ArchivalPassage,
33
+ RecallHit,
34
+ MemoryUser,
35
+ MemfsEntry,
36
+ MemfsFile,
37
+ MemfsRevision,
38
+ MemfsHit,
39
+ MemfsUser,
40
+ StoreStatus,
41
+ EvalCreate,
42
+ EvalResult,
43
+ EvalRun,
44
+ EvalVersionScore,
45
+ )
46
+
47
+ __all__ = [
48
+ "EvalCreate",
49
+ "EvalResult",
50
+ "EvalRun",
51
+ "EvalVersionScore",
52
+ "Client",
53
+ "MessageStream",
54
+ # Schemes
55
+ "User",
56
+ "RecentUser",
57
+ "Message",
58
+ "MessageResponse",
59
+ "TraceStep",
60
+ "Agent",
61
+ "EmbeddingStatus",
62
+ "AgentVersion",
63
+ "Attachment",
64
+ "VersionDiff",
65
+ "Block",
66
+ "BlockType",
67
+ "ClientTool",
68
+ "ToolResult",
69
+ "Defaults",
70
+ "Token",
71
+ "NewToken",
72
+ "Role",
73
+ "ROLES",
74
+ "DailyUsage",
75
+ "MonthlyUsage",
76
+ "RAG",
77
+ "Model",
78
+ "Interrupted",
79
+ "Chat",
80
+ "MCPServer",
81
+ "CoreBlock",
82
+ "ArchivalPassage",
83
+ "RecallHit",
84
+ "MemoryUser",
85
+ "MemfsEntry",
86
+ "MemfsFile",
87
+ "MemfsRevision",
88
+ "MemfsHit",
89
+ "MemfsUser",
90
+ "StoreStatus",
91
+ ]