Skip to content

QVAC-21805 feat: generate Python pydantic models + typed method stubs from the SDK contract#3100

Merged
lauripiisang merged 21 commits into
mainfrom
QVAC-21805-python-models
Jul 16, 2026
Merged

QVAC-21805 feat: generate Python pydantic models + typed method stubs from the SDK contract#3100
lauripiisang merged 21 commits into
mainfrom
QVAC-21805-python-models

Conversation

@lauripiisang

@lauripiisang lauripiisang commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

🎯 What problem does this PR solve?

  • QVAC-17719 needs a typed Python surface — pydantic models + one function per RPC method — generated from the SDK contract, so schema changes update the Python types with zero hand-editing. First Python package in the monorepo.

📝 How does it solve it?

  • New packages/sdk-python. scripts/generate.py runs datamodel-code-generator against contract/schema.json/manifest.json/models.json into src/qvac/_generated/models/, resolves each manifest method's request/response title to wherever it landed, then renders a flat __init__.py re-export and methods.py — one typed function per manifest entry, grouped by call shape (request-reply / server-stream / duplex), plus typed constant enums (ModelType, PluginId, ...) from the contract's model registry:
    def heartbeat(transport, params: HeartbeatRequest) -> HeartbeatResponse: ...
    def transcribe(transport, params: TranscribeRequest) -> Iterator[TranscribeResponse]: ...
    def transcribe_stream(transport, params: TranscribeStreamRequest, up: Iterable[bytes]) -> Iterator[TranscribeStreamResponse]: ...
  • qvac._transport.Transport is an asyncio Protocol each stub calls through (call/call_stream/call_duplex). Doesn't implement the actual socket transport — that's QVAC-21806 (bare-rpc-python integration, QVAC-21806 feat: prototype bare-rpc-python as the wire transport #3208), stacked on this branch; anything implementing the protocol can back the stubs.
  • qvac.api: hand-written convenience wrappers mirroring the JS SDK's client-side API surface (options split from wire params, e.g. onProgress/logger callbacks).
  • The whole surface is asyncio-native, matching the JS SDK.
  • generate.py --check fails clearly on drift.
  • .github/workflows/pr-checks-sdk-python.yml: standalone workflow (no package.json, can't join pr-checks-sdk-pod.yml) running generate.py --check, a scoped black --check, and pytest.

🧪 How was it tested?

  • Unit tests: every method resolves to a request+response class, index re-exports everything, one stub per method with the right call shape, deterministic rebuild, committed output matches a fresh build.
  • A PoC transport (poc_heartbeat.py/poc_transport.py) implements the Transport protocol and smoke-tests heartbeat/state/progress-streaming (a real streamed loadModel via registry:// modelSrc) against a real spawned SDK worker — round-trips correctly. Skipped unless QVAC_POC_SDK_DIR is set, never blocks CI.
  • Full suite verified in a fresh venv on the exact Python 3.10 floor CI pins.
  • Found and fixed two bugs in the contract export this surfaced (landed in QVAC-21804 feat: contract export — Zod schemas to JSON Schema + method manifest #3085): allOf-of-union not merging, and meaningless positional names on nested schemas.

Dependencies

@lauripiisang
lauripiisang requested review from a team as code owners July 7, 2026 10:15
@lauripiisang
lauripiisang marked this pull request as draft July 7, 2026 10:32
@lauripiisang
lauripiisang force-pushed the QVAC-21805-python-models branch from 02d252a to 3bd44a1 Compare July 8, 2026 14:10
@lauripiisang

Copy link
Copy Markdown
Contributor Author

Rebased onto the updated QVAC-21804-contract-export (positional-title fixes + the new manifest progress block) and regenerated.

Added real progress support on the Python side:

  • generate.py now emits a <method>_with_progress stub alongside the plain stub for loadModel/downloadAsset/rag/finetune, driven by the contract's progress manifest block. It streams via call_stream and dispatches each chunk to the progress model or the terminal response model by its wire type.
  • Verified against a real spawned SDK worker, not a mock: the new test_poc_progress.py serves a real cached GGUF model over a local HTTP server (a plain local-path modelSrc never emits progress at all — only the http/registry resolvers do) and asserts on the actual event stream: real incremental download-progress events followed by a validated terminal LoadModelResponse, all checked against the generated pydantic models.

@lauripiisang

Copy link
Copy Markdown
Contributor Author

Simplified the progress e2e test per feedback: dropped the ad-hoc local HTTP server. It now points modelSrc at a registry:// URL from an existing entry in the SDK's built-in catalog (QWEN3_600M_INST_Q4), whose cache filename already matches our locally cached model — so it hits the registry resolver's real cache-hit branch (local disk + checksum, no network) instead of forcing the http resolver via a hand-rolled server. Same coverage (real modelProgress events + validated terminal LoadModelResponse), less test-only machinery.

@lauripiisang

Copy link
Copy Markdown
Contributor Author

Added the two Tier-2 API-parity pieces (from the JS-vs-Python gap audit, tracked in Asana):

  • qvac.models: one generated ModelConstant per contract/models.json entry — load_model_with_progress(..., modelSrc=QWEN3_600M_INST_Q4.src, ...) instead of hand-writing registry:// strings. Already dogfooded in test_poc_progress.py.
  • qvac.api: hand-written wrappers for the 5 JS convenience functions whose behavior is fully reproducible from the wire schemas — cancel (legacy shape normalization), unload_model, invoke_plugin/invoke_plugin_stream (.result unwrap), model_registry_list/search/get_model (model_typeaddon alias), delete_cache. 20 fake-transport unit tests plus real e2e coverage against a spawned worker (including the failure paths).

Also rebuilt this worktree's own packages/sdk to test against current source rather than a stale binary — caught along the way that a real, previously-untested code path (registry engine canonicalization for legacy names like onnx-ocr) works correctly; no SDK schema changes needed.

Not in scope here (tracked separately under the "Full-fidelity" epic per user direction): completion's orchestration, loadModel's type inference, translate's language detection, vla's tensor marshaling, and the duplex streaming session model — all real client-side logic beyond the wire contract.

@lauripiisang

Copy link
Copy Markdown
Contributor Author

Reduced the PoC to exactly what it should be: QvacWorker now only does process lifecycle + bare-rpc wire framing + the three call shapes (call/call_stream/_duplex_call). All the per-method convenience it used to have (heartbeat(), load_model(), completion(), embed(), transcribe(), transcribe_stream(), text_to_speech_stream()) is gone — that's what qvac._generated.methods + qvac.api are for now.

The demo functions build typed requests and get typed pydantic responses back via PocTransport(w). demo_completion defaults to QWEN3_600M_INST_Q4 from qvac.models, so python3 poc_heartbeat.py runs a real loadModel + completion out of the box, no env vars needed.

Verified for real against a spawned worker: heartbeat, completion (via the registry constant), embed, transcribe, and textToSpeechStream all produce correct typed output. One honest gap: transcribe_stream (parakeet duplex) round-trips correctly through the new typed layer but the model emits no partial text for the bundled audio fixture — same wire payload as before my change, so this looks like a pre-existing model/audio/config compatibility quirk in the original PoC, not a regression. Flagging rather than silently passing over it.

@lauripiisang
lauripiisang force-pushed the QVAC-21804-contract-export branch from 2a4667c to 4b35cec Compare July 9, 2026 12:44
@lauripiisang
lauripiisang force-pushed the QVAC-21805-python-models branch from 55362e7 to eff8999 Compare July 9, 2026 12:47
@lauripiisang

Copy link
Copy Markdown
Contributor Author

Rebased onto the updated QVAC-21804-contract-export (batchCompletionStream fix + new registry entries) and regenerated. Rebuilt this worktree's packages/sdk (stale dist/ after the rebase — same class of issue as before, unrelated to this PR's own code). All 33 tests pass, including the real end-to-end ones against a spawned worker.

@lauripiisang

Copy link
Copy Markdown
Contributor Author

Added tests/python_quickstart.py — a faithful port of packages/sdk/examples/quickstart.ts: load a model with progress, run a streaming completion, unload. Only structural difference from the JS version: pull-based progress (an iterator) instead of an onProgress callback, since there's no ergonomic loadModel() wrapper yet (that's the tracked Tier-3 work). Everything else is the real typed package (qvac.models, qvac._generated.methods, qvac.api) over PocTransport.

Verified for real against a spawned worker: loads LLAMA_3_2_1B_INST_Q4_0, streams a real completion ("Explain quantum computing in one sentence"), unloads.

@lauripiisang

Copy link
Copy Markdown
Contributor Author

Converted the whole Python SDK to asyncio-native, matching the JS SDK's Promise/async-iterator model, rather than bridging a sync client API onto an async wire library (relevant for QVAC-21806's bare-rpc-python work).

  • qvac._transport.Transport: call is async def; call_stream/call_duplex return AsyncIterator, call_duplex takes an AsyncIterable[bytes] for up.
  • Every generated method stub (scripts/generate.py's templates) is now async def, with test_generate.py asserting every stub is ast.AsyncFunctionDef (fails if a sync def slips back in).
  • qvac.api's hand-written wrappers (cancel, unload_model, invoke_plugin(_stream), model_registry_*, delete_cache) are all async.
  • poc_heartbeat.QvacWorker now uses asyncio.start_unix_server/create_subprocess_exec instead of blocking sockets; poc_transport.PocTransport, python_quickstart.py, and all real e2e tests updated to match.

Verified for real against a spawned worker: heartbeat, loadModel (with progress, via a registry constant), embed, transcribe, transcribeStream duplex, textToSpeechStream duplex, and the full quickstart — all 5 call shapes behave identically to before the conversion, including the one known pre-existing quirk (empty transcribeStream text for the bundled fixture) staying exactly as it was.

@lauripiisang
lauripiisang force-pushed the QVAC-21805-python-models branch 2 times, most recently from 317191a to ea37864 Compare July 10, 2026 11:19
Base automatically changed from QVAC-21804-contract-export to main July 13, 2026 16:18
@lauripiisang
lauripiisang force-pushed the QVAC-21805-python-models branch from fd3f50b to feb6336 Compare July 13, 2026 21:56
Comment thread packages/sdk-python/tests/poc_heartbeat.py Fixed
Comment thread packages/sdk-python/src/qvac/_transport.py Dismissed
Comment thread packages/sdk-python/src/qvac/_transport.py Dismissed
Comment thread packages/sdk-python/src/qvac/_transport.py Dismissed
@lauripiisang
lauripiisang marked this pull request as ready for review July 13, 2026 22:05

@opaninakuffo opaninakuffo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

re-reviewed after the rebase onto main + commit reorg; sdk-python tree is byte-identical to the prior pass and CI (Generate + test, SDK Pod Checks) is green. a few minor cleanup items below, nothing blocking.

Comment thread .github/workflows/pr-checks-sdk-python.yml Outdated
Comment thread .github/workflows/pr-checks-sdk-python.yml Outdated
Comment thread packages/sdk-python/README.md Outdated
Comment thread packages/sdk-python/src/qvac/_generated/models/_internal.py
lauripiisang added a commit that referenced this pull request Jul 14, 2026
…, docstring)

- Widen CI's black --check to src/qvac (excluding _generated/models/)
  instead of an explicit file list that omitted api.py/methods.py/
  models.py/schemas.py -- methods.py was already failing black
  unnoticed. Reformat it now that the check actually covers it.
- Fix stale CI comment describing the PoC tests' skip condition
  (they gate on a built worker existing, not just the env var).
- Update README's method-stub examples to their real async signatures;
  note qvac.schemas also exports the constant enums.
- Add an explanatory comment to the empty except in
  QvacWorker.close() (best-effort teardown, timeout non-fatal).
lauripiisang added a commit that referenced this pull request Jul 14, 2026
…, docstring)

