kirha 0.1.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.
kirha-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kirha
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.
kirha-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,185 @@
1
+ Metadata-Version: 2.4
2
+ Name: kirha
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Kirha Search and Tools API
5
+ Keywords: kirha,search,ai,api,sdk
6
+ Author: Kirha
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Programming Language :: Python :: 3.15
18
+ Classifier: Typing :: Typed
19
+ Requires-Dist: httpx>=0.27,<1
20
+ Requires-Python: >=3.10
21
+ Project-URL: Homepage, https://kirha.com
22
+ Project-URL: Documentation, https://docs.kirha.com
23
+ Project-URL: Repository, https://github.com/kirha-ai/kirha-sdk-python
24
+ Description-Content-Type: text/markdown
25
+
26
+ <p align="center">
27
+ <img src="https://raw.githubusercontent.com/kirha-ai/kirha-sdk-python/main/assets/banner.png" alt="KIRHA SDK" />
28
+ </p>
29
+
30
+ <p align="center">
31
+ <a href="https://pypi.org/project/kirha/"><img src="https://img.shields.io/pypi/v/kirha" alt="PyPI version" /></a>
32
+ <a href="https://github.com/kirha-ai/kirha-sdk-python/actions/workflows/ci.yml"><img src="https://github.com/kirha-ai/kirha-sdk-python/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
33
+ <a href="https://pypi.org/project/kirha/"><img src="https://img.shields.io/pypi/pyversions/kirha" alt="Python versions" /></a>
34
+ </p>
35
+
36
+ <p align="center">
37
+ <a href="https://kirha.com"><b>🦋 KIRHA</b></a> •
38
+ <a href="https://docs.kirha.com"><b>📚 Documentation</b></a> •
39
+ <a href="https://app.kirha.com/auth/register"><b>🔑 Get an API key</b></a>
40
+ </p>
41
+
42
+ # KIRHA SDK
43
+
44
+ Python SDK for the KIRHA Search and Tools APIs.
45
+
46
+ ## Installation
47
+
48
+ ```bash
49
+ pip install kirha
50
+ # or
51
+ uv add kirha
52
+ # or
53
+ poetry add kirha
54
+ ```
55
+
56
+ Requires Python 3.10 or later.
57
+
58
+ ## Quick Start
59
+
60
+ ```python
61
+ from kirha import Kirha
62
+
63
+ kirha = Kirha()
64
+
65
+ result = kirha.search("What does the boxed warning on Ozempic say?")
66
+
67
+ print(result.data)
68
+ ```
69
+
70
+ `Kirha()` reads your API key from the `KIRHA_API_KEY` environment variable. Pass `api_key="..."` to set it in code.
71
+
72
+ ## Examples
73
+
74
+ ### Summarization
75
+
76
+ ```python
77
+ kirha = Kirha(api_key="your-api-key")
78
+
79
+ result = kirha.search("Summarize latest NVIDIA earning calls", summarization=True)
80
+
81
+ print(result.summary)
82
+ ```
83
+
84
+ ### Summarization with instruction
85
+
86
+ ```python
87
+ kirha = Kirha(api_key="your-api-key")
88
+
89
+ result = kirha.search(
90
+ "List top 10 stablecoins yield on Ethereum",
91
+ summarization={
92
+ "instruction": "Format the response as a table with columns: Stablecoin, Protocol, APY",
93
+ },
94
+ )
95
+
96
+ print(result.summary)
97
+ ```
98
+
99
+ ### Plan mode
100
+
101
+ Review the steps and the estimated cost before running the query, yourself or with an agent. Planning charges nothing until the plan runs.
102
+
103
+ ```python
104
+ kirha = Kirha(api_key="your-api-key")
105
+
106
+ plan = kirha.plan(
107
+ "Find engineers working at companies behind the latest patents in Machine Learning",
108
+ )
109
+
110
+ print(plan.steps)
111
+ print(plan.usage)
112
+
113
+ result = plan.execute()
114
+ print(result.data)
115
+ ```
116
+
117
+ A plan expires 5 minutes after it is created. To run it from another process, after an approval step for example, call `kirha.execute_plan(plan_id)`.
118
+
119
+ ### Direct tool calling
120
+
121
+ ```python
122
+ tools = kirha.tools(focus="crypto")
123
+ print([tool.name for tool in tools])
124
+
125
+ result = kirha.execute_tool("coingecko_searchCoin", {"query": "avax", "limit": 1})
126
+ print(result.output)
127
+ ```
128
+
129
+ ### Async
130
+
131
+ ```python
132
+ import asyncio
133
+
134
+ from kirha import AsyncKirha
135
+
136
+
137
+ async def main() -> None:
138
+ async with AsyncKirha() as kirha:
139
+ result = await kirha.search("What does the boxed warning on Ozempic say?")
140
+ print(result.data)
141
+
142
+
143
+ asyncio.run(main())
144
+ ```
145
+
146
+ `AsyncKirha` has the same methods as `Kirha`, as coroutines. Both clients keep a connection pool open, so close them with `close()` or use them as context managers.
147
+
148
+ ## Documentation
149
+
150
+ - [Search API](https://github.com/kirha-ai/kirha-sdk-python/blob/main/docs/search-api.md) - Full API reference
151
+ - [Examples](https://github.com/kirha-ai/kirha-sdk-python/tree/main/examples) - Runnable scripts
152
+
153
+ ## Errors
154
+
155
+ | Error | Description |
156
+ |-------|-------------|
157
+ | `KirhaError` | Base error class |
158
+ | `ConfigurationError` | No API key passed and `KIRHA_API_KEY` not set |
159
+ | `ApiError` | API returned an error, with `status_code`, `code` and `details` |
160
+ | `AuthenticationError` | Invalid or missing API key (401) |
161
+ | `RateLimitError` | Rate limit exceeded (429), with `retry_after` in seconds |
162
+ | `PlanExpiredError` | Plan expired (5 minute limit) |
163
+ | `NetworkError` | Network/connection failure or timeout |
164
+
165
+ `AuthenticationError` and `RateLimitError` extend `ApiError`.
166
+
167
+ ## Development
168
+
169
+ ```bash
170
+ uv sync
171
+ uv run pytest
172
+ ```
173
+
174
+ `uv run pytest -m integration` runs the tests against the live API with your `KIRHA_API_KEY`. They spend credits.
175
+
176
+ ### Releasing
177
+
178
+ 1. Bump `version` in `pyproject.toml` (for example `uv version 0.2.0`) and merge it to `main`.
179
+ 2. Create a GitHub release with a tag matching that version, prefixed with `v` (for example `v0.2.0`).
180
+
181
+ Publishing the release runs the [publish workflow](https://github.com/kirha-ai/kirha-sdk-python/actions/workflows/publish.yml), which checks that the tag matches the package version, runs the tests, builds the package and uploads it to [PyPI](https://pypi.org/project/kirha/) through trusted publishing. No PyPI token is stored in the repository.
182
+
183
+ ## License
184
+
185
+ MIT
kirha-0.1.0/README.md ADDED
@@ -0,0 +1,160 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/kirha-ai/kirha-sdk-python/main/assets/banner.png" alt="KIRHA SDK" />
3
+ </p>
4
+
5
+ <p align="center">
6
+ <a href="https://pypi.org/project/kirha/"><img src="https://img.shields.io/pypi/v/kirha" alt="PyPI version" /></a>
7
+ <a href="https://github.com/kirha-ai/kirha-sdk-python/actions/workflows/ci.yml"><img src="https://github.com/kirha-ai/kirha-sdk-python/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
8
+ <a href="https://pypi.org/project/kirha/"><img src="https://img.shields.io/pypi/pyversions/kirha" alt="Python versions" /></a>
9
+ </p>
10
+
11
+ <p align="center">
12
+ <a href="https://kirha.com"><b>🦋 KIRHA</b></a> •
13
+ <a href="https://docs.kirha.com"><b>📚 Documentation</b></a> •
14
+ <a href="https://app.kirha.com/auth/register"><b>🔑 Get an API key</b></a>
15
+ </p>
16
+
17
+ # KIRHA SDK
18
+
19
+ Python SDK for the KIRHA Search and Tools APIs.
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ pip install kirha
25
+ # or
26
+ uv add kirha
27
+ # or
28
+ poetry add kirha
29
+ ```
30
+
31
+ Requires Python 3.10 or later.
32
+
33
+ ## Quick Start
34
+
35
+ ```python
36
+ from kirha import Kirha
37
+
38
+ kirha = Kirha()
39
+
40
+ result = kirha.search("What does the boxed warning on Ozempic say?")
41
+
42
+ print(result.data)
43
+ ```
44
+
45
+ `Kirha()` reads your API key from the `KIRHA_API_KEY` environment variable. Pass `api_key="..."` to set it in code.
46
+
47
+ ## Examples
48
+
49
+ ### Summarization
50
+
51
+ ```python
52
+ kirha = Kirha(api_key="your-api-key")
53
+
54
+ result = kirha.search("Summarize latest NVIDIA earning calls", summarization=True)
55
+
56
+ print(result.summary)
57
+ ```
58
+
59
+ ### Summarization with instruction
60
+
61
+ ```python
62
+ kirha = Kirha(api_key="your-api-key")
63
+
64
+ result = kirha.search(
65
+ "List top 10 stablecoins yield on Ethereum",
66
+ summarization={
67
+ "instruction": "Format the response as a table with columns: Stablecoin, Protocol, APY",
68
+ },
69
+ )
70
+
71
+ print(result.summary)
72
+ ```
73
+
74
+ ### Plan mode
75
+
76
+ Review the steps and the estimated cost before running the query, yourself or with an agent. Planning charges nothing until the plan runs.
77
+
78
+ ```python
79
+ kirha = Kirha(api_key="your-api-key")
80
+
81
+ plan = kirha.plan(
82
+ "Find engineers working at companies behind the latest patents in Machine Learning",
83
+ )
84
+
85
+ print(plan.steps)
86
+ print(plan.usage)
87
+
88
+ result = plan.execute()
89
+ print(result.data)
90
+ ```
91
+
92
+ A plan expires 5 minutes after it is created. To run it from another process, after an approval step for example, call `kirha.execute_plan(plan_id)`.
93
+
94
+ ### Direct tool calling
95
+
96
+ ```python
97
+ tools = kirha.tools(focus="crypto")
98
+ print([tool.name for tool in tools])
99
+
100
+ result = kirha.execute_tool("coingecko_searchCoin", {"query": "avax", "limit": 1})
101
+ print(result.output)
102
+ ```
103
+
104
+ ### Async
105
+
106
+ ```python
107
+ import asyncio
108
+
109
+ from kirha import AsyncKirha
110
+
111
+
112
+ async def main() -> None:
113
+ async with AsyncKirha() as kirha:
114
+ result = await kirha.search("What does the boxed warning on Ozempic say?")
115
+ print(result.data)
116
+
117
+
118
+ asyncio.run(main())
119
+ ```
120
+
121
+ `AsyncKirha` has the same methods as `Kirha`, as coroutines. Both clients keep a connection pool open, so close them with `close()` or use them as context managers.
122
+
123
+ ## Documentation
124
+
125
+ - [Search API](https://github.com/kirha-ai/kirha-sdk-python/blob/main/docs/search-api.md) - Full API reference
126
+ - [Examples](https://github.com/kirha-ai/kirha-sdk-python/tree/main/examples) - Runnable scripts
127
+
128
+ ## Errors
129
+
130
+ | Error | Description |
131
+ |-------|-------------|
132
+ | `KirhaError` | Base error class |
133
+ | `ConfigurationError` | No API key passed and `KIRHA_API_KEY` not set |
134
+ | `ApiError` | API returned an error, with `status_code`, `code` and `details` |
135
+ | `AuthenticationError` | Invalid or missing API key (401) |
136
+ | `RateLimitError` | Rate limit exceeded (429), with `retry_after` in seconds |
137
+ | `PlanExpiredError` | Plan expired (5 minute limit) |
138
+ | `NetworkError` | Network/connection failure or timeout |
139
+
140
+ `AuthenticationError` and `RateLimitError` extend `ApiError`.
141
+
142
+ ## Development
143
+
144
+ ```bash
145
+ uv sync
146
+ uv run pytest
147
+ ```
148
+
149
+ `uv run pytest -m integration` runs the tests against the live API with your `KIRHA_API_KEY`. They spend credits.
150
+
151
+ ### Releasing
152
+
153
+ 1. Bump `version` in `pyproject.toml` (for example `uv version 0.2.0`) and merge it to `main`.
154
+ 2. Create a GitHub release with a tag matching that version, prefixed with `v` (for example `v0.2.0`).
155
+
156
+ Publishing the release runs the [publish workflow](https://github.com/kirha-ai/kirha-sdk-python/actions/workflows/publish.yml), which checks that the tag matches the package version, runs the tests, builds the package and uploads it to [PyPI](https://pypi.org/project/kirha/) through trusted publishing. No PyPI token is stored in the repository.
157
+
158
+ ## License
159
+
160
+ MIT
@@ -0,0 +1,63 @@
1
+ [project]
2
+ name = "kirha"
3
+ version = "0.1.0"
4
+ description = "Official Python SDK for the Kirha Search and Tools API"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.10"
9
+ dependencies = ["httpx>=0.27,<1"]
10
+ keywords = [
11
+ "kirha",
12
+ "search",
13
+ "ai",
14
+ "api",
15
+ "sdk",
16
+ ]
17
+ classifiers = [
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
26
+ "Programming Language :: Python :: 3.15",
27
+ "Typing :: Typed",
28
+ ]
29
+
30
+ [[project.authors]]
31
+ name = "Kirha"
32
+
33
+ [project.urls]
34
+ Homepage = "https://kirha.com"
35
+ Documentation = "https://docs.kirha.com"
36
+ Repository = "https://github.com/kirha-ai/kirha-sdk-python"
37
+
38
+ [build-system]
39
+ requires = ["uv_build>=0.13.0,<0.14"]
40
+ build-backend = "uv_build"
41
+
42
+ [dependency-groups]
43
+ dev = [
44
+ "mypy>=2.4.0",
45
+ "pytest>=9.1.1",
46
+ "respx>=0.23.1",
47
+ "ruff>=0.17.0",
48
+ ]
49
+
50
+ [tool.mypy]
51
+ strict = true
52
+ files = [
53
+ "src",
54
+ "tests",
55
+ "examples",
56
+ ]
57
+
58
+ [tool.pytest]
59
+ addopts = [
60
+ "-m",
61
+ "not integration",
62
+ ]
63
+ markers = ["integration: calls the live Kirha API, needs KIRHA_API_KEY"]
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "kirha"
3
+ version = "0.1.0"
4
+ description = "Official Python SDK for the Kirha Search and Tools API"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Kirha" }]
9
+ requires-python = ">=3.10"
10
+ dependencies = ["httpx>=0.27,<1"]
11
+ keywords = ["kirha", "search", "ai", "api", "sdk"]
12
+ classifiers = [
13
+ "Intended Audience :: Developers",
14
+ "Operating System :: OS Independent",
15
+ "Programming Language :: Python :: 3 :: Only",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ "Programming Language :: Python :: 3.15",
22
+ "Typing :: Typed",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://kirha.com"
27
+ Documentation = "https://docs.kirha.com"
28
+ Repository = "https://github.com/kirha-ai/kirha-sdk-python"
29
+
30
+ [build-system]
31
+ requires = ["uv_build>=0.13.0,<0.14"]
32
+ build-backend = "uv_build"
33
+
34
+ [dependency-groups]
35
+ dev = ["mypy>=2.4.0", "pytest>=9.1.1", "respx>=0.23.1", "ruff>=0.17.0"]
36
+
37
+ [tool.mypy]
38
+ strict = true
39
+ files = ["src", "tests", "examples"]
40
+
41
+ [tool.pytest]
42
+ addopts = ["-m", "not integration"]
43
+ markers = ["integration: calls the live Kirha API, needs KIRHA_API_KEY"]
@@ -0,0 +1,44 @@
1
+ from ._client import AsyncKirha, AsyncPlan, Kirha, Plan
2
+ from ._errors import (
3
+ ApiError,
4
+ AuthenticationError,
5
+ ConfigurationError,
6
+ KirhaError,
7
+ NetworkError,
8
+ PlanExpiredError,
9
+ RateLimitError,
10
+ )
11
+ from ._models import (
12
+ Account,
13
+ Planning,
14
+ PlanningRuntime,
15
+ PlanStep,
16
+ SearchResult,
17
+ Summarization,
18
+ Tool,
19
+ ToolExecutionResult,
20
+ Usage,
21
+ )
22
+
23
+ __all__ = [
24
+ "Account",
25
+ "ApiError",
26
+ "AsyncKirha",
27
+ "AsyncPlan",
28
+ "AuthenticationError",
29
+ "ConfigurationError",
30
+ "Kirha",
31
+ "KirhaError",
32
+ "NetworkError",
33
+ "Plan",
34
+ "PlanExpiredError",
35
+ "PlanStep",
36
+ "Planning",
37
+ "PlanningRuntime",
38
+ "RateLimitError",
39
+ "SearchResult",
40
+ "Summarization",
41
+ "Tool",
42
+ "ToolExecutionResult",
43
+ "Usage",
44
+ ]
@@ -0,0 +1,427 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING, Any, TypeVar
7
+
8
+ import httpx
9
+
10
+ if TYPE_CHECKING:
11
+ from typing_extensions import Self
12
+
13
+ from ._errors import (
14
+ ApiError,
15
+ AuthenticationError,
16
+ ConfigurationError,
17
+ NetworkError,
18
+ PlanExpiredError,
19
+ RateLimitError,
20
+ )
21
+ from ._models import (
22
+ Account,
23
+ PlanningRuntime,
24
+ PlanStep,
25
+ SearchResult,
26
+ Summarization,
27
+ Tool,
28
+ ToolExecutionResult,
29
+ Usage,
30
+ parse_plan,
31
+ parse_search_result,
32
+ parse_tool_execution_result,
33
+ parse_tools,
34
+ )
35
+
36
+ BASE_URL = "https://api.kirha.com"
37
+ DEFAULT_TIMEOUT = 120.0
38
+ PLAN_EXPIRED_CODE = "CONVERSATION_TURN_TIMEOUT"
39
+
40
+ T = TypeVar("T")
41
+
42
+
43
+ @dataclass(frozen=True, kw_only=True)
44
+ class _PlanFields:
45
+ id: str
46
+ query: str
47
+ focus: str | None
48
+ status: str
49
+ steps: list[PlanStep]
50
+ usage: Usage
51
+ account: Account
52
+ reason: str | None = None
53
+
54
+
55
+ @dataclass(frozen=True, kw_only=True)
56
+ class Plan(_PlanFields):
57
+ _client: Kirha = field(repr=False, compare=False)
58
+ _summarization: bool | Summarization | None = field(
59
+ default=None, repr=False, compare=False
60
+ )
61
+
62
+ def execute(
63
+ self,
64
+ *,
65
+ summarization: bool | Summarization | None = None,
66
+ include_data: bool | None = None,
67
+ include_planning: bool | None = None,
68
+ ) -> SearchResult:
69
+ if summarization is None:
70
+ summarization = self._summarization
71
+ return self._client.execute_plan(
72
+ self.id,
73
+ summarization=summarization,
74
+ include_data=include_data,
75
+ include_planning=include_planning,
76
+ )
77
+
78
+
79
+ @dataclass(frozen=True, kw_only=True)
80
+ class AsyncPlan(_PlanFields):
81
+ _client: AsyncKirha = field(repr=False, compare=False)
82
+ _summarization: bool | Summarization | None = field(
83
+ default=None, repr=False, compare=False
84
+ )
85
+
86
+ async def execute(
87
+ self,
88
+ *,
89
+ summarization: bool | Summarization | None = None,
90
+ include_data: bool | None = None,
91
+ include_planning: bool | None = None,
92
+ ) -> SearchResult:
93
+ if summarization is None:
94
+ summarization = self._summarization
95
+ return await self._client.execute_plan(
96
+ self.id,
97
+ summarization=summarization,
98
+ include_data=include_data,
99
+ include_planning=include_planning,
100
+ )
101
+
102
+
103
+ class _BaseClient:
104
+ def __init__(
105
+ self,
106
+ api_key: str | None,
107
+ focus: str | None,
108
+ summarization: bool | Summarization | None,
109
+ ) -> None:
110
+ api_key = api_key or os.environ.get("KIRHA_API_KEY")
111
+ if not api_key:
112
+ raise ConfigurationError(
113
+ "api_key is required: pass it or set KIRHA_API_KEY"
114
+ )
115
+ self._headers = {"Authorization": f"Bearer {api_key}"}
116
+ self._focus = focus
117
+ self._summarization = summarization
118
+
119
+ def _search_body(
120
+ self,
121
+ query: str,
122
+ focus: str | None,
123
+ summarization: bool | Summarization | None,
124
+ include_data: bool | None,
125
+ include_planning: bool | None,
126
+ planning_runtime: PlanningRuntime | None,
127
+ ) -> dict[str, Any]:
128
+ return _compact(
129
+ query=query,
130
+ focus=self._resolve_focus(focus),
131
+ summarization=self._summarization_body(summarization),
132
+ include_raw_data=include_data,
133
+ include_planning=include_planning,
134
+ planning_runtime=planning_runtime,
135
+ )
136
+
137
+ def _plan_body(
138
+ self, query: str, focus: str | None, planning_runtime: PlanningRuntime | None
139
+ ) -> dict[str, Any]:
140
+ return _compact(
141
+ query=query,
142
+ focus=self._resolve_focus(focus),
143
+ planning_runtime=planning_runtime,
144
+ )
145
+
146
+ def _run_body(
147
+ self,
148
+ plan_id: str,
149
+ summarization: bool | Summarization | None,
150
+ include_data: bool | None,
151
+ include_planning: bool | None,
152
+ ) -> dict[str, Any]:
153
+ return _compact(
154
+ id=plan_id,
155
+ summarization=self._summarization_body(summarization),
156
+ include_raw_data=include_data,
157
+ include_planning=include_planning,
158
+ )
159
+
160
+ def _resolve_focus(self, focus: str | None) -> str | None:
161
+ return (self._focus if focus is None else focus) or None
162
+
163
+ def _summarization_body(
164
+ self, summarization: bool | Summarization | None
165
+ ) -> dict[str, Any] | None:
166
+ value = self._summarization if summarization is None else summarization
167
+ if isinstance(value, dict):
168
+ return _compact(enable=True, instruction=value.get("instruction"))
169
+ return {"enable": True} if value else None
170
+
171
+
172
+ class Kirha(_BaseClient):
173
+ def __init__(
174
+ self,
175
+ api_key: str | None = None,
176
+ *,
177
+ focus: str | None = None,
178
+ summarization: bool | Summarization | None = None,
179
+ timeout: float = DEFAULT_TIMEOUT,
180
+ ) -> None:
181
+ super().__init__(api_key, focus, summarization)
182
+ self._http = httpx.Client(
183
+ base_url=BASE_URL, headers=self._headers, timeout=timeout
184
+ )
185
+
186
+ def search(
187
+ self,
188
+ query: str,
189
+ *,
190
+ focus: str | None = None,
191
+ summarization: bool | Summarization | None = None,
192
+ include_data: bool | None = None,
193
+ include_planning: bool | None = None,
194
+ planning_runtime: PlanningRuntime | None = None,
195
+ ) -> SearchResult:
196
+ body = self._search_body(
197
+ query,
198
+ focus,
199
+ summarization,
200
+ include_data,
201
+ include_planning,
202
+ planning_runtime,
203
+ )
204
+ return self._request("POST", "/search/v1", parse_search_result, json=body)
205
+
206
+ def plan(
207
+ self,
208
+ query: str,
209
+ *,
210
+ focus: str | None = None,
211
+ summarization: bool | Summarization | None = None,
212
+ planning_runtime: PlanningRuntime | None = None,
213
+ ) -> Plan:
214
+ body = self._plan_body(query, focus, planning_runtime)
215
+ fields = self._request("POST", "/search/v1/plan", parse_plan, json=body)
216
+ return Plan(
217
+ **fields,
218
+ query=query,
219
+ focus=body.get("focus"),
220
+ _client=self,
221
+ _summarization=summarization,
222
+ )
223
+
224
+ def execute_plan(
225
+ self,
226
+ plan_id: str,
227
+ *,
228
+ summarization: bool | Summarization | None = None,
229
+ include_data: bool | None = None,
230
+ include_planning: bool | None = None,
231
+ ) -> SearchResult:
232
+ body = self._run_body(plan_id, summarization, include_data, include_planning)
233
+ try:
234
+ return self._request(
235
+ "POST", "/search/v1/plan/run", parse_search_result, json=body
236
+ )
237
+ except ApiError as error:
238
+ if error.code == PLAN_EXPIRED_CODE:
239
+ raise PlanExpiredError(plan_id) from error
240
+ raise
241
+
242
+ def tools(self, *, focus: str) -> list[Tool]:
243
+ return self._request(
244
+ "GET", "/tools/v1", parse_tools, params={"vertical_id": focus}
245
+ )
246
+
247
+ def execute_tool(
248
+ self, tool_name: str, input: dict[str, Any] | None = None
249
+ ) -> ToolExecutionResult:
250
+ body = {"tool_name": tool_name, "input": input or {}}
251
+ return self._request(
252
+ "POST", "/tools/v1/execute", parse_tool_execution_result, json=body
253
+ )
254
+
255
+ def close(self) -> None:
256
+ self._http.close()
257
+
258
+ def __enter__(self) -> Self:
259
+ return self
260
+
261
+ def __exit__(self, *_: object) -> None:
262
+ self.close()
263
+
264
+ def _request(
265
+ self, method: str, path: str, parse: Callable[[Any], T], **kwargs: Any
266
+ ) -> T:
267
+ try:
268
+ response = self._http.request(method, path, **kwargs)
269
+ except httpx.TransportError as error:
270
+ raise NetworkError(
271
+ f"Request to {BASE_URL}{path} failed: {error!r}"
272
+ ) from error
273
+ return _handle(response, parse)
274
+
275
+
276
+ class AsyncKirha(_BaseClient):
277
+ def __init__(
278
+ self,
279
+ api_key: str | None = None,
280
+ *,
281
+ focus: str | None = None,
282
+ summarization: bool | Summarization | None = None,
283
+ timeout: float = DEFAULT_TIMEOUT,
284
+ ) -> None:
285
+ super().__init__(api_key, focus, summarization)
286
+ self._http = httpx.AsyncClient(
287
+ base_url=BASE_URL, headers=self._headers, timeout=timeout
288
+ )
289
+
290
+ async def search(
291
+ self,
292
+ query: str,
293
+ *,
294
+ focus: str | None = None,
295
+ summarization: bool | Summarization | None = None,
296
+ include_data: bool | None = None,
297
+ include_planning: bool | None = None,
298
+ planning_runtime: PlanningRuntime | None = None,
299
+ ) -> SearchResult:
300
+ body = self._search_body(
301
+ query,
302
+ focus,
303
+ summarization,
304
+ include_data,
305
+ include_planning,
306
+ planning_runtime,
307
+ )
308
+ return await self._request("POST", "/search/v1", parse_search_result, json=body)
309
+
310
+ async def plan(
311
+ self,
312
+ query: str,
313
+ *,
314
+ focus: str | None = None,
315
+ summarization: bool | Summarization | None = None,
316
+ planning_runtime: PlanningRuntime | None = None,
317
+ ) -> AsyncPlan:
318
+ body = self._plan_body(query, focus, planning_runtime)
319
+ fields = await self._request("POST", "/search/v1/plan", parse_plan, json=body)
320
+ return AsyncPlan(
321
+ **fields,
322
+ query=query,
323
+ focus=body.get("focus"),
324
+ _client=self,
325
+ _summarization=summarization,
326
+ )
327
+
328
+ async def execute_plan(
329
+ self,
330
+ plan_id: str,
331
+ *,
332
+ summarization: bool | Summarization | None = None,
333
+ include_data: bool | None = None,
334
+ include_planning: bool | None = None,
335
+ ) -> SearchResult:
336
+ body = self._run_body(plan_id, summarization, include_data, include_planning)
337
+ try:
338
+ return await self._request(
339
+ "POST", "/search/v1/plan/run", parse_search_result, json=body
340
+ )
341
+ except ApiError as error:
342
+ if error.code == PLAN_EXPIRED_CODE:
343
+ raise PlanExpiredError(plan_id) from error
344
+ raise
345
+
346
+ async def tools(self, *, focus: str) -> list[Tool]:
347
+ return await self._request(
348
+ "GET", "/tools/v1", parse_tools, params={"vertical_id": focus}
349
+ )
350
+
351
+ async def execute_tool(
352
+ self, tool_name: str, input: dict[str, Any] | None = None
353
+ ) -> ToolExecutionResult:
354
+ body = {"tool_name": tool_name, "input": input or {}}
355
+ return await self._request(
356
+ "POST", "/tools/v1/execute", parse_tool_execution_result, json=body
357
+ )
358
+
359
+ async def close(self) -> None:
360
+ await self._http.aclose()
361
+
362
+ async def __aenter__(self) -> Self:
363
+ return self
364
+
365
+ async def __aexit__(self, *_: object) -> None:
366
+ await self.close()
367
+
368
+ async def _request(
369
+ self, method: str, path: str, parse: Callable[[Any], T], **kwargs: Any
370
+ ) -> T:
371
+ try:
372
+ response = await self._http.request(method, path, **kwargs)
373
+ except httpx.TransportError as error:
374
+ raise NetworkError(
375
+ f"Request to {BASE_URL}{path} failed: {error!r}"
376
+ ) from error
377
+ return _handle(response, parse)
378
+
379
+
380
+ def _compact(**fields: Any) -> dict[str, Any]:
381
+ return {key: value for key, value in fields.items() if value is not None}
382
+
383
+
384
+ def _handle(response: httpx.Response, parse: Callable[[Any], T]) -> T:
385
+ if not response.is_success:
386
+ raise _api_error(response)
387
+ try:
388
+ return parse(response.json())
389
+ except (ValueError, KeyError, TypeError, AttributeError) as error:
390
+ raise ApiError(
391
+ "Unexpected response from the Kirha API",
392
+ response.status_code,
393
+ "invalid_response",
394
+ response.text,
395
+ ) from error
396
+
397
+
398
+ def _api_error(response: httpx.Response) -> ApiError:
399
+ status = response.status_code
400
+ try:
401
+ body = response.json()
402
+ except ValueError:
403
+ body = None
404
+
405
+ nested = body.get("error") if isinstance(body, dict) else None
406
+ if isinstance(nested, dict) and "message" in nested:
407
+ message = nested["message"]
408
+ code = nested.get("code", "unknown_error")
409
+ details = nested.get("details")
410
+ elif isinstance(body, dict) and "reason" in body:
411
+ message, code, details = body["reason"], body.get("type", "unknown_error"), None
412
+ else:
413
+ message = f"HTTP {status}: {response.reason_phrase}"
414
+ code, details = "unknown_error", body
415
+
416
+ if status == 401:
417
+ return AuthenticationError(message, status, code, details)
418
+ if status == 429:
419
+ retry_after = response.headers.get("Retry-After", "")
420
+ return RateLimitError(
421
+ message,
422
+ status,
423
+ code,
424
+ details,
425
+ int(retry_after) if retry_after.isdigit() else None,
426
+ )
427
+ return ApiError(message, status, code, details)
@@ -0,0 +1,46 @@
1
+ from typing import Any
2
+
3
+
4
+ class KirhaError(Exception):
5
+ pass
6
+
7
+
8
+ class ConfigurationError(KirhaError):
9
+ pass
10
+
11
+
12
+ class ApiError(KirhaError):
13
+ def __init__(
14
+ self, message: str, status_code: int, code: str, details: Any = None
15
+ ) -> None:
16
+ super().__init__(message)
17
+ self.status_code = status_code
18
+ self.code = code
19
+ self.details = details
20
+
21
+
22
+ class AuthenticationError(ApiError):
23
+ pass
24
+
25
+
26
+ class RateLimitError(ApiError):
27
+ def __init__(
28
+ self,
29
+ message: str,
30
+ status_code: int,
31
+ code: str,
32
+ details: Any = None,
33
+ retry_after: int | None = None,
34
+ ) -> None:
35
+ super().__init__(message, status_code, code, details)
36
+ self.retry_after = retry_after
37
+
38
+
39
+ class PlanExpiredError(KirhaError):
40
+ def __init__(self, plan_id: str) -> None:
41
+ super().__init__(f"Plan {plan_id} has expired (plans are valid for 5 minutes)")
42
+ self.plan_id = plan_id
43
+
44
+
45
+ class NetworkError(KirhaError):
46
+ pass
@@ -0,0 +1,137 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Any, Literal, TypedDict
3
+
4
+ PlanningRuntime = Literal["standard", "fast"]
5
+
6
+
7
+ class Summarization(TypedDict, total=False):
8
+ instruction: str
9
+
10
+
11
+ @dataclass(frozen=True, slots=True, kw_only=True)
12
+ class Usage:
13
+ estimated: float
14
+ consumed: float
15
+
16
+
17
+ @dataclass(frozen=True, slots=True, kw_only=True)
18
+ class Account:
19
+ balance: float
20
+ balance_timestamp: str
21
+
22
+
23
+ @dataclass(frozen=True, slots=True, kw_only=True)
24
+ class PlanStep:
25
+ id: str
26
+ status: str
27
+ tool_name: str
28
+ parameters: dict[str, Any]
29
+ reasoning: str | None = None
30
+
31
+
32
+ @dataclass(frozen=True, slots=True, kw_only=True)
33
+ class Planning:
34
+ status: str
35
+ steps: list[PlanStep]
36
+ reason: str | None = None
37
+
38
+
39
+ @dataclass(frozen=True, slots=True, kw_only=True)
40
+ class SearchResult:
41
+ id: str
42
+ usage: Usage
43
+ account: Account
44
+ summary: str | None = None
45
+ data: list[dict[str, Any]] | None = None
46
+ planning: Planning | None = None
47
+
48
+
49
+ @dataclass(frozen=True, slots=True, kw_only=True)
50
+ class Tool:
51
+ id: str
52
+ name: str
53
+ description: str
54
+ input_schema: dict[str, Any] = field(default_factory=dict)
55
+ output_schema: dict[str, Any] = field(default_factory=dict)
56
+
57
+
58
+ @dataclass(frozen=True, slots=True, kw_only=True)
59
+ class ToolExecutionResult:
60
+ tool_name: str
61
+ output: Any
62
+ is_error: bool
63
+ usage: Usage
64
+ error_message: str | None = None
65
+
66
+
67
+ def parse_usage(data: Any) -> Usage:
68
+ return Usage(estimated=data["estimated"], consumed=data["consumed"])
69
+
70
+
71
+ def parse_account(data: Any) -> Account:
72
+ return Account(balance=data["balance"], balance_timestamp=data["balance_timestamp"])
73
+
74
+
75
+ def parse_steps(data: Any) -> list[PlanStep]:
76
+ return [
77
+ PlanStep(
78
+ id=step["id"],
79
+ status=step["status"],
80
+ tool_name=step["tool_name"],
81
+ parameters=step["parameters"],
82
+ reasoning=step.get("reasoning"),
83
+ )
84
+ for step in data
85
+ ]
86
+
87
+
88
+ def parse_search_result(data: Any) -> SearchResult:
89
+ planning = data.get("planning")
90
+ return SearchResult(
91
+ id=data["id"],
92
+ usage=parse_usage(data["usage"]),
93
+ account=parse_account(data["account"]),
94
+ summary=data.get("summary"),
95
+ data=data.get("raw_data"),
96
+ planning=Planning(
97
+ status=planning["status"],
98
+ steps=parse_steps(planning["steps"]),
99
+ reason=planning.get("reason"),
100
+ )
101
+ if planning
102
+ else None,
103
+ )
104
+
105
+
106
+ def parse_plan(data: Any) -> dict[str, Any]:
107
+ return {
108
+ "id": data["id"],
109
+ "status": data["status"],
110
+ "steps": parse_steps(data["steps"]),
111
+ "usage": parse_usage(data["usage"]),
112
+ "account": parse_account(data["account"]),
113
+ "reason": data.get("reason"),
114
+ }
115
+
116
+
117
+ def parse_tools(data: Any) -> list[Tool]:
118
+ return [
119
+ Tool(
120
+ id=tool["id"],
121
+ name=tool["name"],
122
+ description=tool["description"],
123
+ input_schema=tool.get("input_schema") or {},
124
+ output_schema=tool.get("output_schema") or {},
125
+ )
126
+ for tool in data
127
+ ]
128
+
129
+
130
+ def parse_tool_execution_result(data: Any) -> ToolExecutionResult:
131
+ return ToolExecutionResult(
132
+ tool_name=data["tool_name"],
133
+ output=data["output"],
134
+ is_error=data["is_error"],
135
+ usage=parse_usage(data["usage"]),
136
+ error_message=data.get("error_message"),
137
+ )
File without changes