coval-agent-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.
- coval_agent_sdk/__init__.py +29 -0
- coval_agent_sdk/_audio.py +101 -0
- coval_agent_sdk/_connection.py +165 -0
- coval_agent_sdk/_defaults.py +33 -0
- coval_agent_sdk/_exceptions.py +15 -0
- coval_agent_sdk/_http.py +196 -0
- coval_agent_sdk/_runs.py +254 -0
- coval_agent_sdk/_session.py +346 -0
- coval_agent_sdk/audio.py +147 -0
- coval_agent_sdk/client.py +110 -0
- coval_agent_sdk/config.py +54 -0
- coval_agent_sdk/errors.py +53 -0
- coval_agent_sdk/py.typed +0 -0
- coval_agent_sdk/results.py +66 -0
- coval_agent_sdk/telemetry.py +58 -0
- coval_agent_sdk/tracing.py +346 -0
- coval_agent_sdk-0.1.0.dist-info/METADATA +178 -0
- coval_agent_sdk-0.1.0.dist-info/RECORD +22 -0
- coval_agent_sdk-0.1.0.dist-info/WHEEL +5 -0
- coval_agent_sdk-0.1.0.dist-info/licenses/LICENSE +202 -0
- coval_agent_sdk-0.1.0.dist-info/licenses/NOTICE +5 -0
- coval_agent_sdk-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Connect Python voice agents to Coval over outbound WebSockets."""
|
|
2
|
+
|
|
3
|
+
from coval_agent_sdk.audio import AudioConnection
|
|
4
|
+
from coval_agent_sdk.client import CovalVoiceClient
|
|
5
|
+
from coval_agent_sdk.config import RunConfig
|
|
6
|
+
from coval_agent_sdk.errors import CovalConnectionError
|
|
7
|
+
from coval_agent_sdk.errors import CovalError
|
|
8
|
+
from coval_agent_sdk.errors import CovalHttpError
|
|
9
|
+
from coval_agent_sdk.errors import CovalTraceError
|
|
10
|
+
from coval_agent_sdk.errors import RunError
|
|
11
|
+
from coval_agent_sdk.results import ConversationResult
|
|
12
|
+
from coval_agent_sdk.results import RunResult
|
|
13
|
+
from coval_agent_sdk.results import TraceResult
|
|
14
|
+
from coval_agent_sdk.telemetry import TraceExporter
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AudioConnection",
|
|
18
|
+
"ConversationResult",
|
|
19
|
+
"CovalConnectionError",
|
|
20
|
+
"CovalError",
|
|
21
|
+
"CovalHttpError",
|
|
22
|
+
"CovalTraceError",
|
|
23
|
+
"CovalVoiceClient",
|
|
24
|
+
"RunConfig",
|
|
25
|
+
"RunError",
|
|
26
|
+
"RunResult",
|
|
27
|
+
"TraceExporter",
|
|
28
|
+
"TraceResult",
|
|
29
|
+
]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Validate and pace serialized outbound PCM16 audio."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import collections
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from websockets.asyncio import client
|
|
10
|
+
|
|
11
|
+
from coval_agent_sdk import _defaults
|
|
12
|
+
from coval_agent_sdk import errors
|
|
13
|
+
from coval_agent_sdk import results
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate_audio(audio: object) -> None:
|
|
17
|
+
"""Reject invalid or oversized application audio before any wire write.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
audio: PCM16 little-endian mono 16 kHz bytes.
|
|
21
|
+
|
|
22
|
+
Raises:
|
|
23
|
+
errors.CovalError: Audio is malformed or exceeds the 2 MiB limit.
|
|
24
|
+
"""
|
|
25
|
+
if (
|
|
26
|
+
not isinstance(audio, bytes)
|
|
27
|
+
or len(audio) % _defaults.VOICE_SAMPLE_WIDTH_BYTES
|
|
28
|
+
):
|
|
29
|
+
raise errors.CovalError("Audio must be even-length PCM16 bytes.")
|
|
30
|
+
if len(audio) > _defaults.MAX_SEND_BYTES:
|
|
31
|
+
raise errors.CovalError(
|
|
32
|
+
"Audio exceeds the 2 MiB limit; send smaller chunks."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PcmWriter:
|
|
37
|
+
"""Own output serialization, cross-call pacing, and exact sent counts."""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self._lock = asyncio.Lock()
|
|
41
|
+
self._next_send_at = 0.0
|
|
42
|
+
self.sent_bytes = 0
|
|
43
|
+
|
|
44
|
+
async def send(
|
|
45
|
+
self, websocket: client.ClientConnection, audio: bytes
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Send validated chunks while retaining pacing between callers."""
|
|
48
|
+
async with self._lock:
|
|
49
|
+
for offset in range(0, len(audio), _defaults.VOICE_CHUNK_BYTES):
|
|
50
|
+
chunk = audio[offset : offset + _defaults.VOICE_CHUNK_BYTES]
|
|
51
|
+
await asyncio.sleep(
|
|
52
|
+
max(0.0, self._next_send_at - time.monotonic())
|
|
53
|
+
)
|
|
54
|
+
await websocket.send(chunk)
|
|
55
|
+
self.sent_bytes += len(chunk)
|
|
56
|
+
self._next_send_at = max(
|
|
57
|
+
self._next_send_at, time.monotonic()
|
|
58
|
+
) + len(chunk) / (
|
|
59
|
+
_defaults.VOICE_SAMPLE_RATE_HZ
|
|
60
|
+
* _defaults.VOICE_CHANNEL_COUNT
|
|
61
|
+
* _defaults.VOICE_SAMPLE_WIDTH_BYTES
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ConnectionState:
|
|
66
|
+
"""Private shared transport state; never exported by the public facade."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, simulation_id: str) -> None:
|
|
69
|
+
self.simulation_id = simulation_id
|
|
70
|
+
self.websocket: client.ClientConnection | None = None
|
|
71
|
+
self.writer = PcmWriter()
|
|
72
|
+
self.input: collections.deque[bytes] = collections.deque()
|
|
73
|
+
self.buffered_bytes = 0
|
|
74
|
+
self.input_changed = asyncio.Event()
|
|
75
|
+
self.receiving = False
|
|
76
|
+
self.ready = asyncio.Event()
|
|
77
|
+
self.closed = asyncio.Event()
|
|
78
|
+
self.error: BaseException | None = None
|
|
79
|
+
self.completed = False
|
|
80
|
+
self.received_bytes = 0
|
|
81
|
+
self.result: results.ConversationResult | None = None
|
|
82
|
+
|
|
83
|
+
def feed_audio(self, audio: bytes) -> None:
|
|
84
|
+
"""Bound unread audio without blocking terminal control frames."""
|
|
85
|
+
if self.buffered_bytes + len(audio) > _defaults.MAX_RECEIVE_BYTES:
|
|
86
|
+
raise errors.CovalError(
|
|
87
|
+
"Unread audio exceeded the 64 KiB receive buffer; "
|
|
88
|
+
"drain receive_audio() promptly into a bounded pipeline."
|
|
89
|
+
)
|
|
90
|
+
self.received_bytes += len(audio)
|
|
91
|
+
self.buffered_bytes += len(audio)
|
|
92
|
+
if audio:
|
|
93
|
+
self.input.append(audio)
|
|
94
|
+
self.input_changed.set()
|
|
95
|
+
|
|
96
|
+
def finish(self, error: BaseException | None = None) -> None:
|
|
97
|
+
"""Wake readers and writers without manufacturing success."""
|
|
98
|
+
if self.error is None:
|
|
99
|
+
self.error = error
|
|
100
|
+
self.closed.set()
|
|
101
|
+
self.input_changed.set()
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Discover and claim one-use outbound relay connections."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import dataclasses
|
|
7
|
+
import time
|
|
8
|
+
from urllib import parse
|
|
9
|
+
|
|
10
|
+
from websockets import exceptions
|
|
11
|
+
from websockets.asyncio import client
|
|
12
|
+
|
|
13
|
+
from coval_agent_sdk import _defaults
|
|
14
|
+
from coval_agent_sdk import _http
|
|
15
|
+
from coval_agent_sdk import errors
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclasses.dataclass(frozen=True)
|
|
19
|
+
class ConnectionOffer:
|
|
20
|
+
"""Hold one short-lived, attempt-scoped relay credential.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
wss_url: Secure relay WebSocket URL.
|
|
24
|
+
token: One-use credential, sent only in the upgrade header.
|
|
25
|
+
expires_at: Expiry as Unix epoch seconds.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
wss_url: str
|
|
29
|
+
token: str
|
|
30
|
+
expires_at: float
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def wait_for_offer(
|
|
34
|
+
api: _http.CovalApi, simulation_id: str
|
|
35
|
+
) -> ConnectionOffer:
|
|
36
|
+
"""Wait until the worker publishes a reachable relay and one-use token."""
|
|
37
|
+
deadline = time.monotonic() + _defaults.CONNECT_WAIT_SECONDS
|
|
38
|
+
path = f"simulations/{parse.quote(simulation_id, safe='')}/connect"
|
|
39
|
+
while time.monotonic() < deadline:
|
|
40
|
+
try:
|
|
41
|
+
connection = await api.get(path)
|
|
42
|
+
except (
|
|
43
|
+
errors.CovalHttpError,
|
|
44
|
+
errors.CovalConnectionError,
|
|
45
|
+
) as exception:
|
|
46
|
+
if (
|
|
47
|
+
isinstance(exception, errors.CovalHttpError)
|
|
48
|
+
and exception.status_code
|
|
49
|
+
not in _defaults.RETRYABLE_HTTP_STATUSES
|
|
50
|
+
):
|
|
51
|
+
raise
|
|
52
|
+
await asyncio.sleep(_defaults.POLL_INTERVAL_SECONDS)
|
|
53
|
+
continue
|
|
54
|
+
status = connection.get("status")
|
|
55
|
+
if status == "READY":
|
|
56
|
+
url = connection.get("wss_url")
|
|
57
|
+
token = connection.get("token")
|
|
58
|
+
expires_at = connection.get("expires_at")
|
|
59
|
+
if not isinstance(url, str) or not url.startswith("wss://"):
|
|
60
|
+
raise errors.CovalError(
|
|
61
|
+
f"Simulation {simulation_id} returned "
|
|
62
|
+
f"READY without a WSS URL."
|
|
63
|
+
)
|
|
64
|
+
if not isinstance(token, str) or not token:
|
|
65
|
+
raise errors.CovalError(
|
|
66
|
+
f"Simulation {simulation_id} returned "
|
|
67
|
+
f"READY without a token."
|
|
68
|
+
)
|
|
69
|
+
if (
|
|
70
|
+
not isinstance(expires_at, (int, float))
|
|
71
|
+
or expires_at <= time.time()
|
|
72
|
+
):
|
|
73
|
+
raise errors.CovalError(
|
|
74
|
+
f"Simulation {simulation_id} returned an "
|
|
75
|
+
f"expired connection offer."
|
|
76
|
+
)
|
|
77
|
+
return ConnectionOffer(
|
|
78
|
+
wss_url=url, token=token, expires_at=float(expires_at)
|
|
79
|
+
)
|
|
80
|
+
if status in _defaults.TERMINAL_CONNECTION_STATUSES:
|
|
81
|
+
raise errors.CovalError(
|
|
82
|
+
f"Simulation {simulation_id} ended before connecting: {status}."
|
|
83
|
+
)
|
|
84
|
+
# An unattached attempt can expire while the worker prepares its
|
|
85
|
+
# replacement.
|
|
86
|
+
if status not in {"PENDING", "PROVISIONING", "EXPIRED"}:
|
|
87
|
+
raise errors.CovalError(
|
|
88
|
+
f"Simulation {simulation_id} returned "
|
|
89
|
+
f"unknown connection status {status!r}."
|
|
90
|
+
)
|
|
91
|
+
await asyncio.sleep(_defaults.POLL_INTERVAL_SECONDS)
|
|
92
|
+
raise errors.CovalError(
|
|
93
|
+
f"Simulation {simulation_id} did not "
|
|
94
|
+
f"publish a relay connection within "
|
|
95
|
+
f"{_defaults.CONNECT_WAIT_SECONDS} "
|
|
96
|
+
f"seconds."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def open_voice_connection(
|
|
101
|
+
api: _http.CovalApi, simulation_id: str
|
|
102
|
+
) -> client.ClientConnection:
|
|
103
|
+
"""Retry a live offer through transient WebSocket handshake failures."""
|
|
104
|
+
deadline = time.monotonic() + _defaults.CONNECT_WAIT_SECONDS
|
|
105
|
+
retry_delay_seconds = _defaults.POLL_INTERVAL_SECONDS
|
|
106
|
+
last_error: Exception | None = None
|
|
107
|
+
while time.monotonic() < deadline:
|
|
108
|
+
remaining_seconds = deadline - time.monotonic()
|
|
109
|
+
try:
|
|
110
|
+
offer = await asyncio.wait_for(
|
|
111
|
+
wait_for_offer(api, simulation_id), timeout=remaining_seconds
|
|
112
|
+
)
|
|
113
|
+
except asyncio.TimeoutError as exception:
|
|
114
|
+
raise errors.CovalError(
|
|
115
|
+
f"Simulation {simulation_id} did not "
|
|
116
|
+
f"offer a usable relay before its "
|
|
117
|
+
f"connect deadline."
|
|
118
|
+
) from exception
|
|
119
|
+
while time.time() < offer.expires_at and time.monotonic() < deadline:
|
|
120
|
+
upgrade_timeout = min(
|
|
121
|
+
_defaults.HTTP_TIMEOUT_SECONDS,
|
|
122
|
+
offer.expires_at - time.time(),
|
|
123
|
+
deadline - time.monotonic(),
|
|
124
|
+
)
|
|
125
|
+
if upgrade_timeout <= 0:
|
|
126
|
+
break
|
|
127
|
+
try:
|
|
128
|
+
return await client.connect(
|
|
129
|
+
offer.wss_url,
|
|
130
|
+
additional_headers={
|
|
131
|
+
"Authorization": f"Bearer {offer.token}"
|
|
132
|
+
},
|
|
133
|
+
open_timeout=upgrade_timeout,
|
|
134
|
+
ping_interval=_defaults.WEBSOCKET_PING_INTERVAL_SECONDS,
|
|
135
|
+
ping_timeout=_defaults.WEBSOCKET_PING_TIMEOUT_SECONDS,
|
|
136
|
+
max_size=_defaults.VOICE_SAMPLE_RATE_HZ
|
|
137
|
+
* _defaults.VOICE_SAMPLE_WIDTH_BYTES
|
|
138
|
+
* 10,
|
|
139
|
+
)
|
|
140
|
+
except (
|
|
141
|
+
EOFError,
|
|
142
|
+
OSError,
|
|
143
|
+
asyncio.TimeoutError,
|
|
144
|
+
exceptions.WebSocketException,
|
|
145
|
+
) as exception:
|
|
146
|
+
if client.process_exception(exception) is not None:
|
|
147
|
+
raise errors.CovalError(
|
|
148
|
+
f"Simulation {simulation_id} relay upgrade failed."
|
|
149
|
+
) from exception
|
|
150
|
+
last_error = exception
|
|
151
|
+
remaining_seconds = min(
|
|
152
|
+
offer.expires_at - time.time(), deadline - time.monotonic()
|
|
153
|
+
)
|
|
154
|
+
if remaining_seconds <= 0:
|
|
155
|
+
break
|
|
156
|
+
await asyncio.sleep(min(retry_delay_seconds, remaining_seconds))
|
|
157
|
+
retry_delay_seconds = min(
|
|
158
|
+
retry_delay_seconds * 2, _defaults.CONNECT_RETRY_MAX_SECONDS
|
|
159
|
+
)
|
|
160
|
+
retry_delay_seconds = _defaults.POLL_INTERVAL_SECONDS
|
|
161
|
+
raise errors.CovalError(
|
|
162
|
+
f"Simulation {simulation_id} could not "
|
|
163
|
+
f"connect to its voice relay "
|
|
164
|
+
f"before the deadline."
|
|
165
|
+
) from last_error
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Internal API, relay, audio, and buffering defaults."""
|
|
2
|
+
|
|
3
|
+
DEFAULT_API_BASE_URL = "https://api.coval.dev/v1"
|
|
4
|
+
HTTP_TIMEOUT_SECONDS = 15
|
|
5
|
+
POLL_INTERVAL_SECONDS = 2
|
|
6
|
+
CONNECT_WAIT_SECONDS = 300
|
|
7
|
+
CONNECT_RETRY_MAX_SECONDS = 8
|
|
8
|
+
RUN_WAIT_SECONDS = 3_600
|
|
9
|
+
MONITORING_RETRY_SECONDS = 30
|
|
10
|
+
WEBSOCKET_PING_INTERVAL_SECONDS = 20
|
|
11
|
+
WEBSOCKET_PING_TIMEOUT_SECONDS = 20
|
|
12
|
+
VOICE_SAMPLE_RATE_HZ = 16_000
|
|
13
|
+
VOICE_CHANNEL_COUNT = 1
|
|
14
|
+
VOICE_SAMPLE_WIDTH_BYTES = 2
|
|
15
|
+
VOICE_CHUNK_DURATION_SECONDS = 0.02
|
|
16
|
+
VOICE_CHUNK_BYTES = int(
|
|
17
|
+
VOICE_SAMPLE_RATE_HZ
|
|
18
|
+
* VOICE_CHANNEL_COUNT
|
|
19
|
+
* VOICE_SAMPLE_WIDTH_BYTES
|
|
20
|
+
* VOICE_CHUNK_DURATION_SECONDS
|
|
21
|
+
)
|
|
22
|
+
MAX_SEND_BYTES = 2 * 1024 * 1024
|
|
23
|
+
MAX_RECEIVE_BYTES = 64 * 1024
|
|
24
|
+
CLOSE_CONFIRM_TIMEOUT_SECONDS = 10
|
|
25
|
+
TERMINAL_CONNECTION_STATUSES = frozenset(
|
|
26
|
+
{"CONNECTED", "CLOSED", "FAILED", "CANCELLED"}
|
|
27
|
+
)
|
|
28
|
+
TERMINAL_RUN_STATUSES = frozenset({"COMPLETED", "FAILED", "CANCELLED"})
|
|
29
|
+
TERMINAL_SIMULATION_STATUSES = TERMINAL_RUN_STATUSES | {"SKIPPED", "DELETED"}
|
|
30
|
+
QUEUED_SIMULATION_STATUSES = frozenset({"PENDING", "IN QUEUE"})
|
|
31
|
+
RETRYABLE_HTTP_STATUSES = frozenset({409, 429, 500, 502, 503, 504})
|
|
32
|
+
|
|
33
|
+
TRACE_FLUSH_TIMEOUT_MILLISECONDS = 5_000
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Preserve primary exceptions while retaining secondary cleanup diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def add_cleanup_note(exception: BaseException, note: str) -> None:
|
|
7
|
+
"""Attach secondary cleanup context where the runtime supports notes.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
exception: Primary failure whose identity must be preserved.
|
|
11
|
+
note: Diagnostic describing a secondary cleanup failure.
|
|
12
|
+
"""
|
|
13
|
+
add_note = getattr(exception, "add_note", None)
|
|
14
|
+
if callable(add_note):
|
|
15
|
+
add_note(note)
|
coval_agent_sdk/_http.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Private Coval API SDK transport with JSON and protobuf request support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
from typing import cast
|
|
9
|
+
from urllib import parse
|
|
10
|
+
|
|
11
|
+
from coval_sdk import api_client
|
|
12
|
+
from coval_sdk import configuration
|
|
13
|
+
from coval_sdk import exceptions
|
|
14
|
+
from urllib3 import exceptions as http_exceptions
|
|
15
|
+
|
|
16
|
+
from coval_agent_sdk import _defaults
|
|
17
|
+
from coval_agent_sdk import errors
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CovalApi:
|
|
21
|
+
"""Private adapter around the Coval API SDK request client."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
api_base_url: str,
|
|
27
|
+
api_key: str,
|
|
28
|
+
workspace_id: str | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Configure the API root and authentication headers.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
api_base_url: API root including the version path.
|
|
34
|
+
api_key: Organization API key.
|
|
35
|
+
workspace_id: Optional workspace selector.
|
|
36
|
+
"""
|
|
37
|
+
if parse.urlsplit(api_base_url).scheme not in {"http", "https"}:
|
|
38
|
+
raise ValueError("api_base_url must use http or https.")
|
|
39
|
+
self.api_base_url = api_base_url.rstrip("/")
|
|
40
|
+
self.api_key = api_key
|
|
41
|
+
self.workspace_id = workspace_id
|
|
42
|
+
|
|
43
|
+
async def get(
|
|
44
|
+
self, path: str, *, query: dict[str, str] | None = None
|
|
45
|
+
) -> dict[str, Any]:
|
|
46
|
+
"""Fetch an object resource without blocking the audio event loop.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
path: Resource path relative to the configured API root.
|
|
50
|
+
query: Optional URL query parameters.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The decoded JSON object.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
errors.CovalError: The request fails or returns non-object JSON.
|
|
57
|
+
"""
|
|
58
|
+
return await asyncio.to_thread(
|
|
59
|
+
self._request_json, "GET", path, query, None
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
async def post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
63
|
+
"""Post JSON once without automatically retrying mutations.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
path: Resource path relative to the configured API root.
|
|
67
|
+
payload: JSON object to send.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
The decoded JSON response object.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
errors.CovalError: The request fails or returns non-object JSON.
|
|
74
|
+
"""
|
|
75
|
+
return await asyncio.to_thread(
|
|
76
|
+
self._request_json, "POST", path, None, payload
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def post_protobuf(
|
|
80
|
+
self, path: str, payload: bytes, *, headers: dict[str, str]
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Post protobuf once from a worker thread, releasing request resources.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
path: Resource path relative to the configured API root.
|
|
86
|
+
payload: Encoded protobuf request body.
|
|
87
|
+
headers: Additional endpoint-specific headers.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
errors.CovalError: The API rejects or cannot receive the request.
|
|
91
|
+
"""
|
|
92
|
+
self._request(
|
|
93
|
+
"POST",
|
|
94
|
+
path,
|
|
95
|
+
None,
|
|
96
|
+
payload,
|
|
97
|
+
content_type="application/x-protobuf",
|
|
98
|
+
headers=headers,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def _request_json(
|
|
102
|
+
self,
|
|
103
|
+
method: str,
|
|
104
|
+
path: str,
|
|
105
|
+
query: dict[str, str] | None,
|
|
106
|
+
payload: dict[str, Any] | None,
|
|
107
|
+
) -> dict[str, Any]:
|
|
108
|
+
body = self._request(method, path, query, payload)
|
|
109
|
+
try:
|
|
110
|
+
response_payload: object = json.loads(body)
|
|
111
|
+
except (ValueError, UnicodeError):
|
|
112
|
+
raise errors.CovalError(
|
|
113
|
+
f"Coval {method} {path} returned invalid JSON."
|
|
114
|
+
) from None
|
|
115
|
+
if not isinstance(response_payload, dict):
|
|
116
|
+
raise errors.CovalError(
|
|
117
|
+
f"Coval {method} {path} returned a non-object JSON response."
|
|
118
|
+
)
|
|
119
|
+
return cast(dict[str, Any], response_payload)
|
|
120
|
+
|
|
121
|
+
def _request(
|
|
122
|
+
self,
|
|
123
|
+
method: str,
|
|
124
|
+
path: str,
|
|
125
|
+
query: dict[str, str] | None,
|
|
126
|
+
payload: dict[str, Any] | bytes | None,
|
|
127
|
+
*,
|
|
128
|
+
content_type: str = "application/json",
|
|
129
|
+
headers: dict[str, str] | None = None,
|
|
130
|
+
) -> bytes:
|
|
131
|
+
settings = configuration.Configuration(
|
|
132
|
+
host=self.api_base_url,
|
|
133
|
+
api_key={"Coval_Agents_API_ApiKeyAuth": self.api_key},
|
|
134
|
+
retries=0,
|
|
135
|
+
)
|
|
136
|
+
# The generated client leaves request/response members untyped. Keep
|
|
137
|
+
# that dynamic boundary here and validate JSON before returning it.
|
|
138
|
+
sdk: Any = api_client.ApiClient(settings)
|
|
139
|
+
request_headers = dict(headers or {})
|
|
140
|
+
request_headers.update(
|
|
141
|
+
{
|
|
142
|
+
"Accept": "application/json",
|
|
143
|
+
"Content-Type": content_type,
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
if self.workspace_id is not None:
|
|
147
|
+
request_headers["X-Coval-Workspace-Id"] = self.workspace_id
|
|
148
|
+
try:
|
|
149
|
+
# Discovery is not in the generated endpoint set. Keep request
|
|
150
|
+
# serialization and transport in the API SDK; models stay private.
|
|
151
|
+
parameters = sdk.param_serialize(
|
|
152
|
+
method=method,
|
|
153
|
+
resource_path=f"/{path.lstrip('/')}",
|
|
154
|
+
query_params=list((query or {}).items()),
|
|
155
|
+
header_params=request_headers,
|
|
156
|
+
body=payload,
|
|
157
|
+
auth_settings=["Coval_Agents_API_ApiKeyAuth"],
|
|
158
|
+
)
|
|
159
|
+
response = sdk.call_api(
|
|
160
|
+
*parameters,
|
|
161
|
+
_request_timeout=_defaults.HTTP_TIMEOUT_SECONDS,
|
|
162
|
+
)
|
|
163
|
+
try:
|
|
164
|
+
body = response.read()
|
|
165
|
+
if not 200 <= response.status <= 299:
|
|
166
|
+
detail = body[:500].decode("utf-8", errors="replace")
|
|
167
|
+
raise errors.CovalHttpError(
|
|
168
|
+
status_code=response.status,
|
|
169
|
+
message=(
|
|
170
|
+
f"Coval {method} {path} returned HTTP "
|
|
171
|
+
f"{response.status}: {detail}"
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
return cast(bytes, body)
|
|
175
|
+
finally:
|
|
176
|
+
response.response.close()
|
|
177
|
+
response.response.release_conn()
|
|
178
|
+
except exceptions.ApiException as exception:
|
|
179
|
+
status: object = cast(Any, exception).status
|
|
180
|
+
if isinstance(status, int) and status != 0:
|
|
181
|
+
raise errors.CovalHttpError(
|
|
182
|
+
status_code=status,
|
|
183
|
+
message=(f"Coval {method} {path} returned HTTP {status}."),
|
|
184
|
+
) from None
|
|
185
|
+
raise errors.CovalConnectionError(
|
|
186
|
+
f"Coval {method} {path} could not reach the API."
|
|
187
|
+
) from None
|
|
188
|
+
except (http_exceptions.HTTPError, OSError) as exception:
|
|
189
|
+
raise errors.CovalConnectionError(
|
|
190
|
+
f"Coval {method} {path} could not reach the API: "
|
|
191
|
+
f"{type(exception).__name__}."
|
|
192
|
+
) from None
|
|
193
|
+
finally:
|
|
194
|
+
# A cancelled coroutine cannot stop its blocking worker. The worker
|
|
195
|
+
# retains ownership and releases its pool after the bounded request.
|
|
196
|
+
sdk.rest_client.pool_manager.clear()
|