Context
Surfaced by the multi-LLM review of PR #403 (perps/margin API). List endpoints across the SDK extract their array with data.get(key) or [] and then [Model.model_validate(x) for x in raw], never validating the response envelope. When the spec marks the array REQUIRED, a missing/null array is silently coerced to [], masking server-side spec drift instead of surfacing it. This is an SDK-wide convention (the prediction API does the same), so the fix should be repo-wide to avoid divergence — not perps-only.
Problem
The array is pulled straight off the raw dict with no typed-envelope validation, and or [] swallows a missing/null REQUIRED key:
Perps (non-paginated list endpoints — all return a plain builtins.list[...]):
kalshi/perps/resources/markets.py:70-71 and :137-138 — raw = data.get("markets") or []. Spec GetMarginMarketsResponse.markets is REQUIRED (specs/perps_openapi.yaml:2062-2070).
kalshi/perps/resources/markets.py:117-118 and :186-187 — raw = data.get("candlesticks") or []. Spec GetMarginMarketCandlesticksResponse.candlesticks is REQUIRED (specs/perps_openapi.yaml:2522-2535). Note this response also requires a top-level ticker field, which the resource discards entirely.
kalshi/perps/resources/funding.py:80, :99-101, :129, :150-152 — data.get("funding_rates", []) / data.get("funding_history", []). Spec GetMarginHistoricalFundingRatesResponse.funding_rates and GetMarginFundingHistoryResponse.funding_history are both REQUIRED (specs/perps_openapi.yaml:2489-2498 and :2457-2466).
kalshi/perps/resources/order_groups.py:86-87 and :239-240 — raw = data.get("order_groups", []). Spec GetOrderGroupsResponse.order_groups is OPTIONAL (no required block — specs/perps_openapi.yaml:1497-1504), so this one must stay tolerant.
Prediction API (same pattern, for parity):
kalshi/resources/markets.py:340-341 / :593-594 (candlesticks — data.get("candlesticks", [])), :445-446 / :699-700 (bulk_candlesticks — data.get("markets", [])), :460-461 / :714-715 (bulk_orderbooks — data.get("orderbooks", [])).
- The shared paginator helper
kalshi/resources/_base.py:370 and :613 does the same: raw_items = data.get(items_key) or [] (the comment there even acknowledges it: # .get(key, []) misses explicit null; or [] coerces both.).
Concrete failure: if Kalshi ships a contract change that renames markets → data (or returns null for a required array), every one of these methods silently returns []. The caller sees "no markets" rather than an error, and the contract-drift tests (which only diff request shapes + model fields, not live extraction) won't catch it. There is no envelope model to anchor a typed Model.model_validate(data) that would raise on the missing required key.
Proposed fix
Minimal, repo-wide-consistent:
- Add typed list-response envelope models (
model_config = {"extra": "allow"}, matching the response convention) for the REQUIRED-array endpoints, with the array field non-optional so Pydantic raises on a missing/null key:
- perps:
GetMarginMarketsResponse{markets: list[MarginMarket]}, GetMarginMarketCandlesticksResponse{ticker: str, candlesticks: list[MarginMarketCandlestick]}, GetMarginHistoricalFundingRatesResponse{funding_rates: list[MarginFundingRate]}, GetMarginFundingHistoryResponse{funding_history: list[MarginFundingHistoryEntry]} in the matching kalshi/perps/models/*.py.
- prediction: an envelope for the
candlesticks / bulk-candlesticks / bulk-orderbooks arrays (or reuse existing models where one already fits).
- In each resource method, replace
raw = data.get(key) or [] with envelope = Envelope.model_validate(data); raw = envelope.<field> for REQUIRED arrays. The bulk-orderbook path keeps its existing per-item _orderbook_from_item transform but iterates the validated list.
- Leave OPTIONAL arrays tolerant:
GetOrderGroupsResponse.order_groups stays data.get("order_groups") or [] (or an envelope with an Optional field defaulting to []).
- Register the new envelope models in
_contract_map.py / BODY_MODEL_MAP as appropriate so the drift harness covers them.
Scope decision worth confirming on the issue: whether to also route the Page-based _list helper (_base.py:370/:613) through envelope validation, or leave the shared paginator as-is and only harden the non-paginated list methods. The paginated path is lower-risk (a Page with items=[] is a legitimate empty page), so the conservative cut is non-paginated REQUIRED-array methods first.
Acceptance criteria
Notes
Deferred from the PR #403 review because the headline perps fixes (f8cc960 ws, da953b0 klear, f2ef973 rest/models) shipped first; f2ef973 hardened GetMarginOrdersResponse.cursor and order-size guards but did not touch the list-envelope extraction in markets/funding/order_groups (verified against current HEAD). Tradeoff: silently masking drift is convenient (callers never crash on an empty list) but defeats the SDK's spec-conformance posture — the whole point of the extra="forbid" request side and the drift harness. Keep the OPTIONAL order_groups path tolerant; only REQUIRED arrays should hard-fail. Part of the perps EPIC #387. "Needs verification" caveat: whether to harden the shared _base.py paginator is a design call for the issue thread (this draft recommends leaving it for a follow-up).
Context
Surfaced by the multi-LLM review of PR #403 (perps/margin API). List endpoints across the SDK extract their array with
data.get(key) or []and then[Model.model_validate(x) for x in raw], never validating the response envelope. When the spec marks the array REQUIRED, a missing/null array is silently coerced to[], masking server-side spec drift instead of surfacing it. This is an SDK-wide convention (the prediction API does the same), so the fix should be repo-wide to avoid divergence — not perps-only.Problem
The array is pulled straight off the raw dict with no typed-envelope validation, and
or []swallows a missing/null REQUIRED key:Perps (non-paginated list endpoints — all return a plain
builtins.list[...]):kalshi/perps/resources/markets.py:70-71and:137-138—raw = data.get("markets") or []. SpecGetMarginMarketsResponse.marketsis REQUIRED (specs/perps_openapi.yaml:2062-2070).kalshi/perps/resources/markets.py:117-118and:186-187—raw = data.get("candlesticks") or []. SpecGetMarginMarketCandlesticksResponse.candlesticksis REQUIRED (specs/perps_openapi.yaml:2522-2535). Note this response also requires a top-leveltickerfield, which the resource discards entirely.kalshi/perps/resources/funding.py:80,:99-101,:129,:150-152—data.get("funding_rates", [])/data.get("funding_history", []). SpecGetMarginHistoricalFundingRatesResponse.funding_ratesandGetMarginFundingHistoryResponse.funding_historyare both REQUIRED (specs/perps_openapi.yaml:2489-2498and:2457-2466).kalshi/perps/resources/order_groups.py:86-87and:239-240—raw = data.get("order_groups", []). SpecGetOrderGroupsResponse.order_groupsis OPTIONAL (norequiredblock —specs/perps_openapi.yaml:1497-1504), so this one must stay tolerant.Prediction API (same pattern, for parity):
kalshi/resources/markets.py:340-341/:593-594(candlesticks—data.get("candlesticks", [])),:445-446/:699-700(bulk_candlesticks—data.get("markets", [])),:460-461/:714-715(bulk_orderbooks—data.get("orderbooks", [])).kalshi/resources/_base.py:370and:613does the same:raw_items = data.get(items_key) or [](the comment there even acknowledges it:# .get(key, []) misses explicit null; or [] coerces both.).Concrete failure: if Kalshi ships a contract change that renames
markets→data(or returnsnullfor a required array), every one of these methods silently returns[]. The caller sees "no markets" rather than an error, and the contract-drift tests (which only diff request shapes + model fields, not live extraction) won't catch it. There is no envelope model to anchor a typedModel.model_validate(data)that would raise on the missing required key.Proposed fix
Minimal, repo-wide-consistent:
model_config = {"extra": "allow"}, matching the response convention) for the REQUIRED-array endpoints, with the array field non-optional so Pydantic raises on a missing/null key:GetMarginMarketsResponse{markets: list[MarginMarket]},GetMarginMarketCandlesticksResponse{ticker: str, candlesticks: list[MarginMarketCandlestick]},GetMarginHistoricalFundingRatesResponse{funding_rates: list[MarginFundingRate]},GetMarginFundingHistoryResponse{funding_history: list[MarginFundingHistoryEntry]}in the matchingkalshi/perps/models/*.py.candlesticks/ bulk-candlesticks / bulk-orderbooks arrays (or reuse existing models where one already fits).raw = data.get(key) or []withenvelope = Envelope.model_validate(data); raw = envelope.<field>for REQUIRED arrays. The bulk-orderbook path keeps its existing per-item_orderbook_from_itemtransform but iterates the validated list.GetOrderGroupsResponse.order_groupsstaysdata.get("order_groups") or [](or an envelope with anOptionalfield defaulting to[])._contract_map.py/BODY_MODEL_MAPas appropriate so the drift harness covers them.Scope decision worth confirming on the issue: whether to also route the
Page-based_listhelper (_base.py:370/:613) through envelope validation, or leave the shared paginator as-is and only harden the non-paginated list methods. The paginated path is lower-risk (aPagewithitems=[]is a legitimate empty page), so the conservative cut is non-paginated REQUIRED-array methods first.Acceptance criteria
perps/resources/markets.py(list,candlesticks),perps/resources/funding.py(historical_rates,history) validate via the envelope instead ofdata.get(key) or [];MarginMarketCandlestickcallers preserve the requiredticker.markets.pycandlesticks/bulk_candlesticks/bulk_orderbooksvalidate via an envelope for their REQUIRED arrays.order_groups.pylist(sync + async) remains tolerant of a missing/nullorder_groups(OPTIONAL) — covered by a regression test.pydantic.ValidationError(or a wrappedKalshiError) when the array key is absent ornull; the OPTIONAL order-groups method returns[]for the same input.Notes
Deferred from the PR #403 review because the headline perps fixes (f8cc960 ws, da953b0 klear, f2ef973 rest/models) shipped first; f2ef973 hardened
GetMarginOrdersResponse.cursorand order-size guards but did not touch the list-envelope extraction in markets/funding/order_groups (verified against currentHEAD). Tradeoff: silently masking drift is convenient (callers never crash on an empty list) but defeats the SDK's spec-conformance posture — the whole point of theextra="forbid"request side and the drift harness. Keep the OPTIONALorder_groupspath tolerant; only REQUIRED arrays should hard-fail. Part of the perps EPIC #387. "Needs verification" caveat: whether to harden the shared_base.pypaginator is a design call for the issue thread (this draft recommends leaving it for a follow-up).