- Widen CI's black --check to src/qvac (excluding _generated/models/)
  instead of an explicit file list that omitted api.py/methods.py/
  models.py/schemas.py -- methods.py was already failing black
  unnoticed. Reformat it now that the check actually covers it.
- Fix stale CI comment describing the PoC tests' skip condition
  (they gate on a built worker existing, not just the env var).
- Update README's method-stub examples to their real async signatures;
  note qvac.schemas also exports the constant enums.
- Add an explanatory comment to the empty except in
  QvacWorker.close() (best-effort teardown, timeout non-fatal).
@lauripiisang
lauripiisang force-pushed the QVAC-21805-python-models branch from 7d00ecf to e2578ae Compare July 14, 2026 17:18
opaninakuffo
opaninakuffo previously approved these changes Jul 14, 2026

@arun-mani-j arun-mani-j left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good overall, some minor nits.

Comment thread packages/sdk/schemas/constants-registry.ts Outdated
Comment thread packages/sdk-python/pyproject.toml Outdated
Comment thread packages/sdk-python/pyproject.toml
Comment thread packages/sdk-python/pyproject.toml
…egistry constants from the SDK contract

- scripts/generate.py runs datamodel-code-generator against packages/sdk/contract/{schema.json,manifest.json,models.json}
  to produce pydantic models (_generated/models/**) and one typed stub per RPC method (_generated/methods.py),
  grouped by call shape (request-reply / server-stream / duplex)
- typed constant enums (ModelType, PluginId, ...) generated from the contract's model registry
- public re-export namespaces (qvac.models, qvac.methods, qvac.schemas) over the generated internals
- CI: pr-checks-sdk-python.yml runs generate.py --check to catch drift between the contract and committed output
…PI wrapper layer

- qvac._transport.Transport: asyncio Protocol defining call/call_stream/call_duplex,
  the abstract surface the generated method stubs call through
- qvac.api: hand-written convenience wrappers mirroring the JS SDK's client-side API
  (options split from wire params, e.g. onProgress/logger callbacks)
…ode-mirroring quickstart

- poc_heartbeat.py / poc_transport.py: a Transport implementation over the SDK worker's
  wire protocol, used to smoke-test the generated stubs end-to-end against a real worker
- test_poc_smoke.py / test_poc_progress.py: unary and progress-streaming round trips
  (e.g. a real streamed loadModel) against a spawned worker, using registry:// modelSrc
- python_quickstart.py mirrors the SDK's Node quickstart
… methods

- test_poc_methods.py converts poc_heartbeat.py's manual demo functions
  (embed, transcribe, completion_stream, transcribe_stream,
  text_to_speech_stream) into real pytest assertions against a spawned
  worker, closing part of the test-coverage gap flagged in review.
- Fixes transcribe_stream along the way: the wire format is 16kHz mono
  s16le PCM fed at roughly real-time cadence, not f32 fed as fast as
  possible -- parakeet's streaming session only decodes audio paced
  close to real time (confirmed against transcription-parakeet's own
  duplex-streaming tests) and never emits partials otherwise, which is
  exactly the "one honest gap" this PR's own history already flagged.
…method

Adds real end-to-end tests (against a spawned worker, real models,
real fixtures) for: classify, ocr_stream, translate, rag (ingest +
search + workspace cleanup), diffusion_stream, upscale_stream,
bci_transcribe / bci_transcribe_stream, get_model_info,
get_loaded_model_info, download_asset, and finetune.

- classify needs an empty-string modelSrc to hit the addon's bundled
  weights fallback (`config.modelPath ?? (params.modelPath ||
  undefined)`) -- any non-empty placeholder is treated as a literal
  path and fails to resolve, since the plugin skips normal
  modelSrc resolution (`skipPrimaryModelPathValidation`).
- RagRequest/RagResponse are RootModel unions over `operation`, unlike
  most other methods' flat response shapes -- construct requests via
  the top-level RagRequest wrapper and unwrap `.root` on responses.
- finetune requires an explicit learningRate: omitting it hits a
  native GGML_ASSERT(opt_pars.adamw.alpha > 0.0f) crash with no
  addon-side default. Mirrors the SDK e2e suite's own known-good
  finetune-executor.ts config (tiny fixture dataset, 1 epoch).
- video_stream is deliberately not covered here: its cheapest legal
  config still loads an ~11GB text encoder with no existing e2e
  precedent to validate against; left as a known gap rather than
  guessing at a slow, unverified test.
Rebased onto upstream/main, which had moved forward with unrelated
sdcpp-generation config additions (main-gpu GPU pinning, LTX-2 video
layout support) since this branch forked. CI's pull_request check
tests the merge of this branch with main, not this branch's tip alone,
so the committed _generated tree needs to reflect main's contract
changes too -- regenerated and verified against the merged state.
datamodel-code-generator already runs black internally by default
(--formatters defaults to [black, isort]); the only reason its output
disagreed with a bare `black` invocation was single- vs double-quote
normalization. Pass --use-double-quotes to the generator so its own
black pass matches ours exactly, then drop the black-check workflow's
_generated/models/ exclusion entirely -- one consistent style across
generated and hand-written code, verified with a fresh
--use-double-quotes run diffing byte-for-byte against a plain `black`
pass (only quote characters change, same line count).
The previous fix hand-wrote the name->value record (MP3/M4A/etc), which
review correctly flagged as a second source of truth that could drift
from @/constants/audio's SUPPORTED_AUDIO_FORMATS array. Derive the
record programmatically instead (".mp3" -> "MP3") so there's exactly
one place the format list lives. Verified byte-identical contract
export output and a clean sdk typecheck.
`license = { text = "Apache-2.0" }` is deprecated per current PyPA
guidance; the SPDX string form is now recommended and correctly
emits a `License-Expression` metadata field (verified by building the
wheel and inspecting METADATA).
Rebased onto upstream/main, which picked up "accept non-string JSON
Schema enum values in tool schemas" (#3278) -- widens
CompletionStreamRequest/BatchCompletionStreamRequest's tool-parameter
enum field from list[str] to list[str | float | bool | None].
Regenerated and verified against the merged contract.
- ruff (E/F/I/UP, E501 ignored -- black owns wrapping) and mypy
  (check_untyped_defs, pydantic.mypy plugin) as new dev/gen extras
- generate.py: --use-annotated + --base-class qvac._generated_base
  (fixes conint()/constr() used as bare type annotations, and coerces
  enum-typed fields' JSON-Schema-literal defaults via validate_default)
- generate.py now runs ruff's import-sort fix on datamodel-code-generator's
  output, since --base-class's injected import isn't isort-clean on its own
- py.typed marker so mypy can check the installed package by name
- apply ruff's auto-fixes (import order, typing.AsyncIterable/AsyncIterator
  -> collections.abc) across hand-written src/tests files; noqa the
  intentional `import *` + `__all__` re-export pattern in methods.py/models.py
- CI: add lint (ruff) and typecheck (mypy) steps; document tooling in README
- test_poc_methods.py: narrow RagResponse.root (a 9-member union) with
  isinstance before accessing operation-specific fields -- .processed/
  .results only exist on RagResponseIngest/RagResponseSearch, not the
  other variants
- test_api.py: FakeTransport.call_duplex wasn't a generator (no yield),
  so it structurally returned Coroutine instead of AsyncIterator and
  didn't conform to the Transport protocol it's meant to fake; add a
  None-check before indexing transport.sent
- poc_heartbeat.py: assert self._writer/_reader are set before use in
  _send_frame/_recv (both only ever called after start() connects them);
  cast embedding to its flat shape (request always sends a single string)
- python_quickstart.py: assert model_id is set after the load loop
  (previously could stay None if no terminal reply arrived, then get
  passed straight into unload_model); rename the completion-loop's
  `event` to `delta_event` so it doesn't collide with the load loop's
- test_generate.py: assert spec_from_file_location's result isn't None
  before use; fix a pytest fixture's return annotation (Path -> Iterator[Path])
  to match that it's actually a generator
arun-mani-j
arun-mani-j previously approved these changes Jul 16, 2026
Line wrap left over from the SupportedAudioFormat derive-from-source
change was failing the sdk pod's prettier --check. No logic change.
opaninakuffo
opaninakuffo previously approved these changes Jul 16, 2026
arun-mani-j
arun-mani-j previously approved these changes Jul 16, 2026
…oolDialect

Main merge pulled in QVAC-22274's getLoadedModelInfo contract change
(optional toolDialect enum on LocalLoadedModelInfo); regenerate so the
committed client matches contract/schema.json again.
@lauripiisang
lauripiisang dismissed stale reviews from arun-mani-j and opaninakuffo via 86f09a7 July 16, 2026 08:57
@lauripiisang
lauripiisang merged commit 167e1e7 into main Jul 16, 2026
34 checks passed
@lauripiisang
lauripiisang deleted the QVAC-21805-python-models branch July 16, 2026 12:48
lauripiisang added a commit that referenced this pull request Jul 16, 2026
…gates

Rebasing onto merged #3100 pulls in ruff+mypy CI for sdk-python; the
bare-rpc transport predates it. Bring it into line:

- ruff: typing.AsyncIterable/AsyncIterator/Sequence -> collections.abc,
  drop redundant forward-ref quotes, sort the test import block
- mypy: assert post-connect invariants (rpc/writer set) and add a
  _require_rpc guard so the Transport methods don't deref Optional; add a
  scoped ignore_missing_imports override for the optional, git-only
  bare_rpc/compact_encoding extras
- test: load the Qwen3 completion model with an explicit n_ctx -- it's a
  thinking model, so the worker reserves context for the reasoning trace
  and the metadata-default budget overflowed even a tiny prompt
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants