Upgrading the spec and regenerating the client
Nothing tells this repo when the service's spec moves. Someone notices, and then
runs this pipeline. The steps are ordered because each one's output is the next
one's input, and because two of them are the only thing standing between a stale
copy and a client that looks generated but isn't.
The pieces
| Thing |
Where |
| Vendored spec |
specs/llmwhisperer.json |
| Generator wrapper |
tools/gen_sdk.sh (pins the generator, records SPEC_SOURCE_*) |
| Generated transport |
src/unstract/llmwhisperer/_sdk_llmwhisperer/ — never hand-edited |
| Hand-written facade |
src/unstract/llmwhisperer/client_v2.py |
| Gates |
sdk-drift and api-surface jobs in .github/workflows/ci_test.yaml |
| Release |
.github/workflows/main.yml, workflow_dispatch |
Upstream, the spec is produced by the service that serves these endpoints
(Zipstack/unstract-llm-whisperer, generated by its tools/gen_spec.py,
committed at specs/llmwhisperer.json, with its own drift workflow). It is a
copy here, not a fork.
The sequence
Copy the spec byte-for-byte from the upstream commit you are upgrading to.
Do not reformat it and do not edit it here — the drift gate regenerates from
whatever is committed, so an edit here becomes a client no service serves.
Move SPEC_SOURCE_REV in tools/gen_sdk.sh in the same commit (alongside
SPEC_SOURCE_REPO / SPEC_SOURCE_PATH if upstream moved the file). Without
that update nothing distinguishes a current copy from one upstream has moved
past — the script and the drift gate both report clean either way, which is
exactly the failure this pipeline exists to prevent.
Regenerate:
./tools/gen_sdk.sh
It rebuilds the tree from scratch with a pinned generator. If it exits
non-zero on a generator warning, believe it: the generator downgrades a
schema it cannot parse to a warning, drops the endpoint or model, writes the
rest and exits 0 — so the warning is the only signal that an operation is
missing.
Review the generated surface, with new files included:
git add -N -- src/unstract/llmwhisperer/_sdk_llmwhisperer
git diff --stat -- src/unstract/llmwhisperer/_sdk_llmwhisperer
git add -N first because a plain diff cannot see a file the generator has
newly created — which is precisely what a spec that grew an endpoint
produces. Read the diff as the API change it represents.
Make the new surface reachable, in client_v2.py. Regeneration only moves
the transport. If the spec added an endpoint or parameter callers should be
able to use, the facade is where it becomes public API. Fixes belong here or
upstream in the spec, never in the generated tree — regeneration overwrites
that wholesale.
Run the tests: tox, which collects all of tests/ — the integration
tier included, so it needs LLMWHISPERER_API_KEY in .env. uv run pytest tests/unit is the offline subset for a fast loop, not a substitute: it
cannot catch an integration regression. tests/unit/compat_test.py
compares this client against the last released one, vendored under
tests/baseline/. Refresh that baseline only when you mean to move the parity
reference point, with tools/refresh_baseline.sh <released-version>; a spec
upgrade on its own is not a reason to move it.
The two CI gates
sdk-drift regenerates from the committed spec and fails on any diff, so a
hand-edit of the generated tree and a spec change nobody regenerated over both
fail the same way. If it is red, run step 3 and commit the result.
api-surface runs griffe check against the latest release tag — signatures
only, so it catches a facade change that breaks callers without changing the
wire. Two things about it are worth knowing before you fight it:
- It checks named modules, not the whole package, because the generated
subpackage is public to griffe: a spec change that drops a field would
otherwise report as a breakage of this client's own surface, while the drift
gate simultaneously demands the generated tree follow the spec. So if you add,
delete or rename a public module, update the module list in the
griffe check
command in ci_test.yaml in the same PR — otherwise the gate silently stops
watching a module, or fails on one that no longer exists.
- It is not redundant with
compat_test.py. Griffe cannot see what goes out on
the wire; that suite owns that half.
Versioning and release
Choose the bump by what changed for callers: major when the spec removed or
renamed something callers depend on, minor for new endpoints or new
behaviour, patch for fixes that keep the surface identical. A red
api-surface gate is the signal for major.
Do not touch __version__ in src/unstract/llmwhisperer/__init__.py in your PR.
The in-repo value is the last released version; main.yml reads it, applies the
bump you pick at dispatch time, and commits the result itself. Bumping it in the
PR makes the release skip a version.
Release by dispatching Release Tag and Publish Package on main and choosing
the bump.
Downstream
unstract-cli pins this client exactly and vendors a copy of the same spec.
After a release lands on PyPI, that pin needs bumping there; see the
bump-client-pins skill in that repo.
1---2name: spec-upgrade3description: Sync the OpenAPI spec from the LLMWhisperer service repo into this repo and regenerate the transport layer. Use whenever the service adds or changes an endpoint, when the `sdk-drift` or `api-surface` CI job goes red, when `specs/llmwhisperer.json` is behind upstream, or when someone asks to "regenerate the SDK", "refresh the spec", "pick up the new endpoint", or "release llmwhisperer-client". Reach for this even when the request only mentions the generated code or a new parameter — the upgrade is an ordered pipeline, and skipping a step leaves a gate red or the spec silently stale.4---56# Upgrading the spec and regenerating the client78Nothing tells this repo when the service's spec moves. Someone notices, and then9runs this pipeline. The steps are ordered because each one's output is the next10one's input, and because two of them are the only thing standing between a stale11copy and a client that looks generated but isn't.1213## The pieces1415| Thing | Where |16|---|---|17| Vendored spec | `specs/llmwhisperer.json` |18| Generator wrapper | `tools/gen_sdk.sh` (pins the generator, records `SPEC_SOURCE_*`) |19| Generated transport | `src/unstract/llmwhisperer/_sdk_llmwhisperer/` — never hand-edited |20| Hand-written facade | `src/unstract/llmwhisperer/client_v2.py` |21| Gates | `sdk-drift` and `api-surface` jobs in `.github/workflows/ci_test.yaml` |22| Release | `.github/workflows/main.yml`, `workflow_dispatch` |2324Upstream, the spec is produced by the service that serves these endpoints25(`Zipstack/unstract-llm-whisperer`, generated by its `tools/gen_spec.py`,26committed at `specs/llmwhisperer.json`, with its own drift workflow). It is a27copy here, not a fork.2829## The sequence30311. **Copy the spec byte-for-byte** from the upstream commit you are upgrading to.32 Do not reformat it and do not edit it here — the drift gate regenerates from33 whatever is committed, so an edit here becomes a client no service serves.34352. **Move `SPEC_SOURCE_REV` in `tools/gen_sdk.sh` in the same commit** (alongside36 `SPEC_SOURCE_REPO` / `SPEC_SOURCE_PATH` if upstream moved the file). Without37 that update nothing distinguishes a current copy from one upstream has moved38 past — the script and the drift gate both report clean either way, which is39 exactly the failure this pipeline exists to prevent.40413. **Regenerate:**4243 ```bash44 ./tools/gen_sdk.sh45 ```4647 It rebuilds the tree from scratch with a pinned generator. If it exits48 non-zero on a generator warning, believe it: the generator downgrades a49 schema it cannot parse to a warning, drops the endpoint or model, writes the50 rest and exits 0 — so the warning is the only signal that an operation is51 missing.52534. **Review the generated surface, with new files included:**5455 ```bash56 git add -N -- src/unstract/llmwhisperer/_sdk_llmwhisperer57 git diff --stat -- src/unstract/llmwhisperer/_sdk_llmwhisperer58 ```5960 `git add -N` first because a plain diff cannot see a file the generator has61 newly created — which is precisely what a spec that grew an endpoint62 produces. Read the diff as the API change it represents.63645. **Make the new surface reachable, in `client_v2.py`.** Regeneration only moves65 the transport. If the spec added an endpoint or parameter callers should be66 able to use, the facade is where it becomes public API. Fixes belong here or67 upstream in the spec, never in the generated tree — regeneration overwrites68 that wholesale.69706. **Run the tests:** `tox`, which collects all of `tests/` — the integration71 tier included, so it needs `LLMWHISPERER_API_KEY` in `.env`. `uv run pytest72 tests/unit` is the offline subset for a fast loop, not a substitute: it73 cannot catch an integration regression. `tests/unit/compat_test.py`74 compares this client against the last released one, vendored under75 `tests/baseline/`. Refresh that baseline only when you mean to move the parity76 reference point, with `tools/refresh_baseline.sh <released-version>`; a spec77 upgrade on its own is not a reason to move it.7879## The two CI gates8081**`sdk-drift`** regenerates from the committed spec and fails on any diff, so a82hand-edit of the generated tree and a spec change nobody regenerated over both83fail the same way. If it is red, run step 3 and commit the result.8485**`api-surface`** runs `griffe check` against the latest release tag — signatures86only, so it catches a facade change that breaks callers without changing the87wire. Two things about it are worth knowing before you fight it:8889- It checks **named modules**, not the whole package, because the generated90 subpackage is public to griffe: a spec change that drops a field would91 otherwise report as a breakage of this client's own surface, while the drift92 gate simultaneously demands the generated tree follow the spec. So if you add,93 delete or rename a public module, update the module list in the `griffe check`94 command in `ci_test.yaml` in the same PR — otherwise the gate silently stops95 watching a module, or fails on one that no longer exists.96- It is not redundant with `compat_test.py`. Griffe cannot see what goes out on97 the wire; that suite owns that half.9899## Versioning and release100101Choose the bump by what changed for callers: **major** when the spec removed or102renamed something callers depend on, **minor** for new endpoints or new103behaviour, **patch** for fixes that keep the surface identical. A red104`api-surface` gate is the signal for major.105106Do not touch `__version__` in `src/unstract/llmwhisperer/__init__.py` in your PR.107The in-repo value is the *last released* version; `main.yml` reads it, applies the108bump you pick at dispatch time, and commits the result itself. Bumping it in the109PR makes the release skip a version.110111Release by dispatching **Release Tag and Publish Package** on `main` and choosing112the bump.113114## Downstream115116`unstract-cli` pins this client exactly and vendors a copy of the same spec.117After a release lands on PyPI, that pin needs bumping there; see the118`bump-client-pins` skill in that repo.