cominty-sdk 0.1.0__py3-none-any.whl

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,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"
cominty_sdk/_http.py ADDED
@@ -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
cominty_sdk/_qa.py ADDED
@@ -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()
cominty_sdk/client.py ADDED
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Self
5
+
6
+ from pydantic_settings import BaseSettings, SettingsConfigDict
7
+
8
+ from cominty_sdk._http import AsyncHTTPClient
9
+ from cominty_sdk.config import (
10
+ DEFAULT_MAX_RETRIES,
11
+ DEFAULT_STREAM_TIMEOUT,
12
+ DEFAULT_TIMEOUT,
13
+ ComintyEnvironment,
14
+ )
15
+ from cominty_sdk.exceptions import resolve_base_url
16
+ from cominty_sdk.resources.chat import ChatResource
17
+ from cominty_sdk.resources.files import FilesResource
18
+ from cominty_sdk.resources.messages import MessagesResource
19
+ from cominty_sdk.resources.threads import ThreadsResource
20
+ from cominty_sdk.resources.usage import UsageResource
21
+
22
+
23
+ class ClientSettings(BaseSettings):
24
+ model_config = SettingsConfigDict(
25
+ env_prefix="COMINTY_",
26
+ env_file=".env",
27
+ extra="ignore",
28
+ )
29
+
30
+ api_key: str | None = None
31
+ api_url: str | None = None
32
+ environment: ComintyEnvironment = ComintyEnvironment.PRODUCTION
33
+ agent_id: str | None = None
34
+ max_retries: int = DEFAULT_MAX_RETRIES
35
+ timeout: float = DEFAULT_TIMEOUT
36
+ stream_timeout: float = DEFAULT_STREAM_TIMEOUT
37
+
38
+
39
+ class AsyncCominty:
40
+ """Async client for the Cominty managed agent chat API."""
41
+
42
+ def __init__(
43
+ self,
44
+ *,
45
+ api_key: str | None = None,
46
+ base_url: str | None = None,
47
+ environment: ComintyEnvironment | str | None = None,
48
+ agent_id: str | None = None,
49
+ max_retries: int = DEFAULT_MAX_RETRIES,
50
+ timeout: float = DEFAULT_TIMEOUT,
51
+ stream_timeout: float = DEFAULT_STREAM_TIMEOUT,
52
+ ) -> None:
53
+ settings = ClientSettings()
54
+ resolved_api_key = api_key or settings.api_key or os.environ.get("COMINTY_API_KEY")
55
+ if not resolved_api_key:
56
+ raise ValueError(
57
+ "API key is required. Pass api_key= or set COMINTY_API_KEY."
58
+ )
59
+
60
+ resolved_base_url = resolve_base_url(
61
+ base_url=base_url or settings.api_url,
62
+ environment=environment or settings.environment,
63
+ )
64
+ resolved_agent_id = agent_id or settings.agent_id
65
+ if max_retries != DEFAULT_MAX_RETRIES:
66
+ resolved_max_retries = max_retries
67
+ else:
68
+ resolved_max_retries = settings.max_retries
69
+ resolved_timeout = timeout if timeout != DEFAULT_TIMEOUT else settings.timeout
70
+ resolved_stream_timeout = (
71
+ stream_timeout
72
+ if stream_timeout != DEFAULT_STREAM_TIMEOUT
73
+ else settings.stream_timeout
74
+ )
75
+
76
+ self._http = AsyncHTTPClient(
77
+ base_url=resolved_base_url,
78
+ api_key=resolved_api_key,
79
+ max_retries=resolved_max_retries,
80
+ timeout=resolved_timeout,
81
+ stream_timeout=resolved_stream_timeout,
82
+ )
83
+
84
+ self.threads = ThreadsResource(self._http)
85
+ self.messages = MessagesResource(
86
+ self._http,
87
+ default_agent_id=resolved_agent_id,
88
+ threads=self.threads,
89
+ )
90
+ self.chat = ChatResource(
91
+ self._http,
92
+ default_agent_id=resolved_agent_id,
93
+ threads=self.threads,
94
+ messages=self.messages,
95
+ )
96
+ self.files = FilesResource(self._http)
97
+ self.usage = UsageResource(self._http)
98
+
99
+ async def close(self) -> None:
100
+ await self._http.close()
101
+
102
+ async def __aenter__(self) -> Self:
103
+ return self
104
+
105
+ async def __aexit__(self, *args: object) -> None:
106
+ await self.close()
cominty_sdk/config.py ADDED
@@ -0,0 +1,29 @@
1
+ from enum import StrEnum
2
+ from typing import Literal
3
+
4
+ DEFAULT_BASE_URLS: dict[str, str] = {
5
+ "dev": "https://api.dev.cominty.com",
6
+ "staging": "https://api.staging.cominty.com",
7
+ "production": "https://api.cominty.com",
8
+ }
9
+
10
+
11
+ class ComintyEnvironment(StrEnum):
12
+ DEV = "dev"
13
+ STAGING = "staging"
14
+ PRODUCTION = "production"
15
+
16
+
17
+ EnvironmentName = Literal["dev", "staging", "production"]
18
+
19
+ DEFAULT_MAX_RETRIES = 3
20
+ DEFAULT_TIMEOUT = 60.0
21
+ DEFAULT_STREAM_TIMEOUT = 300.0
22
+ DEFAULT_POLL_INTERVAL = 1.0
23
+ DEFAULT_POLL_TIMEOUT = 120.0
24
+
25
+ # Message statuses that indicate the agent is still processing.
26
+ NON_TERMINAL_STATUSES = frozenset({"pending", "running", "in_progress", "processing"})
27
+
28
+ # Header used for API key authentication.
29
+ AUTH_HEADER = "x-cominty-token"
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from cominty_sdk.config import DEFAULT_BASE_URLS, ComintyEnvironment
6
+
7
+
8
+ class ComintyError(Exception):
9
+ """Base exception for all Cominty SDK errors."""
10
+
11
+ def __init__(
12
+ self,
13
+ message: str,
14
+ *,
15
+ status_code: int | None = None,
16
+ body: Any | None = None,
17
+ ) -> None:
18
+ super().__init__(message)
19
+ self.message = message
20
+ self.status_code = status_code
21
+ self.body = body
22
+
23
+
24
+ class ComintyAPIError(ComintyError):
25
+ """Raised when the API returns an error response."""
26
+
27
+
28
+ class AuthenticationError(ComintyAPIError):
29
+ """Raised on 401 Unauthorized."""
30
+
31
+
32
+ class NotFoundError(ComintyAPIError):
33
+ """Raised on 404 Not Found."""
34
+
35
+
36
+ class ValidationError(ComintyAPIError):
37
+ """Raised on 422 Unprocessable Entity."""
38
+
39
+
40
+ class RateLimitError(ComintyAPIError):
41
+ """Raised on 429 Too Many Requests."""
42
+
43
+
44
+ class ServerError(ComintyAPIError):
45
+ """Raised on 5xx server errors."""
46
+
47
+
48
+ class ComintyTimeoutError(ComintyError):
49
+ """Raised when a request or poll operation times out."""
50
+
51
+
52
+ class ComintyServerShuttingDownError(ComintyError):
53
+ """Raised when the API returns a partial response due to server shutdown."""
54
+
55
+
56
+ def raise_for_status(status_code: int, body: Any, message: str | None = None) -> None:
57
+ """Map HTTP status codes to typed exceptions."""
58
+ msg = message or f"API request failed with status {status_code}"
59
+ if status_code == 401:
60
+ raise AuthenticationError(msg, status_code=status_code, body=body)
61
+ if status_code == 404:
62
+ raise NotFoundError(msg, status_code=status_code, body=body)
63
+ if status_code == 422:
64
+ raise ValidationError(msg, status_code=status_code, body=body)
65
+ if status_code == 429:
66
+ raise RateLimitError(msg, status_code=status_code, body=body)
67
+ if 500 <= status_code < 600:
68
+ raise ServerError(msg, status_code=status_code, body=body)
69
+ if 400 <= status_code < 500:
70
+ raise ComintyAPIError(msg, status_code=status_code, body=body)
71
+
72
+
73
+ def resolve_base_url(
74
+ *,
75
+ base_url: str | None = None,
76
+ environment: ComintyEnvironment | str | None = None,
77
+ ) -> str:
78
+ """Resolve the API base URL from explicit override or environment name."""
79
+ if base_url is not None:
80
+ return base_url.rstrip("/")
81
+ env = environment or ComintyEnvironment.PRODUCTION
82
+ env_name = env.value if isinstance(env, ComintyEnvironment) else env
83
+ try:
84
+ return DEFAULT_BASE_URLS[env_name].rstrip("/")
85
+ except KeyError as exc:
86
+ raise ValueError(
87
+ f"Unknown environment {env_name!r}. "
88
+ f"Expected one of: {', '.join(DEFAULT_BASE_URLS)}"
89
+ ) from exc
@@ -0,0 +1,38 @@
1
+ from cominty_sdk.models.files import (
2
+ ConversationFileOut,
3
+ FileUploadConfirmation,
4
+ FileUploadPermission,
5
+ )
6
+ from cominty_sdk.models.messages import (
7
+ ChatOptions,
8
+ ChatRequest,
9
+ DocumentCitation,
10
+ HumanMessage,
11
+ MessageOut,
12
+ Question,
13
+ StartChatOptions,
14
+ StartChatRequest,
15
+ WebCitation,
16
+ )
17
+ from cominty_sdk.models.threads import ThreadOut, ThreadSummaryOut, ThreadUpdate
18
+ from cominty_sdk.models.usage import AgentDetail, UsageReport
19
+
20
+ __all__ = [
21
+ "AgentDetail",
22
+ "ChatOptions",
23
+ "ChatRequest",
24
+ "ConversationFileOut",
25
+ "DocumentCitation",
26
+ "FileUploadConfirmation",
27
+ "FileUploadPermission",
28
+ "HumanMessage",
29
+ "MessageOut",
30
+ "Question",
31
+ "StartChatOptions",
32
+ "StartChatRequest",
33
+ "ThreadOut",
34
+ "ThreadSummaryOut",
35
+ "ThreadUpdate",
36
+ "UsageReport",
37
+ "WebCitation",
38
+ ]
@@ -0,0 +1,30 @@
1
+ """Pydantic models for chat file operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+
8
+ class FileUploadPermission(BaseModel):
9
+ model_config = ConfigDict(extra="ignore")
10
+
11
+ url: str
12
+ fields: dict[str, str] = Field(default_factory=dict)
13
+
14
+
15
+ class FileUploadConfirmation(BaseModel):
16
+ model_config = ConfigDict(extra="forbid")
17
+
18
+ etag: str
19
+ key: str
20
+
21
+
22
+ class ConversationFileOut(BaseModel):
23
+ model_config = ConfigDict(extra="ignore")
24
+
25
+ id: str
26
+ name: str
27
+ size: int
28
+ mimetype: str
29
+ origin: str
30
+ url: str | None = None