cominty-sdk 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.
- cominty_sdk-0.1.0/.gitignore +10 -0
- cominty_sdk-0.1.0/PKG-INFO +166 -0
- cominty_sdk-0.1.0/README.md +137 -0
- cominty_sdk-0.1.0/pyproject.toml +70 -0
- cominty_sdk-0.1.0/src/cominty_sdk/__init__.py +50 -0
- cominty_sdk-0.1.0/src/cominty_sdk/_http.py +169 -0
- cominty_sdk-0.1.0/src/cominty_sdk/_qa.py +74 -0
- cominty_sdk-0.1.0/src/cominty_sdk/_streaming.py +32 -0
- cominty_sdk-0.1.0/src/cominty_sdk/client.py +106 -0
- cominty_sdk-0.1.0/src/cominty_sdk/config.py +29 -0
- cominty_sdk-0.1.0/src/cominty_sdk/exceptions.py +89 -0
- cominty_sdk-0.1.0/src/cominty_sdk/models/__init__.py +38 -0
- cominty_sdk-0.1.0/src/cominty_sdk/models/files.py +30 -0
- cominty_sdk-0.1.0/src/cominty_sdk/models/messages.py +149 -0
- cominty_sdk-0.1.0/src/cominty_sdk/models/threads.py +47 -0
- cominty_sdk-0.1.0/src/cominty_sdk/models/usage.py +46 -0
- cominty_sdk-0.1.0/src/cominty_sdk/resources/chat.py +85 -0
- cominty_sdk-0.1.0/src/cominty_sdk/resources/files.py +84 -0
- cominty_sdk-0.1.0/src/cominty_sdk/resources/messages.py +136 -0
- cominty_sdk-0.1.0/src/cominty_sdk/resources/threads.py +50 -0
- cominty_sdk-0.1.0/src/cominty_sdk/resources/usage.py +14 -0
- cominty_sdk-0.1.0/src/cominty_sdk/retry.py +76 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cominty-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official async Python client for the Cominty managed agent chat API
|
|
5
|
+
Project-URL: Homepage, https://github.com/cominty/python-sdk
|
|
6
|
+
Project-URL: Repository, https://github.com/cominty/python-sdk
|
|
7
|
+
Author: Cominty
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: agent,chat,cominty,sdk
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: httpx>=0.27
|
|
20
|
+
Requires-Dist: pydantic-settings>=2
|
|
21
|
+
Requires-Dist: pydantic>=2
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
26
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
27
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# Cominty Python SDK
|
|
31
|
+
|
|
32
|
+
Official async Python client for the Cominty managed agent chat API.
|
|
33
|
+
|
|
34
|
+
## Requirements
|
|
35
|
+
|
|
36
|
+
- Python 3.11+
|
|
37
|
+
- A Cominty API key
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install cominty-sdk
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Or with [uv](https://docs.astral.sh/uv/):
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
uv add cominty-sdk
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Configuration
|
|
52
|
+
|
|
53
|
+
| Variable | Description |
|
|
54
|
+
|----------|-------------|
|
|
55
|
+
| `COMINTY_API_KEY` | API key (required) |
|
|
56
|
+
| `COMINTY_API_URL` | Override base URL (optional, takes priority) |
|
|
57
|
+
| `COMINTY_ENVIRONMENT` | `dev`, `staging`, or `production` (default: `production`) |
|
|
58
|
+
| `COMINTY_AGENT_ID` | Default agent ID for chat operations |
|
|
59
|
+
| `COMINTY_MAX_RETRIES` | Max retries on transient errors (default: `3`) |
|
|
60
|
+
| `COMINTY_TIMEOUT` | Request timeout in seconds (default: `60`) |
|
|
61
|
+
|
|
62
|
+
Default base URLs are placeholders and can be overridden with `COMINTY_API_URL`:
|
|
63
|
+
|
|
64
|
+
- `dev`: `https://api.dev.cominty.com`
|
|
65
|
+
- `staging`: `https://api.staging.cominty.com`
|
|
66
|
+
- `production`: `https://api.cominty.com`
|
|
67
|
+
|
|
68
|
+
Authentication uses the `x-cominty-token` header.
|
|
69
|
+
|
|
70
|
+
## Quick start
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import asyncio
|
|
74
|
+
|
|
75
|
+
from cominty_sdk import AsyncCominty, HumanMessage
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def main() -> None:
|
|
79
|
+
async with AsyncCominty() as client:
|
|
80
|
+
thread, reply = await client.chat.start_and_wait(
|
|
81
|
+
HumanMessage(content="What is Cominty?"),
|
|
82
|
+
agent_id="your-agent-id",
|
|
83
|
+
)
|
|
84
|
+
print(reply.content)
|
|
85
|
+
print(reply.tool_names)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
asyncio.run(main())
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Send a message in an existing thread
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
message = await client.messages.send_and_wait(
|
|
95
|
+
thread_id=thread.id,
|
|
96
|
+
message=HumanMessage(
|
|
97
|
+
content="Search our docs for onboarding steps",
|
|
98
|
+
source_ids=[42],
|
|
99
|
+
disabled_tools=["web"],
|
|
100
|
+
),
|
|
101
|
+
agent_id="your-agent-id",
|
|
102
|
+
)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Upload a file
|
|
106
|
+
|
|
107
|
+
Upload is a single high-level call that performs the 3-step S3 flow internally:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
file_id = await client.files.upload("report.pdf")
|
|
111
|
+
|
|
112
|
+
await client.messages.send_and_wait(
|
|
113
|
+
thread_id=thread.id,
|
|
114
|
+
message=HumanMessage(content="Summarize this file", file_ids=[file_id]),
|
|
115
|
+
agent_id="your-agent-id",
|
|
116
|
+
)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Streaming
|
|
120
|
+
|
|
121
|
+
The API returns JSONL events on the stream endpoint:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
async for event in client.messages.stream(message.id):
|
|
125
|
+
print(event)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## QA helpers
|
|
129
|
+
|
|
130
|
+
`MessageOut` exposes convenience accessors for automated QA:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
reply.tool_names # tools invoked (from events)
|
|
134
|
+
reply.cite_tags # raw <cite .../> tags
|
|
135
|
+
reply.document_citations # parsed document citations
|
|
136
|
+
reply.web_citations # parsed web citations
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Covered endpoints
|
|
140
|
+
|
|
141
|
+
| Resource | Methods |
|
|
142
|
+
|----------|---------|
|
|
143
|
+
| Threads | `list`, `get`, `update`, `archive` |
|
|
144
|
+
| Chat | `start`, `start_and_wait` |
|
|
145
|
+
| Messages | `send`, `send_and_wait`, `wait_until_done`, `cancel`, `export`, `stream` |
|
|
146
|
+
| Files | `upload`, `download` |
|
|
147
|
+
| Usage | `get` |
|
|
148
|
+
|
|
149
|
+
## Development
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
uv sync --all-extras --dev
|
|
153
|
+
uv run pytest
|
|
154
|
+
uv run ruff check .
|
|
155
|
+
uv run mypy
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Integration tests are opt-in:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
COMINTY_API_KEY=... COMINTY_AGENT_ID=... uv run pytest -m integration
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## License
|
|
165
|
+
|
|
166
|
+
MIT
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Cominty Python SDK
|
|
2
|
+
|
|
3
|
+
Official async Python client for the Cominty managed agent chat API.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Python 3.11+
|
|
8
|
+
- A Cominty API key
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install cominty-sdk
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or with [uv](https://docs.astral.sh/uv/):
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uv add cominty-sdk
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Configuration
|
|
23
|
+
|
|
24
|
+
| Variable | Description |
|
|
25
|
+
|----------|-------------|
|
|
26
|
+
| `COMINTY_API_KEY` | API key (required) |
|
|
27
|
+
| `COMINTY_API_URL` | Override base URL (optional, takes priority) |
|
|
28
|
+
| `COMINTY_ENVIRONMENT` | `dev`, `staging`, or `production` (default: `production`) |
|
|
29
|
+
| `COMINTY_AGENT_ID` | Default agent ID for chat operations |
|
|
30
|
+
| `COMINTY_MAX_RETRIES` | Max retries on transient errors (default: `3`) |
|
|
31
|
+
| `COMINTY_TIMEOUT` | Request timeout in seconds (default: `60`) |
|
|
32
|
+
|
|
33
|
+
Default base URLs are placeholders and can be overridden with `COMINTY_API_URL`:
|
|
34
|
+
|
|
35
|
+
- `dev`: `https://api.dev.cominty.com`
|
|
36
|
+
- `staging`: `https://api.staging.cominty.com`
|
|
37
|
+
- `production`: `https://api.cominty.com`
|
|
38
|
+
|
|
39
|
+
Authentication uses the `x-cominty-token` header.
|
|
40
|
+
|
|
41
|
+
## Quick start
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import asyncio
|
|
45
|
+
|
|
46
|
+
from cominty_sdk import AsyncCominty, HumanMessage
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def main() -> None:
|
|
50
|
+
async with AsyncCominty() as client:
|
|
51
|
+
thread, reply = await client.chat.start_and_wait(
|
|
52
|
+
HumanMessage(content="What is Cominty?"),
|
|
53
|
+
agent_id="your-agent-id",
|
|
54
|
+
)
|
|
55
|
+
print(reply.content)
|
|
56
|
+
print(reply.tool_names)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
asyncio.run(main())
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Send a message in an existing thread
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
message = await client.messages.send_and_wait(
|
|
66
|
+
thread_id=thread.id,
|
|
67
|
+
message=HumanMessage(
|
|
68
|
+
content="Search our docs for onboarding steps",
|
|
69
|
+
source_ids=[42],
|
|
70
|
+
disabled_tools=["web"],
|
|
71
|
+
),
|
|
72
|
+
agent_id="your-agent-id",
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Upload a file
|
|
77
|
+
|
|
78
|
+
Upload is a single high-level call that performs the 3-step S3 flow internally:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
file_id = await client.files.upload("report.pdf")
|
|
82
|
+
|
|
83
|
+
await client.messages.send_and_wait(
|
|
84
|
+
thread_id=thread.id,
|
|
85
|
+
message=HumanMessage(content="Summarize this file", file_ids=[file_id]),
|
|
86
|
+
agent_id="your-agent-id",
|
|
87
|
+
)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Streaming
|
|
91
|
+
|
|
92
|
+
The API returns JSONL events on the stream endpoint:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
async for event in client.messages.stream(message.id):
|
|
96
|
+
print(event)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## QA helpers
|
|
100
|
+
|
|
101
|
+
`MessageOut` exposes convenience accessors for automated QA:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
reply.tool_names # tools invoked (from events)
|
|
105
|
+
reply.cite_tags # raw <cite .../> tags
|
|
106
|
+
reply.document_citations # parsed document citations
|
|
107
|
+
reply.web_citations # parsed web citations
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Covered endpoints
|
|
111
|
+
|
|
112
|
+
| Resource | Methods |
|
|
113
|
+
|----------|---------|
|
|
114
|
+
| Threads | `list`, `get`, `update`, `archive` |
|
|
115
|
+
| Chat | `start`, `start_and_wait` |
|
|
116
|
+
| Messages | `send`, `send_and_wait`, `wait_until_done`, `cancel`, `export`, `stream` |
|
|
117
|
+
| Files | `upload`, `download` |
|
|
118
|
+
| Usage | `get` |
|
|
119
|
+
|
|
120
|
+
## Development
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
uv sync --all-extras --dev
|
|
124
|
+
uv run pytest
|
|
125
|
+
uv run ruff check .
|
|
126
|
+
uv run mypy
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Integration tests are opt-in:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
COMINTY_API_KEY=... COMINTY_AGENT_ID=... uv run pytest -m integration
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "cominty-sdk"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Official async Python client for the Cominty managed agent chat API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Cominty" }]
|
|
9
|
+
keywords = ["cominty", "sdk", "agent", "chat"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"httpx>=0.27",
|
|
22
|
+
"pydantic>=2",
|
|
23
|
+
"pydantic-settings>=2",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = [
|
|
28
|
+
"mypy>=1.11",
|
|
29
|
+
"pytest>=8",
|
|
30
|
+
"pytest-asyncio>=0.24",
|
|
31
|
+
"respx>=0.21",
|
|
32
|
+
"ruff>=0.6",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/cominty/python-sdk"
|
|
37
|
+
Repository = "https://github.com/cominty/python-sdk"
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["hatchling"]
|
|
41
|
+
build-backend = "hatchling.build"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/cominty_sdk"]
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.sdist]
|
|
47
|
+
only-include = ["src/cominty_sdk", "README.md", "pyproject.toml"]
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
asyncio_mode = "auto"
|
|
51
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
52
|
+
testpaths = ["tests"]
|
|
53
|
+
markers = [
|
|
54
|
+
"integration: opt-in tests requiring COMINTY_API_KEY",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
[tool.ruff]
|
|
58
|
+
target-version = "py311"
|
|
59
|
+
line-length = 100
|
|
60
|
+
src = ["src", "tests"]
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
64
|
+
|
|
65
|
+
[tool.mypy]
|
|
66
|
+
python_version = "3.11"
|
|
67
|
+
strict = true
|
|
68
|
+
files = ["src/cominty_sdk"]
|
|
69
|
+
warn_return_any = true
|
|
70
|
+
warn_unused_configs = true
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Cominty SDK — async Python client for the managed agent chat API."""
|
|
2
|
+
|
|
3
|
+
from cominty_sdk.client import AsyncCominty
|
|
4
|
+
from cominty_sdk.config import ComintyEnvironment
|
|
5
|
+
from cominty_sdk.exceptions import (
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
ComintyAPIError,
|
|
8
|
+
ComintyError,
|
|
9
|
+
ComintyServerShuttingDownError,
|
|
10
|
+
ComintyTimeoutError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
RateLimitError,
|
|
13
|
+
ServerError,
|
|
14
|
+
ValidationError,
|
|
15
|
+
)
|
|
16
|
+
from cominty_sdk.models.files import ConversationFileOut
|
|
17
|
+
from cominty_sdk.models.messages import (
|
|
18
|
+
DocumentCitation,
|
|
19
|
+
HumanMessage,
|
|
20
|
+
MessageOut,
|
|
21
|
+
Question,
|
|
22
|
+
WebCitation,
|
|
23
|
+
)
|
|
24
|
+
from cominty_sdk.models.threads import ThreadOut, ThreadSummaryOut
|
|
25
|
+
from cominty_sdk.models.usage import UsageReport
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"AsyncCominty",
|
|
29
|
+
"AuthenticationError",
|
|
30
|
+
"ComintyAPIError",
|
|
31
|
+
"ComintyEnvironment",
|
|
32
|
+
"ComintyError",
|
|
33
|
+
"ComintyServerShuttingDownError",
|
|
34
|
+
"ComintyTimeoutError",
|
|
35
|
+
"ConversationFileOut",
|
|
36
|
+
"DocumentCitation",
|
|
37
|
+
"HumanMessage",
|
|
38
|
+
"MessageOut",
|
|
39
|
+
"NotFoundError",
|
|
40
|
+
"Question",
|
|
41
|
+
"RateLimitError",
|
|
42
|
+
"ServerError",
|
|
43
|
+
"ThreadOut",
|
|
44
|
+
"ThreadSummaryOut",
|
|
45
|
+
"UsageReport",
|
|
46
|
+
"ValidationError",
|
|
47
|
+
"WebCitation",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any, TypeVar
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from cominty_sdk.config import AUTH_HEADER, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT
|
|
10
|
+
from cominty_sdk.exceptions import ComintyTimeoutError, raise_for_status
|
|
11
|
+
from cominty_sdk.retry import compute_backoff, is_retryable_exception, maybe_raise_for_status
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T", bound=BaseModel)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AsyncHTTPClient:
|
|
17
|
+
"""Internal async HTTP client with auth, retries, and error mapping."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
base_url: str,
|
|
23
|
+
api_key: str,
|
|
24
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
25
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
26
|
+
stream_timeout: float | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.base_url = base_url.rstrip("/")
|
|
29
|
+
self.api_key = api_key
|
|
30
|
+
self.max_retries = max_retries
|
|
31
|
+
self.timeout = timeout
|
|
32
|
+
self.stream_timeout = stream_timeout or timeout
|
|
33
|
+
self._client = httpx.AsyncClient(
|
|
34
|
+
base_url=self.base_url,
|
|
35
|
+
timeout=httpx.Timeout(timeout),
|
|
36
|
+
headers={AUTH_HEADER: api_key},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
async def close(self) -> None:
|
|
40
|
+
await self._client.aclose()
|
|
41
|
+
|
|
42
|
+
async def request(
|
|
43
|
+
self,
|
|
44
|
+
method: str,
|
|
45
|
+
path: str,
|
|
46
|
+
*,
|
|
47
|
+
params: dict[str, Any] | None = None,
|
|
48
|
+
json: dict[str, Any] | None = None,
|
|
49
|
+
headers: dict[str, str] | None = None,
|
|
50
|
+
parse_json: bool = True,
|
|
51
|
+
) -> Any:
|
|
52
|
+
"""Send a request with retries and return parsed JSON or raw response."""
|
|
53
|
+
last_exc: BaseException | None = None
|
|
54
|
+
for attempt in range(self.max_retries + 1):
|
|
55
|
+
try:
|
|
56
|
+
response = await self._client.request(
|
|
57
|
+
method,
|
|
58
|
+
path,
|
|
59
|
+
params=params,
|
|
60
|
+
json=json,
|
|
61
|
+
headers=headers,
|
|
62
|
+
)
|
|
63
|
+
body: Any
|
|
64
|
+
if parse_json:
|
|
65
|
+
body = response.json() if response.content else None
|
|
66
|
+
else:
|
|
67
|
+
body = response.content
|
|
68
|
+
|
|
69
|
+
if response.status_code >= 400:
|
|
70
|
+
maybe_raise_for_status(
|
|
71
|
+
response.status_code,
|
|
72
|
+
body,
|
|
73
|
+
f"{method} {path} failed",
|
|
74
|
+
)
|
|
75
|
+
return body
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
if not is_retryable_exception(exc) or attempt >= self.max_retries:
|
|
78
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
79
|
+
raise ComintyTimeoutError(str(exc)) from exc
|
|
80
|
+
raise
|
|
81
|
+
last_exc = exc
|
|
82
|
+
await asyncio.sleep(compute_backoff(attempt))
|
|
83
|
+
assert last_exc is not None
|
|
84
|
+
raise last_exc
|
|
85
|
+
|
|
86
|
+
async def request_model(
|
|
87
|
+
self,
|
|
88
|
+
method: str,
|
|
89
|
+
path: str,
|
|
90
|
+
model: type[T],
|
|
91
|
+
*,
|
|
92
|
+
params: dict[str, Any] | None = None,
|
|
93
|
+
json: dict[str, Any] | None = None,
|
|
94
|
+
headers: dict[str, str] | None = None,
|
|
95
|
+
) -> T:
|
|
96
|
+
data = await self.request(method, path, params=params, json=json, headers=headers)
|
|
97
|
+
return model.model_validate(data)
|
|
98
|
+
|
|
99
|
+
async def request_bytes(
|
|
100
|
+
self,
|
|
101
|
+
method: str,
|
|
102
|
+
path: str,
|
|
103
|
+
*,
|
|
104
|
+
params: dict[str, Any] | None = None,
|
|
105
|
+
headers: dict[str, str] | None = None,
|
|
106
|
+
) -> bytes:
|
|
107
|
+
last_exc: BaseException | None = None
|
|
108
|
+
for attempt in range(self.max_retries + 1):
|
|
109
|
+
try:
|
|
110
|
+
response = await self._client.request(
|
|
111
|
+
method,
|
|
112
|
+
path,
|
|
113
|
+
params=params,
|
|
114
|
+
headers=headers,
|
|
115
|
+
)
|
|
116
|
+
if response.status_code >= 400:
|
|
117
|
+
body: Any = None
|
|
118
|
+
if response.content:
|
|
119
|
+
try:
|
|
120
|
+
body = response.json()
|
|
121
|
+
except Exception:
|
|
122
|
+
body = response.text
|
|
123
|
+
raise_for_status(
|
|
124
|
+
response.status_code,
|
|
125
|
+
body,
|
|
126
|
+
f"{method} {path} failed",
|
|
127
|
+
)
|
|
128
|
+
return response.content
|
|
129
|
+
except Exception as exc:
|
|
130
|
+
if not is_retryable_exception(exc) or attempt >= self.max_retries:
|
|
131
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
132
|
+
raise ComintyTimeoutError(str(exc)) from exc
|
|
133
|
+
raise
|
|
134
|
+
last_exc = exc
|
|
135
|
+
await asyncio.sleep(compute_backoff(attempt))
|
|
136
|
+
assert last_exc is not None
|
|
137
|
+
raise last_exc
|
|
138
|
+
|
|
139
|
+
async def stream_request(
|
|
140
|
+
self,
|
|
141
|
+
method: str,
|
|
142
|
+
path: str,
|
|
143
|
+
*,
|
|
144
|
+
params: dict[str, Any] | None = None,
|
|
145
|
+
headers: dict[str, str] | None = None,
|
|
146
|
+
) -> httpx.Response:
|
|
147
|
+
"""Open a streaming HTTP response (caller must close context)."""
|
|
148
|
+
response = await self._client.stream(
|
|
149
|
+
method,
|
|
150
|
+
path,
|
|
151
|
+
params=params,
|
|
152
|
+
headers=headers,
|
|
153
|
+
timeout=httpx.Timeout(self.stream_timeout),
|
|
154
|
+
).__aenter__()
|
|
155
|
+
if response.status_code >= 400:
|
|
156
|
+
await response.aread()
|
|
157
|
+
body: Any = None
|
|
158
|
+
if response.content:
|
|
159
|
+
try:
|
|
160
|
+
body = response.json()
|
|
161
|
+
except Exception:
|
|
162
|
+
body = response.text
|
|
163
|
+
raise_for_status(response.status_code, body, f"{method} {path} failed")
|
|
164
|
+
return response
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def raw_client(self) -> httpx.AsyncClient:
|
|
168
|
+
"""Expose the underlying httpx client for non-API requests (e.g. S3 upload)."""
|
|
169
|
+
return self._client
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
StreamEvent = dict[str, Any]
|
|
11
|
+
|
|
12
|
+
DOCUMENT_CITE_PATTERN = re.compile(
|
|
13
|
+
r'<cite\s+document_id="([^"]+)"\s+pages="([^"]+)"\s+name="([^"]+)"\s*/>',
|
|
14
|
+
)
|
|
15
|
+
WEB_CITE_PATTERN = re.compile(r'<cite\s+url="(https?://[^"]+)"\s*/>')
|
|
16
|
+
CITE_TAG_PATTERN = re.compile(r"<cite[^>]*/>")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def extract_tool_names(events: list[dict[str, Any]] | None) -> list[str]:
|
|
20
|
+
"""Extract tool names from message events, deduplicated preserving order."""
|
|
21
|
+
if not events:
|
|
22
|
+
return []
|
|
23
|
+
names: list[str] = []
|
|
24
|
+
seen: set[str] = set()
|
|
25
|
+
for event in events:
|
|
26
|
+
name = _extract_tool_name_from_event(event)
|
|
27
|
+
if name and name not in seen:
|
|
28
|
+
seen.add(name)
|
|
29
|
+
names.append(name)
|
|
30
|
+
return names
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _extract_tool_name_from_event(event: dict[str, Any]) -> str | None:
|
|
34
|
+
for key in ("tool_name", "tool", "name"):
|
|
35
|
+
value = event.get(key)
|
|
36
|
+
if isinstance(value, str) and value:
|
|
37
|
+
return value
|
|
38
|
+
event_type = event.get("type") or event.get("event")
|
|
39
|
+
if event_type in ("tool_call", "tool_use", "tool"):
|
|
40
|
+
for key in ("tool_name", "tool", "name"):
|
|
41
|
+
value = event.get(key)
|
|
42
|
+
if isinstance(value, str) and value:
|
|
43
|
+
return value
|
|
44
|
+
data = event.get("data")
|
|
45
|
+
if isinstance(data, dict):
|
|
46
|
+
return _extract_tool_name_from_event(data)
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def extract_cite_tags(content: str) -> list[str]:
|
|
51
|
+
"""Return raw <cite .../> tags found in message content."""
|
|
52
|
+
return CITE_TAG_PATTERN.findall(content)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_document_citations(content: str) -> list[dict[str, str]]:
|
|
56
|
+
"""Parse document citation tags from content."""
|
|
57
|
+
return [
|
|
58
|
+
{"document_id": m.group(1), "pages": m.group(2), "name": m.group(3)}
|
|
59
|
+
for m in DOCUMENT_CITE_PATTERN.finditer(content)
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_web_citations(content: str) -> list[dict[str, str]]:
|
|
64
|
+
"""Parse web citation tags from content."""
|
|
65
|
+
return [{"url": m.group(1)} for m in WEB_CITE_PATTERN.finditer(content)]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def iter_jsonl_events(response: httpx.Response) -> AsyncIterator[StreamEvent]:
|
|
69
|
+
"""Parse a JSONL stream into async event dicts."""
|
|
70
|
+
async for line in response.aiter_lines():
|
|
71
|
+
stripped = line.strip()
|
|
72
|
+
if not stripped:
|
|
73
|
+
continue
|
|
74
|
+
yield json.loads(stripped)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from cominty_sdk._qa import StreamEvent, iter_jsonl_events
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from cominty_sdk._http import AsyncHTTPClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def stream_message_events(
|
|
13
|
+
http: AsyncHTTPClient,
|
|
14
|
+
message_id: str,
|
|
15
|
+
*,
|
|
16
|
+
last_event_id: str | None = None,
|
|
17
|
+
) -> AsyncIterator[StreamEvent]:
|
|
18
|
+
"""Stream JSONL events for a message."""
|
|
19
|
+
headers: dict[str, str] = {}
|
|
20
|
+
if last_event_id is not None:
|
|
21
|
+
headers["last-event-id"] = last_event_id
|
|
22
|
+
|
|
23
|
+
response = await http.stream_request(
|
|
24
|
+
"GET",
|
|
25
|
+
f"/chat/messages/{message_id}/stream",
|
|
26
|
+
headers=headers or None,
|
|
27
|
+
)
|
|
28
|
+
try:
|
|
29
|
+
async for event in iter_jsonl_events(response):
|
|
30
|
+
yield event
|
|
31
|
+
finally:
|
|
32
|
+
await response.aclose()
|