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.
Files changed (36) hide show
  1. midwater-0.1.0/.gitignore +15 -0
  2. midwater-0.1.0/CHANGELOG.md +36 -0
  3. midwater-0.1.0/LICENSE +21 -0
  4. midwater-0.1.0/PKG-INFO +412 -0
  5. midwater-0.1.0/README.md +380 -0
  6. midwater-0.1.0/fixtures/SHA256SUMS +11 -0
  7. midwater-0.1.0/fixtures/agent-health.json +29 -0
  8. midwater-0.1.0/fixtures/conversation-accepted.json +4 -0
  9. midwater-0.1.0/fixtures/conversation-create.json +57 -0
  10. midwater-0.1.0/fixtures/conversation-duplicate.json +5 -0
  11. midwater-0.1.0/fixtures/conversation.json +55 -0
  12. midwater-0.1.0/fixtures/errors.json +125 -0
  13. midwater-0.1.0/fixtures/feedback-create.json +5 -0
  14. midwater-0.1.0/fixtures/feedback.json +6 -0
  15. midwater-0.1.0/fixtures/group-health.json +37 -0
  16. midwater-0.1.0/fixtures/webhook-vectors.json +62 -0
  17. midwater-0.1.0/openapi/midwater.yaml +892 -0
  18. midwater-0.1.0/pyproject.toml +90 -0
  19. midwater-0.1.0/src/midwater/__init__.py +72 -0
  20. midwater-0.1.0/src/midwater/_base.py +242 -0
  21. midwater-0.1.0/src/midwater/_client.py +443 -0
  22. midwater-0.1.0/src/midwater/_errors.py +162 -0
  23. midwater-0.1.0/src/midwater/_version.py +1 -0
  24. midwater-0.1.0/src/midwater/py.typed +0 -0
  25. midwater-0.1.0/src/midwater/types.py +446 -0
  26. midwater-0.1.0/src/midwater/webhooks.py +148 -0
  27. midwater-0.1.0/tests/conftest.py +57 -0
  28. midwater-0.1.0/tests/contract/conftest.py +34 -0
  29. midwater-0.1.0/tests/contract/test_contract.py +269 -0
  30. midwater-0.1.0/tests/helpers.py +69 -0
  31. midwater-0.1.0/tests/test_client_async.py +246 -0
  32. midwater-0.1.0/tests/test_client_sync.py +612 -0
  33. midwater-0.1.0/tests/test_config.py +154 -0
  34. midwater-0.1.0/tests/test_no_key_leak.py +154 -0
  35. midwater-0.1.0/tests/test_pins.py +42 -0
  36. midwater-0.1.0/tests/test_webhooks.py +150 -0
@@ -0,0 +1,15 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+ .env*
10
+ !.env.example
11
+ .DS_Store
12
+ .venv312/
13
+ .coverage
14
+ .coverage.*
15
+ htmlcov/
@@ -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.
@@ -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).