You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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, and code are secrets. They must never appear in log output, exception messages, or repr(). The transport already avoids interpolating bodies into log lines (it logs only METHOD path); preserve that. LogInRequest must not be logged. Do not add the request body to any logger.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 / KlearAuthrepr must redact credentials (mirror the spirit of KalshiConfig, 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"})
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 no serialization_alias needed. No DollarDecimal / FixedPointCount fields — all three are plain strings.
Do not give it a custom __repr__ that leaks fields; if a redacting repr is added, redact password and code.
LogInResponse (response model)
Spec #/components/schemas/LogInResponse. All fields optional per spec (none listed under required).
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_in with code. 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 the session cookie value once log_in succeeds, and exposes whether it is authenticated. Unlike KalshiAuth it has no RSA key, no sign_request.
The transport must learn to carry cookies. The cleanest, lowest-blast-radius approach (confirm during impl, run gitnexus_impact on SyncTransport.__init__ / AsyncTransport.__init__ first): let the httpx Client/AsyncClient own the cookie jar. The login response's Set-Cookie is auto-captured by httpx when the same client instance is reused, and auto-replayed on subsequent requests. So KlearClient should:
Construct its own httpx.Client(base_url=..., cookies=httpx.Cookies()) (or reuse the existing transport with auth=None so no KALSHI-ACCESS-* headers are ever signed/sent).
Call POST /log_in (unauthenticated — security: []), let httpx capture the session cookie.
If required_mfa_method is set, re-call with code.
Do NOT reuse the RSA-PSS signing path.KlearClient constructs its transport with auth=None so the per-attempt auth.sign_request(...) branch is skipped (self._auth is falsy → auth_headers = {}). The session cookie travels on the httpx client's jar, not via extra_headers. Verify that passing auth=None to SyncTransport/AsyncTransport already yields a no-auth request (it does — see _base_client.py lines 303 / 534).
Retry policy is inherited unchanged: POST /log_in is never retried (POST), and the transport already enforces that. Good — login must not be replayed.
Add the new hosts to a Klear-specific _KNOWN_HOSTS (api.klear.kalshi.com, demo-api.kalshi.co); keep the allow_unknown_host escape 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_headers fields. The extra_headersKALSHI-ACCESS-* rejection from KalshiConfig is 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.
Build LogInRequest(email=..., password=..., code=...), serialize via req.model_dump(exclude_none=True, by_alias=True, mode="json"), POST to /log_in.
Parse LogInResponse. On success the httpx client jar now holds the session cookie automatically; update KlearAuth so is_authenticated flips true.
Return the LogInResponse so the caller can inspect required_mfa_method and re-call with code. (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) and METHOD_ENDPOINT_MAP is parametrized against that single spec. Perps uses its own spec. The foundation issue must (a) commit /tmp/perps_scm_openapi.yaml to specs/perps_scm_openapi.yaml (and /tmp/perps_openapi.yaml → specs/perps_openapi.yaml), and (b) parameterize the drift tests to resolve each MethodEndpointEntry against the correct spec file (e.g. add a spec_file discriminator 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:
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_in happy path (no MFA): mock POST /log_in → 200 with {token, user_id, access_level} and a Set-Cookie: session=... header. Assert LogInResponse parses, required_mfa_method is None, and the session cookie lands in the client's httpx jar (assert it is replayed on a subsequent dummy request).
log_in MFA challenge then success: first mock returns 200 with required_mfa_method set and no token; assert the SDK returns it without raising and does NOT auto-retry. Second call with code=... returns 200 with token + Set-Cookie; assert success and cookie capture.
Error path — 401: mock POST /log_in → 401 with Error body ({code, message}); assert it maps to KalshiAuthError and that the body (email/password) is not present in the exception message.
Error path — 429: mock → 429; assert KalshiRateLimitError, and assert POST is not retried (respx route called exactly once).
Edge — request body shape: assert the serialized body omits code when None (exclude_none=True) and includes it when provided; assert LogInRequest rejects an unknown field (extra="forbid" → ValidationError).
Security — no credential leak: capture log records during log_in and assert password / code / the cookie value never appear in any emitted log line or in repr(config) / repr(client).
Async parity: mirror happy-path + 401 for AsyncKlearClient.auth.log_in.
Config:KlearConfig rejects a base_url lacking /klear-api/v1; .production() / .demo() resolve the right hosts; unknown host rejected unless allow_unknown_host=True.
kalshi/perps/klear/models/auth.py with LogInRequest (extra="forbid") and LogInResponse; Klear Error model present (here or from foundation).
kalshi/perps/klear/resources/auth.py with sync AuthResource.log_in + async AsyncAuthResource.log_in; MFA is returned to the caller, not auto-looped; POST /log_in not retried.
Session cookie (session) captured from Set-Cookie and auto-replayed on subsequent requests via the httpx jar; RSA-PSS signing path is NOT used (auth=None on the transport).
KlearClient, AsyncKlearClient, KlearConfig, KlearAuth, LogInRequest, LogInResponse exported from kalshi/perps/__init__.py and re-exported from kalshi/__init__.py.
No credential leakage: password/code/cookie never logged or in repr; verified by a test.
uv run mypy kalshi/ strict clean (builtins.list[T] inside resource classes).
uv run ruff check . clean.
uv run pytest tests/ -v green, including TestRequestParamDrift, TestRequestBodyDrift, and the new tests/perps/klear/test_auth.py.
Dependencies
perps: foundation — must vendor specs/perps_scm_openapi.yaml (+ specs/perps_openapi.yaml), scaffold the kalshi/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-coded specs/openapi.yaml.
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) viaPOST /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 standaloneKlearClient/AsyncKlearClient, a dedicatedKlearConfig, and a new cookie-session auth strategy wired into the transport — plus the singleLogInoperation. 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
breakingonly because it adds a new top-level public surface (KlearClient,AsyncKlearClient,KlearConfig,KlearAuth) and a newkalshi/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.repr().KlearConfig/KlearAuthreprmust redact credentials (mirror the spirit ofKalshiConfig, which never holds the private key in a logged field).Endpoints / scope
/log_inLogInsecurity: [](unauthenticated — bootstraps the session). BodyLogInRequest. ResponseLogInResponse+Set-Cookieheader. Two-phase: first call with email+password; ifrequired_mfa_methodis non-null in the response, re-call with the same credentials pluscode.Base URLs (own host, NOT trade-api): prod
https://api.klear.kalshi.com/klear-api/v1, demohttps://demo-api.kalshi.co/klear-api/v1. Path component is/klear-api/v1(note: not/trade-api/v2—KlearConfigmust 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 namedsession.Models
New file:
kalshi/perps/klear/models/auth.pyLogInRequest(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.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.__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 underrequired).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.Error model (shared Klear error — confirm whether foundation issue already added it)
Spec
#/components/schemas/Error(code,message,details?,service?). The existing_map_errorinkalshi/_base_client.pyalready readsmessage/details, so the standard exception hierarchy (KalshiAuthErroron 401,KalshiRateLimitErroron 429) maps cleanly without a new exception type. Do not introduce new exception classes — reusekalshi.errors. If the foundation issue hasn't already added a KlearErrorPydantic model, add it askalshi/perps/klear/models/common.py::Error(fieldscode: 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.pyThis is the core of the issue. The existing
SyncTransport/AsyncTransport(kalshi/_base_client.py) inject auth by callingauth.sign_request(method, sign_path)and merging the returnedKALSHI-ACCESS-*headers per attempt. Cookie-session auth does NOT produce per-request headers; it relies on httpx's cookie jar. Design: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.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:httpx.Client(base_url=..., cookies=httpx.Cookies())(or reuse the existing transport withauth=Noneso noKALSHI-ACCESS-*headers are ever signed/sent).POST /log_in(unauthenticated —security: []), let httpx capture thesessioncookie.required_mfa_methodis set, re-call withcode.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).POST /log_inis never retried (POST), and the transport already enforces that. Good — login must not be replayed.KlearConfig—kalshi/perps/klear/config.pyMirror
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"._KNOWN_HOSTS(api.klear.kalshi.com,demo-api.kalshi.co); keep theallow_unknown_hostescape hatch./klear-api/v1(NOT/trade-api/v2— copy the__post_init__path check and change the expected component).ws_base_url(Klear has no WS surface) — drop the WS fields and the split-environment check.extra_headersfields. Theextra_headersKALSHI-ACCESS-*rejection fromKalshiConfigis harmless to keep but irrelevant here..production()/.demo()classmethods.KlearClient/AsyncKlearClient—kalshi/perps/klear/client.pyandasync_client.pyStandalone classes (NOT a namespace on
KalshiClientand NOT onPerpsClient). Public surface mirrorsKalshiClient's lifecycle (__init__,close, context-manager,is_authenticated). Hold the transport + aKlearAuthsession holder.Resource methods
New file:
kalshi/perps/klear/resources/auth.py(or foldlog_indirectly onto the client — pick the one that matchesPerpsClientfrom the foundation issue; prefer a resource for contract-map symmetry).Inside the resource class, annotate any list returns with
builtins.list[T](thelistbuiltin is shadowed). No pagination here (/log_inreturns a single object, no cursor) so nolist_all()/AsyncIterator.Sync (
AuthResource(SyncResource)):Async (
AsyncAuthResource(AsyncResource)):Behaviour:
LogInRequest(email=..., password=..., code=...), serialize viareq.model_dump(exclude_none=True, by_alias=True, mode="json"), POST to/log_in.LogInResponse. On success the httpx client jar now holds thesessioncookie automatically; updateKlearAuthsois_authenticatedflips true.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 toauth.log_inand stores session state.Contract-test wiring
Add to
METHOD_ENDPOINT_MAP(tests/_contract_support.py), tagged to the Klear SCM spec:(The async sibling
AsyncAuthResource.log_inis derived automatically — do NOT add it.)Add to
BODY_MODEL_MAP(tests/test_contracts.py):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 flagscodebecause the SDK marks it optional while leaving it in the signature, that is correct (spec lists onlyemail/passwordas required) and needs no exclusion. Add anExclusion(reason=..., kind="...")only if a real drift surfaces during impl, with a concrete reason string.Tests
New file
tests/perps/klear/test_auth.py, usingrespx.mock. Reuseconftest.pyfixtures 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.POST /log_in→ 401 withErrorbody ({code, message}); assert it maps toKalshiAuthErrorand that the body (email/password) is not present in the exception message.KalshiRateLimitError, and assertPOSTis not retried (respx route called exactly once).codewhenNone(exclude_none=True) and includes it when provided; assertLogInRequestrejects an unknown field (extra="forbid"→ValidationError).log_inand assertpassword/code/ the cookie value never appear in any emitted log line or inrepr(config)/repr(client).AsyncKlearClient.auth.log_in.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) 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.password/code/cookie never logged or inrepr; verified by a test.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
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.