midwater 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.
- midwater-0.1.0/.gitignore +15 -0
- midwater-0.1.0/CHANGELOG.md +36 -0
- midwater-0.1.0/LICENSE +21 -0
- midwater-0.1.0/PKG-INFO +412 -0
- midwater-0.1.0/README.md +380 -0
- midwater-0.1.0/fixtures/SHA256SUMS +11 -0
- midwater-0.1.0/fixtures/agent-health.json +29 -0
- midwater-0.1.0/fixtures/conversation-accepted.json +4 -0
- midwater-0.1.0/fixtures/conversation-create.json +57 -0
- midwater-0.1.0/fixtures/conversation-duplicate.json +5 -0
- midwater-0.1.0/fixtures/conversation.json +55 -0
- midwater-0.1.0/fixtures/errors.json +125 -0
- midwater-0.1.0/fixtures/feedback-create.json +5 -0
- midwater-0.1.0/fixtures/feedback.json +6 -0
- midwater-0.1.0/fixtures/group-health.json +37 -0
- midwater-0.1.0/fixtures/webhook-vectors.json +62 -0
- midwater-0.1.0/openapi/midwater.yaml +892 -0
- midwater-0.1.0/pyproject.toml +90 -0
- midwater-0.1.0/src/midwater/__init__.py +72 -0
- midwater-0.1.0/src/midwater/_base.py +242 -0
- midwater-0.1.0/src/midwater/_client.py +443 -0
- midwater-0.1.0/src/midwater/_errors.py +162 -0
- midwater-0.1.0/src/midwater/_version.py +1 -0
- midwater-0.1.0/src/midwater/py.typed +0 -0
- midwater-0.1.0/src/midwater/types.py +446 -0
- midwater-0.1.0/src/midwater/webhooks.py +148 -0
- midwater-0.1.0/tests/conftest.py +57 -0
- midwater-0.1.0/tests/contract/conftest.py +34 -0
- midwater-0.1.0/tests/contract/test_contract.py +269 -0
- midwater-0.1.0/tests/helpers.py +69 -0
- midwater-0.1.0/tests/test_client_async.py +246 -0
- midwater-0.1.0/tests/test_client_sync.py +612 -0
- midwater-0.1.0/tests/test_config.py +154 -0
- midwater-0.1.0/tests/test_no_key_leak.py +154 -0
- midwater-0.1.0/tests/test_pins.py +42 -0
- midwater-0.1.0/tests/test_webhooks.py +150 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the `midwater` package are listed here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - Unreleased
|
|
8
|
+
|
|
9
|
+
**Unpublished.** This version is not on PyPI.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `Midwater` (sync) and `AsyncMidwater` (async) clients on `httpx`. `api_key` and `base_url`
|
|
14
|
+
are both required (arguments, or `MIDWATER_API_KEY` and `MIDWATER_BASE_URL`); there is no
|
|
15
|
+
default host, and a missing base URL raises `MidwaterError`.
|
|
16
|
+
- `conversations.create()`, `get()`, `wait()` and `feedback()`; `agents.health()`;
|
|
17
|
+
`groups.health()`.
|
|
18
|
+
- Typed request payloads (`ConversationCreate`, `FeedbackCreate`) and response models
|
|
19
|
+
(`ConversationAccepted`, `Conversation`, `CheckResult`, `Feedback`, `AgentHealth`,
|
|
20
|
+
`GroupHealth`, `HealthWindow`, `WebhookEvent`). Unknown fields and enum values pass through.
|
|
21
|
+
- Error classes mapped from HTTP status: `ValidationError` (400, 422), `AuthenticationError`
|
|
22
|
+
(401), `PermissionDeniedError` (403), `NotFoundError` (404), `RequestTimeoutError` (408),
|
|
23
|
+
`IdempotencyConflictError` (409), `PayloadTooLargeError` (413), `RateLimitError` (429),
|
|
24
|
+
`ServiceUnavailableError` (503, a `ServerError`), `ServerError` (other 5xx) and `APIError`
|
|
25
|
+
(anything else). Each has `.status`, `.type`, `.message`, `.fields`, `.body` and
|
|
26
|
+
`.request_id` (from `error.request_id` or the `Midwater-Request-Id` header; both planned
|
|
27
|
+
server-side). Successful responses expose `.request_id` too.
|
|
28
|
+
- Retries on 408, 429, 5xx and network errors, only for calls that are safe to repeat (GETs
|
|
29
|
+
and `conversations.create()`), with exponential backoff, jitter and `Retry-After` (up to
|
|
30
|
+
60 s). `max_retries` defaults to 2 and is capped at 3.
|
|
31
|
+
- An `Idempotency-Key` on every POST (generated UUID4 unless passed). `feedback()` takes an
|
|
32
|
+
`idempotency_key` but is never retried until the server honours the key on that endpoint.
|
|
33
|
+
- `webhooks.verify()` (also `midwater.verify_webhook`) and `webhooks.sign()` for
|
|
34
|
+
`Midwater-Signature`.
|
|
35
|
+
- Shared fixtures in `fixtures/`, pinned by `fixtures/SHA256SUMS` and checked in CI and by
|
|
36
|
+
`tests/test_pins.py`.
|
midwater-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Midwater
|
|
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.
|
midwater-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: midwater
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the Midwater API: send AI-agent conversations, read check results and agent health, verify webhooks.
|
|
5
|
+
Project-URL: Homepage, https://midwater.ai
|
|
6
|
+
Author-email: Midwater <hello@midwater.ai>
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: ai agents,conversation quality,midwater,monitoring,voice agents
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Requires-Dist: httpx<1,>=0.25
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: build>=1.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: mypy>=1.5; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-cov>=4; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# Midwater Python SDK
|
|
34
|
+
|
|
35
|
+
The official Python client for the [Midwater](https://midwater.ai) API. Midwater checks every
|
|
36
|
+
conversation your AI agents have, voice calls and chats. Send each finished conversation with
|
|
37
|
+
its transcript; Midwater's model scores it in the background, and you read back the outcome,
|
|
38
|
+
the result of every check, and each agent's health.
|
|
39
|
+
|
|
40
|
+
- Sync (`Midwater`) and async (`AsyncMidwater`) clients built on `httpx`
|
|
41
|
+
- Typed request payloads and response models (`py.typed`)
|
|
42
|
+
- Automatic retries with backoff, only on calls that are safe to repeat
|
|
43
|
+
- An `Idempotency-Key` on every POST
|
|
44
|
+
- Webhook signature verification
|
|
45
|
+
- Python 3.9+
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install midwater
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
> **Version 0.1.0 is unpublished.** The package is not on PyPI yet. Until it is, install
|
|
54
|
+
> from a checkout: `pip install -e /path/to/midwater-sdk-python`.
|
|
55
|
+
|
|
56
|
+
## Quickstart
|
|
57
|
+
|
|
58
|
+
Set your API key and the base URL for your Midwater environment. Both are required; there is
|
|
59
|
+
no default host.
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
export MIDWATER_API_KEY=mw_test_...
|
|
63
|
+
export MIDWATER_BASE_URL="<the base URL for your Midwater environment>"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from midwater import Midwater
|
|
68
|
+
|
|
69
|
+
client = Midwater() # reads MIDWATER_API_KEY and MIDWATER_BASE_URL
|
|
70
|
+
|
|
71
|
+
accepted = client.conversations.create(
|
|
72
|
+
{
|
|
73
|
+
"external_id": "call_8f2a91",
|
|
74
|
+
"channel": "voice",
|
|
75
|
+
"started_at": "2026-10-05T14:02:11Z",
|
|
76
|
+
"ended_at": "2026-10-05T14:06:40Z",
|
|
77
|
+
"ended_by": "caller",
|
|
78
|
+
"agent": {"id": "front-desk", "name": "Front desk", "version": "1.0.0"},
|
|
79
|
+
"group": {"id": "clinics", "name": "Clinics"},
|
|
80
|
+
"transcript": [
|
|
81
|
+
{
|
|
82
|
+
"speaker": "agent",
|
|
83
|
+
"text": "Thanks for calling. How can I help?",
|
|
84
|
+
"start_ms": 0,
|
|
85
|
+
"end_ms": 2400,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"speaker": "user",
|
|
89
|
+
"text": "I need to move my appointment to Thursday.",
|
|
90
|
+
"start_ms": 2900,
|
|
91
|
+
"end_ms": 5100,
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"speaker": "agent",
|
|
95
|
+
"text": "Done, you're set for Thursday at 10 AM.",
|
|
96
|
+
"start_ms": 5600,
|
|
97
|
+
"end_ms": 8200,
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
"events": [
|
|
101
|
+
{
|
|
102
|
+
"type": "tool_call",
|
|
103
|
+
"name": "reschedule_appointment",
|
|
104
|
+
"status": "success",
|
|
105
|
+
"at_ms": 5000,
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
"metadata": {"language": "en"},
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
print(accepted.id, accepted.status) # "cmv0...", "queued"
|
|
112
|
+
|
|
113
|
+
conversation = client.conversations.wait(accepted.id, timeout=60)
|
|
114
|
+
print(
|
|
115
|
+
"Outcome:", conversation.outcome
|
|
116
|
+
) # resolved / unresolved / escalated / not_real_inquiry / None
|
|
117
|
+
for result in conversation.results:
|
|
118
|
+
print(f"{result.check_key}: {result.verdict} (score {result.score}, by {result.decided_by})")
|
|
119
|
+
print("Open in Midwater:", conversation.dashboard_url)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`conversations.get(id)` and `wait(id)` accept Midwater's ID or your own `external_id`.
|
|
123
|
+
`wait()` polls until `status` is `done` or `failed` and raises `WaitTimeoutError` (a
|
|
124
|
+
`MidwaterError` and a `TimeoutError`) if scoring hasn't finished in time. You can also skip
|
|
125
|
+
polling and listen for the `conversation.evaluated` webhook.
|
|
126
|
+
|
|
127
|
+
Each check result has a `verdict` field, the check's result: `pass` (no problem found), `fail`
|
|
128
|
+
(Midwater found this problem), `uncertain` (a person should look), `not_applicable`, or `met` /
|
|
129
|
+
`not_met` for gating questions. `decided_by` says how it was reached: `rule` (from the events you
|
|
130
|
+
sent), `model` (Midwater's model read the conversation), `llm_judge` (a second review for unclear
|
|
131
|
+
conversations) or `human`.
|
|
132
|
+
|
|
133
|
+
Every response model keeps the decoded JSON in `.raw`, so newly added API fields are available
|
|
134
|
+
before the SDK names them.
|
|
135
|
+
|
|
136
|
+
## Feedback
|
|
137
|
+
|
|
138
|
+
Confirm or correct a check's result. Each call stores a label with source `api`:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
feedback = client.conversations.feedback(
|
|
142
|
+
"call_8f2a91",
|
|
143
|
+
check_key="need_unresolved",
|
|
144
|
+
verdict="fail",
|
|
145
|
+
note="Caller hung up before the booking went through.",
|
|
146
|
+
)
|
|
147
|
+
print(feedback.source) # "api"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Like every POST, feedback carries an `Idempotency-Key` (a generated UUID4, or pass your own
|
|
151
|
+
with `idempotency_key=`). The SDK never retries feedback, not even after a `429` or a
|
|
152
|
+
connection error: until the server de-duplicates feedback by that key (planned), a retry could
|
|
153
|
+
store the same label twice.
|
|
154
|
+
|
|
155
|
+
## Agent and group health
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
health = client.agents.health("front-desk")
|
|
159
|
+
print(health.health_status, "-", health.reason) # e.g. not_enough_calls - Needs 4 more calls...
|
|
160
|
+
print(health.last_7_days.resolution_rate)
|
|
161
|
+
|
|
162
|
+
group = client.groups.health("clinics")
|
|
163
|
+
for agent in group.agents:
|
|
164
|
+
print(agent.id, agent.health_status)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Health is measured per environment: a test key sees test traffic only.
|
|
168
|
+
|
|
169
|
+
## Webhooks
|
|
170
|
+
|
|
171
|
+
Midwater signs every delivery with the `Midwater-Signature` header,
|
|
172
|
+
`t=<unix seconds>,v1=<hex>`, an HMAC-SHA256 of `"<t>.<raw body>"` keyed with your destination's
|
|
173
|
+
signing secret (`whsec_...`). During secret rotation a header can carry several `v1` values;
|
|
174
|
+
any match passes. `webhooks.verify()` (also exported as `midwater.verify_webhook`, same
|
|
175
|
+
signature) checks the signature and the timestamp (default tolerance 300 seconds) and returns
|
|
176
|
+
the parsed event.
|
|
177
|
+
|
|
178
|
+
**Always pass the exact raw bytes you received.** Don't parse the JSON and serialise it again
|
|
179
|
+
first; that changes the bytes and the signature won't match.
|
|
180
|
+
|
|
181
|
+
Flask:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
import os
|
|
185
|
+
from flask import Flask, request
|
|
186
|
+
from midwater import verify_webhook, WebhookVerificationError
|
|
187
|
+
|
|
188
|
+
app = Flask(__name__)
|
|
189
|
+
SECRET = os.environ["MIDWATER_WEBHOOK_SECRET"]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@app.post("/midwater/webhooks")
|
|
193
|
+
def midwater_webhook():
|
|
194
|
+
try:
|
|
195
|
+
event = verify_webhook(request.get_data(), request.headers, SECRET)
|
|
196
|
+
except WebhookVerificationError as exc:
|
|
197
|
+
return {"error": exc.reason}, 400
|
|
198
|
+
if event["type"] == "conversation.evaluated":
|
|
199
|
+
print(event["data"]["conversation_id"], event["data"]["outcome"])
|
|
200
|
+
return "", 204
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
FastAPI / Starlette:
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
207
|
+
from midwater import webhooks, WebhookVerificationError
|
|
208
|
+
|
|
209
|
+
app = FastAPI()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@app.post("/midwater/webhooks")
|
|
213
|
+
async def midwater_webhook(request: Request):
|
|
214
|
+
raw = await request.body() # the raw bytes, not request.json()
|
|
215
|
+
try:
|
|
216
|
+
event = webhooks.verify(raw, request.headers, SECRET)
|
|
217
|
+
except WebhookVerificationError as exc:
|
|
218
|
+
raise HTTPException(status_code=400, detail=exc.reason)
|
|
219
|
+
...
|
|
220
|
+
return {"ok": True}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
`WebhookVerificationError.reason` is one of `missing_header`, `malformed_header`,
|
|
224
|
+
`stale_timestamp`, `invalid_signature` or `no_secret`. Header lookup is case-insensitive.
|
|
225
|
+
Only `Midwater-Signature` is read. Each delivery also has `Midwater-Event` (the event type) and
|
|
226
|
+
`Midwater-Delivery` (the same on every retry of one delivery, useful for de-duplication).
|
|
227
|
+
Answer with any 2xx within 5 seconds; anything else is retried.
|
|
228
|
+
|
|
229
|
+
For your own tests, `webhooks.sign(payload, secret, timestamp=None)` builds a valid header value.
|
|
230
|
+
|
|
231
|
+
## Errors
|
|
232
|
+
|
|
233
|
+
All errors derive from `midwater.MidwaterError`.
|
|
234
|
+
|
|
235
|
+
| Exception | When | `.type` |
|
|
236
|
+
| --- | --- | --- |
|
|
237
|
+
| `ValidationError` | HTTP 400 (invalid JSON) or 422 (schema); see `.fields` | `invalid_json`, `validation_error` |
|
|
238
|
+
| `AuthenticationError` | HTTP 401, or no usable API key at construction | `authentication_error` |
|
|
239
|
+
| `PermissionDeniedError` | HTTP 403 | `permission_denied` |
|
|
240
|
+
| `NotFoundError` | HTTP 404 | `not_found` |
|
|
241
|
+
| `RequestTimeoutError` | HTTP 408 (after retries) | |
|
|
242
|
+
| `IdempotencyConflictError` | HTTP 409: the `Idempotency-Key` was used with a different body | `idempotency_conflict` |
|
|
243
|
+
| `PayloadTooLargeError` | HTTP 413 | `payload_too_large` |
|
|
244
|
+
| `RateLimitError` | HTTP 429 (after retries) | `rate_limited` |
|
|
245
|
+
| `ServiceUnavailableError` | HTTP 503 (after retries); a `ServerError` | `service_unavailable` |
|
|
246
|
+
| `ServerError` | Any other HTTP 5xx (after retries) | `server_error` |
|
|
247
|
+
| `APIError` | Any other error status; base class of the above | |
|
|
248
|
+
| `APIConnectionError` | No HTTP answer: DNS, refused connection, timeout | |
|
|
249
|
+
| `WaitTimeoutError` | `conversations.wait()` ran out of time | |
|
|
250
|
+
| `WebhookVerificationError` | `webhooks.verify()` rejected a delivery; see `.reason` | |
|
|
251
|
+
|
|
252
|
+
`MidwaterError` itself is raised when no base URL is configured.
|
|
253
|
+
|
|
254
|
+
The class is chosen from the HTTP status, never from `.type`, so an error type added to the API
|
|
255
|
+
later never breaks your error handling.
|
|
256
|
+
|
|
257
|
+
Every `APIError` has `.status`, `.type`, `.message`, `.fields` (validation messages by dotted
|
|
258
|
+
path, such as `transcript.0.speaker`), `.body` (the parsed JSON or raw text) and `.request_id`.
|
|
259
|
+
`.request_id` comes from `error.request_id` in the body, else the `Midwater-Request-Id`
|
|
260
|
+
response header, else `None`. Both are **planned** on the server, so expect `None` for now;
|
|
261
|
+
quote it to support once it appears. Successful responses carry the header's value on
|
|
262
|
+
`.request_id` too (also `None` for now).
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
from midwater import ValidationError
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
client.conversations.create({"external_id": "x", "channel": "fax", "transcript": []})
|
|
269
|
+
except ValidationError as exc:
|
|
270
|
+
for path, messages in exc.fields.items():
|
|
271
|
+
print(path, messages)
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Exception messages, log output and `repr(client)` never include your API key (a test turns on
|
|
275
|
+
DEBUG logging for the SDK, `httpx` and `httpcore` and checks).
|
|
276
|
+
|
|
277
|
+
## Retries and idempotency
|
|
278
|
+
|
|
279
|
+
The SDK retries HTTP 408, 429, every 5xx, and network errors (connection failures and
|
|
280
|
+
timeouts), **only on calls that are safe to repeat**: the GETs (`conversations.get()`,
|
|
281
|
+
`wait()`, `agents.health()`, `groups.health()`) and `conversations.create()`, which always
|
|
282
|
+
carries an `Idempotency-Key`. `conversations.feedback()` is never retried (see
|
|
283
|
+
[Feedback](#feedback)). Other 4xx answers are never retried.
|
|
284
|
+
|
|
285
|
+
`max_retries` defaults to 2 and is capped at 3: a larger value is treated as 3. Retries use
|
|
286
|
+
exponential backoff with jitter (0.5 s, 1 s, 2 s, capped at 8 s). A numeric `Retry-After`
|
|
287
|
+
header (seconds) is honoured, up to 60 s.
|
|
288
|
+
|
|
289
|
+
Every POST carries an `Idempotency-Key`. **If you don't pass `idempotency_key`, the SDK
|
|
290
|
+
generates one (a UUID4) for each call and reuses it across that call's retries.** A repeated
|
|
291
|
+
key answers with the first response and `accepted.replayed` is `True`. The key used for a
|
|
292
|
+
conversation is on `accepted.idempotency_key`. Two parts of this are planned on the server and
|
|
293
|
+
not live yet: keys expiring after 24 hours, and answering `409 idempotency_conflict`
|
|
294
|
+
(`IdempotencyConflictError`) when a key is reused with a different body.
|
|
295
|
+
|
|
296
|
+
To make retries safe across processes or restarts too, pass your own key, for example your
|
|
297
|
+
`external_id`:
|
|
298
|
+
|
|
299
|
+
```python
|
|
300
|
+
client.conversations.create(payload, idempotency_key=payload["external_id"])
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Separately, sending an `external_id` that already exists returns the existing conversation
|
|
304
|
+
with `accepted.duplicate == True`; nothing new is created.
|
|
305
|
+
|
|
306
|
+
## Async
|
|
307
|
+
|
|
308
|
+
```python
|
|
309
|
+
import asyncio
|
|
310
|
+
from midwater import AsyncMidwater
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
async def main() -> None:
|
|
314
|
+
async with AsyncMidwater() as client:
|
|
315
|
+
accepted = await client.conversations.create(payload)
|
|
316
|
+
conversation = await client.conversations.wait(accepted.id)
|
|
317
|
+
print(conversation.outcome)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
asyncio.run(main())
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
`AsyncMidwater` has the same methods as `Midwater`, as coroutines. Use `async with`, or call
|
|
324
|
+
`await client.close()`. The sync client supports `with` and `client.close()`.
|
|
325
|
+
|
|
326
|
+
## Environments and API keys
|
|
327
|
+
|
|
328
|
+
Each API key belongs to one environment of one project:
|
|
329
|
+
|
|
330
|
+
- `mw_test_...`: the **test** environment. Use it in development and CI.
|
|
331
|
+
- `mw_live_...`: the **live** environment, for production traffic.
|
|
332
|
+
|
|
333
|
+
Keys made before October 2026 start `vk_test_` / `vk_live_` and still work.
|
|
334
|
+
|
|
335
|
+
Everything you send and read is scoped to the key's environment: a test key can't see live
|
|
336
|
+
conversations, and health is measured separately per environment. The client checks the key's
|
|
337
|
+
prefix when it's constructed and raises `AuthenticationError` if it doesn't look like a
|
|
338
|
+
Midwater key.
|
|
339
|
+
|
|
340
|
+
## Configuration
|
|
341
|
+
|
|
342
|
+
```python
|
|
343
|
+
client = Midwater(
|
|
344
|
+
api_key=None, # required: pass it or set MIDWATER_API_KEY
|
|
345
|
+
base_url=None, # required: pass it or set MIDWATER_BASE_URL
|
|
346
|
+
timeout=30.0, # seconds per request
|
|
347
|
+
max_retries=2, # at most 3
|
|
348
|
+
http_client=None, # your own httpx.Client (AsyncMidwater: httpx.AsyncClient)
|
|
349
|
+
)
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
`base_url` is the base URL for your Midwater environment. There is no default host: without
|
|
353
|
+
`base_url` or `MIDWATER_BASE_URL` the client raises `MidwaterError`. If you pass `http_client`,
|
|
354
|
+
its own timeout applies and the SDK won't close it.
|
|
355
|
+
|
|
356
|
+
## Versioning
|
|
357
|
+
|
|
358
|
+
Every path is under `/v1`. Within `/v1` the API may add response fields and new values to
|
|
359
|
+
enum-like fields (statuses, outcomes, check results, error types). The SDK passes both through
|
|
360
|
+
instead of rejecting them, and your code should accept them too: treat an unknown value as
|
|
361
|
+
"something new", and read new fields from `.raw` until the SDK names them.
|
|
362
|
+
|
|
363
|
+
## Development
|
|
364
|
+
|
|
365
|
+
```bash
|
|
366
|
+
python3 -m venv .venv
|
|
367
|
+
.venv/bin/pip install -e ".[dev]"
|
|
368
|
+
.venv/bin/ruff check . && .venv/bin/ruff format --check .
|
|
369
|
+
.venv/bin/mypy --strict src
|
|
370
|
+
sha256sum -c fixtures/SHA256SUMS # macOS: shasum -a 256 -c fixtures/SHA256SUMS
|
|
371
|
+
.venv/bin/pytest --cov=midwater --cov-report=term-missing --cov-fail-under=90 # unit tests
|
|
372
|
+
.venv/bin/pytest -m contract # live tests against a local Midwater stack
|
|
373
|
+
.venv/bin/python -m build
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
Contract tests read `MIDWATER_API_KEY` and `MIDWATER_BASE_URL` from the environment only, and
|
|
377
|
+
are skipped when either is unset. They only run against `localhost`. The API contract lives in
|
|
378
|
+
`openapi/midwater.yaml`.
|
|
379
|
+
|
|
380
|
+
### Shared fixtures
|
|
381
|
+
|
|
382
|
+
`fixtures/` holds JSON shared by every Midwater SDK: a conversation payload, the API's answers
|
|
383
|
+
(`conversation.json`, `conversation-accepted.json`, `conversation-duplicate.json`,
|
|
384
|
+
`feedback.json`, `agent-health.json`, `group-health.json`), every error type with its status
|
|
385
|
+
(`errors.json`) and the webhook signature vectors (`webhook-vectors.json`). The unit tests mock
|
|
386
|
+
the API with these files. They are generated in another repository and copied in unchanged;
|
|
387
|
+
don't edit them here. `fixtures/SHA256SUMS` pins their checksums and the checksum of
|
|
388
|
+
`openapi/midwater.yaml`: CI runs `sha256sum -c fixtures/SHA256SUMS`, and `tests/test_pins.py`
|
|
389
|
+
fails if any pinned file drifts.
|
|
390
|
+
|
|
391
|
+
## Releasing
|
|
392
|
+
|
|
393
|
+
**Nothing is published today.** Publishing needs the owner's go-ahead. When that's given:
|
|
394
|
+
|
|
395
|
+
1. Create (or confirm) a company-owned `midwater` project on PyPI, owned by a Midwater
|
|
396
|
+
organisation account rather than a personal one, with at least two owners and 2FA.
|
|
397
|
+
2. On PyPI, add a *trusted publisher* for the project: this GitHub repository, a workflow such
|
|
398
|
+
as `.github/workflows/release.yml`, and a protected `pypi` environment that requires
|
|
399
|
+
approval. No API tokens are stored anywhere.
|
|
400
|
+
3. Add that release workflow: on a published GitHub release (or a `v*` tag), build with
|
|
401
|
+
`python -m build`, then upload with `pypa/gh-action-pypi-publish` using
|
|
402
|
+
`permissions: id-token: write` in the `pypi` environment. Try it against TestPyPI first.
|
|
403
|
+
4. Bump `src/midwater/_version.py` if needed, move the `CHANGELOG.md` entry out of
|
|
404
|
+
"Unreleased", and tag the release.
|
|
405
|
+
5. Remove the "unpublished" note from this README.
|
|
406
|
+
|
|
407
|
+
The CI workflow in this repository only lints, type-checks, tests and builds; it never
|
|
408
|
+
publishes.
|
|
409
|
+
|
|
410
|
+
## License
|
|
411
|
+
|
|
412
|
+
MIT. See [LICENSE](LICENSE).
|