Repository navigation
Description
Summary
The Self-Clearing-Member "Klear" API (/tmp/perps_scm_openapi.yaml, title Kalshi Self-Clearing Member API) uses a completely different auth model from the rest of the SDK: instead of per-request RSA-PSS signing, it authenticates with email + password (+ optional MFA code) via POST /log_in, which sets a session cookie (Set-Cookie) that must be replayed on every subsequent request. This issue builds the auth foundation only: a standalone KlearClient / AsyncKlearClient, a dedicated KlearConfig, and a new cookie-session auth strategy wired into the transport — plus the single LogIn operation. The Klear data endpoints (/margin/reports, /margin/active_obligation, settlement balance, etc.) are out of scope and ship in a follow-up issue.
This is the third distinct base host + auth surface in the SDK (RSA-PSS for trade-api, and now cookie-session for klear-api). It is labelled breaking only because it adds a new top-level public surface (KlearClient, AsyncKlearClient, KlearConfig, KlearAuth) and a new kalshi/perps/klear/ package; no existing behaviour changes.
Security consideration (call out in review)
email,password, andcodeare secrets. They must never appear in log output, exception messages, orrepr(). The transport already avoids interpolating bodies into log lines (it logs onlyMETHOD path); preserve that.LogInRequestmust not be logged. Do not add the request body to anylogger.debug(...)call.- The session cookie is a bearer credential. Store it in the httpx cookie jar; do not echo it into logs or
repr(). KlearConfig/KlearAuthreprmust redact credentials (mirror the spirit ofKalshiConfig, which never holds the private key in a logged field).
Endpoints / scope
| Method | Path | operationId | Notes |
|---|---|---|---|
| POST | /log_in |
LogIn |
security: [] (unauthenticated — bootstraps the session). Body LogInRequest. Response LogInResponse + Set-Cookie header. Two-phase: first call with email+password; if required_mfa_method is non-null in the response, re-call with the same credentials plus code. |
Base URLs (own host, NOT trade-api): prod https://api.klear.kalshi.com/klear-api/v1, demo https://demo-api.kalshi.co/klear-api/v1. Path component is /klear-api/v1 (note: not /trade-api/v2 — KlearConfig must validate against its own path, see below).
Security scheme from spec (components.securitySchemes.kalshiSession): type: apiKey, in: cookie, name: session. So the session cookie is literally named session.
Models
New file: kalshi/perps/klear/models/auth.py
LogInRequest (request model — model_config = {"extra": "forbid"})
Spec #/components/schemas/LogInRequest. Required: email, password. Optional: code.
email: str— required.password: str— required.code: str | None = None— MFA code; only sent on the second (challenge-response) call.- Serialize via
model.model_dump(exclude_none=True, by_alias=True, mode="json"). No alias mismatches here (all three wire names match Python names), so noserialization_aliasneeded. NoDollarDecimal/FixedPointCountfields — all three are plain strings. - Do not give it a custom
__repr__that leaks fields; if a redacting repr is added, redactpasswordandcode.
LogInResponse (response model)
Spec #/components/schemas/LogInResponse. All fields optional per spec (none listed under required).
token: str | None = None— session token, present on successful auth.user_id: str | None = None— admin user ID, present on successful auth.access_level: str | None = None.required_mfa_method: str | None = None— when set, MFA is required; caller must re-call/log_inwithcode. This is the signal the client branches on.- No prices/counts/timestamps. No aliases. (Field names map 1:1 to the spec.)
Error model (shared Klear error — confirm whether foundation issue already added it)
Spec #/components/schemas/Error (code, message, details?, service?). The existing _map_error in kalshi/_base_client.py already reads message/details, so the standard exception hierarchy (KalshiAuthError on 401, KalshiRateLimitError on 429) maps cleanly without a new exception type. Do not introduce new exception classes — reuse kalshi.errors. If the foundation issue hasn't already added a Klear Error Pydantic model, add it as kalshi/perps/klear/models/common.py::Error (fields code: str, message: str, details: str | None, service: str | None); otherwise reuse it.
No enums in this issue's owned schemas.
Auth strategy (new) — kalshi/perps/klear/auth.py
This is the core of the issue. The existing SyncTransport / AsyncTransport (kalshi/_base_client.py) inject auth by calling auth.sign_request(method, sign_path) and merging the returned KALSHI-ACCESS-* headers per attempt. Cookie-session auth does NOT produce per-request headers; it relies on httpx's cookie jar. Design:
- New class
KlearAuth(sync) / shared with async — a lightweight session holder, not a signer. It holds thesessioncookie value oncelog_insucceeds, and exposes whether it is authenticated. UnlikeKalshiAuthit has no RSA key, nosign_request. - The transport must learn to carry cookies. The cleanest, lowest-blast-radius approach (confirm during impl, run
gitnexus_impactonSyncTransport.__init__/AsyncTransport.__init__first): let the httpxClient/AsyncClientown the cookie jar. The login response'sSet-Cookieis auto-captured by httpx when the same client instance is reused, and auto-replayed on subsequent requests. SoKlearClientshould:- Construct its own
httpx.Client(base_url=..., cookies=httpx.Cookies())(or reuse the existing transport withauth=Noneso noKALSHI-ACCESS-*headers are ever signed/sent). - Call
POST /log_in(unauthenticated —security: []), let httpx capture thesessioncookie. - If
required_mfa_methodis set, re-call withcode.
- Construct its own
- Do NOT reuse the RSA-PSS signing path.
KlearClientconstructs its transport withauth=Noneso the per-attemptauth.sign_request(...)branch is skipped (self._authis falsy →auth_headers = {}). The session cookie travels on the httpx client's jar, not viaextra_headers. Verify that passingauth=NonetoSyncTransport/AsyncTransportalready yields a no-auth request (it does — see_base_client.pylines 303 / 534). - Retry policy is inherited unchanged:
POST /log_inis never retried (POST), and the transport already enforces that. Good — login must not be replayed.
KlearConfig — kalshi/perps/klear/config.py
Mirror kalshi/config.py::KalshiConfig (frozen dataclass) but:
PRODUCTION_KLEAR_URL = "https://api.klear.kalshi.com/klear-api/v1",DEMO_KLEAR_URL = "https://demo-api.kalshi.co/klear-api/v1".- Add the new hosts to a Klear-specific
_KNOWN_HOSTS(api.klear.kalshi.com,demo-api.kalshi.co); keep theallow_unknown_hostescape hatch. - Path validation must enforce
/klear-api/v1(NOT/trade-api/v2— copy the__post_init__path check and change the expected component). - No
ws_base_url(Klear has no WS surface) — drop the WS fields and the split-environment check. - Keep timeout / retry /
extra_headersfields. Theextra_headersKALSHI-ACCESS-*rejection fromKalshiConfigis harmless to keep but irrelevant here. .production()/.demo()classmethods.
KlearClient / AsyncKlearClient — kalshi/perps/klear/client.py and async_client.py
Standalone classes (NOT a namespace on KalshiClient and NOT on PerpsClient). Public surface mirrors KalshiClient's lifecycle (__init__, close, context-manager, is_authenticated). Hold the transport + a KlearAuth session holder.
Resource methods
New file: kalshi/perps/klear/resources/auth.py (or fold log_in directly onto the client — pick the one that matches PerpsClient from the foundation issue; prefer a resource for contract-map symmetry).
Inside the resource class, annotate any list returns with builtins.list[T] (the list builtin is shadowed). No pagination here (/log_in returns a single object, no cursor) so no list_all() / AsyncIterator.
Sync (AuthResource(SyncResource)):
def log_in(self, *, email: str, password: str, code: str | None = None) -> LogInResponse: ...
Async (AsyncAuthResource(AsyncResource)):
async def log_in(self, *, email: str, password: str, code: str | None = None) -> LogInResponse: ...
Behaviour:
- Build
LogInRequest(email=..., password=..., code=...), serialize viareq.model_dump(exclude_none=True, by_alias=True, mode="json"), POST to/log_in. - Parse
LogInResponse. On success the httpx client jar now holds thesessioncookie automatically; updateKlearAuthsois_authenticatedflips true. - Return the
LogInResponseso the caller can inspectrequired_mfa_methodand re-call withcode. (Do NOT auto-loop on MFA — the SDK can't conjure the OOB code; return it and let the caller drive the second call.)
Convenience (optional, only if trivial — otherwise drop per Simplicity First): a KlearClient.login(email, password, code=None) that delegates to auth.log_in and stores session state.
Contract-test wiring
NOTE — harness change owned by the foundation dependency: the contract harness currently hard-codes
SPEC_FILE = specs/openapi.yaml(tests/test_contracts.py:54) andMETHOD_ENDPOINT_MAPis parametrized against that single spec. Perps uses its own spec. The foundation issue must (a) commit/tmp/perps_scm_openapi.yamltospecs/perps_scm_openapi.yaml(and/tmp/perps_openapi.yaml→specs/perps_openapi.yaml), and (b) parameterize the drift tests to resolve eachMethodEndpointEntryagainst the correct spec file (e.g. add aspec_filediscriminator or a second map). This issue assumes that infrastructure exists; if it does not yet, it is blocked on foundation.
Add to METHOD_ENDPOINT_MAP (tests/_contract_support.py), tagged to the Klear SCM spec:
MethodEndpointEntry(
sdk_method="kalshi.perps.klear.resources.auth.AuthResource.log_in",
http_method="POST",
path_template="/log_in",
request_body_schema="#/components/schemas/LogInRequest",
)
(The async sibling AsyncAuthResource.log_in is derived automatically — do NOT add it.)
Add to BODY_MODEL_MAP (tests/test_contracts.py):
"#/components/schemas/LogInRequest": "kalshi.perps.klear.models.auth.LogInRequest",
EXCLUSIONS (tests/_contract_support.py): none expected — LogInRequest's three fields (email, password, code) map 1:1 to the spec with no rename/wire-normalization. If the body-drift test flags code because the SDK marks it optional while leaving it in the signature, that is correct (spec lists only email/password as required) and needs no exclusion. Add an Exclusion(reason=..., kind="...") only if a real drift surfaces during impl, with a concrete reason string.
Tests
New file tests/perps/klear/test_auth.py, using respx.mock. Reuse conftest.py fixtures where applicable (note: the RSA-key fixtures are NOT needed here — Klear has no signing — but the config/base-URL helpers are). Cover per public method:
log_inhappy path (no MFA): mockPOST /log_in→ 200 with{token, user_id, access_level}and aSet-Cookie: session=...header. AssertLogInResponseparses,required_mfa_method is None, and thesessioncookie lands in the client's httpx jar (assert it is replayed on a subsequent dummy request).log_inMFA challenge then success: first mock returns 200 withrequired_mfa_methodset and no token; assert the SDK returns it without raising and does NOT auto-retry. Second call withcode=...returns 200 withtoken+Set-Cookie; assert success and cookie capture.- Error path — 401: mock
POST /log_in→ 401 withErrorbody ({code, message}); assert it maps toKalshiAuthErrorand that the body (email/password) is not present in the exception message. - Error path — 429: mock → 429; assert
KalshiRateLimitError, and assertPOSTis not retried (respx route called exactly once). - Edge — request body shape: assert the serialized body omits
codewhenNone(exclude_none=True) and includes it when provided; assertLogInRequestrejects an unknown field (extra="forbid"→ValidationError). - Security — no credential leak: capture log records during
log_inand assertpassword/code/ the cookie value never appear in any emitted log line or inrepr(config)/repr(client). - Async parity: mirror happy-path + 401 for
AsyncKlearClient.auth.log_in. - Config:
KlearConfigrejects abase_urllacking/klear-api/v1;.production()/.demo()resolve the right hosts; unknown host rejected unlessallow_unknown_host=True.
Acceptance criteria
-
kalshi/perps/klear/config.py(KlearConfig),kalshi/perps/klear/auth.py(KlearAuthsession holder),kalshi/perps/klear/client.py(KlearClient),async_client.py(AsyncKlearClient) implemented. -
kalshi/perps/klear/models/auth.pywithLogInRequest(extra="forbid") andLogInResponse; KlearErrormodel present (here or from foundation). -
kalshi/perps/klear/resources/auth.pywith syncAuthResource.log_in+ asyncAsyncAuthResource.log_in; MFA is returned to the caller, not auto-looped;POST /log_innot retried. - Session cookie (
session) captured fromSet-Cookieand auto-replayed on subsequent requests via the httpx jar; RSA-PSS signing path is NOT used (auth=Noneon the transport). -
KlearClient,AsyncKlearClient,KlearConfig,KlearAuth,LogInRequest,LogInResponseexported fromkalshi/perps/__init__.pyand re-exported fromkalshi/__init__.py. - No credential leakage:
password/code/cookie never logged or inrepr; verified by a test. - Contract map updated:
METHOD_ENDPOINT_MAPentry (withrequest_body_schema) +BODY_MODEL_MAPentry added; resolved againstspecs/perps_scm_openapi.yaml; drift tests green. -
uv run mypy kalshi/strict clean (builtins.list[T]inside resource classes). -
uv run ruff check .clean. -
uv run pytest tests/ -vgreen, includingTestRequestParamDrift,TestRequestBodyDrift, and the newtests/perps/klear/test_auth.py.
Dependencies
- perps: foundation — must vendor
specs/perps_scm_openapi.yaml(+specs/perps_openapi.yaml), scaffold thekalshi/perps/package + public-export plumbing, and parameterize the contract-test harness (SPEC_FILE/METHOD_ENDPOINT_MAP) to load the per-area perps spec instead of the single hard-codedspecs/openapi.yaml.
Activity