kranth 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
kranth/__init__.py ADDED
@@ -0,0 +1,98 @@
1
+ """Kranth — stress-test ideas with hundreds of AI personas.
2
+
3
+ The whole surface is two clients:
4
+
5
+ >>> from kranth import Kranth
6
+ >>> client = Kranth(api_key="kr_live_…")
7
+ >>> sim = client.sims.create(
8
+ ... idea_text="Launch a paid Rust newsletter.",
9
+ ... persona_count=50,
10
+ ... model_id="claude-sonnet-4-6",
11
+ ... )
12
+ >>> for event in client.sims.stream(sim.sim_id):
13
+ ... print(event.kind, event.data)
14
+
15
+ >>> from kranth import AsyncKranth
16
+ >>> async with AsyncKranth(api_key="kr_live_…") as client:
17
+ ... models = await client.models.list()
18
+ """
19
+
20
+ from kranth._async import AsyncKranth
21
+ from kranth._client import Kranth
22
+ from kranth._errors import (
23
+ KranthAPIError,
24
+ KranthAuthError,
25
+ KranthError,
26
+ KranthPaymentRequired,
27
+ KranthRateLimited,
28
+ KranthValidationError,
29
+ )
30
+ from kranth._models import (
31
+ ApiKey,
32
+ AudienceReaction,
33
+ CreateApiKeyResponse,
34
+ CreateReconResponse,
35
+ CreateSimResponse,
36
+ Debate,
37
+ DebateDetail,
38
+ DebateEvent,
39
+ DebateSynthesis,
40
+ DebateTurn,
41
+ ListDebatesResponse,
42
+ ListSimsResponse,
43
+ Me,
44
+ ModelInfo,
45
+ ReconDetail,
46
+ ReconEvent,
47
+ ReconFinding,
48
+ ReconRun,
49
+ ReconSource,
50
+ ReconSummary,
51
+ ReconTier,
52
+ SimEvent,
53
+ SimSummary,
54
+ SpeakerPreview,
55
+ Usage,
56
+ VerdictReport,
57
+ )
58
+
59
+ __all__ = [
60
+ "Kranth",
61
+ "AsyncKranth",
62
+ "KranthError",
63
+ "KranthAPIError",
64
+ "KranthAuthError",
65
+ "KranthValidationError",
66
+ "KranthPaymentRequired",
67
+ "KranthRateLimited",
68
+ "ApiKey",
69
+ "CreateApiKeyResponse",
70
+ "CreateSimResponse",
71
+ "ListSimsResponse",
72
+ "Me",
73
+ "ModelInfo",
74
+ "SimEvent",
75
+ "SimSummary",
76
+ "Usage",
77
+ "VerdictReport",
78
+ # Recon
79
+ "CreateReconResponse",
80
+ "ReconSummary",
81
+ "ReconDetail",
82
+ "ReconRun",
83
+ "ReconFinding",
84
+ "ReconSource",
85
+ "ReconTier",
86
+ "ReconEvent",
87
+ # Debate
88
+ "Debate",
89
+ "DebateDetail",
90
+ "DebateTurn",
91
+ "DebateSynthesis",
92
+ "AudienceReaction",
93
+ "SpeakerPreview",
94
+ "ListDebatesResponse",
95
+ "DebateEvent",
96
+ ]
97
+
98
+ __version__ = "0.2.0"
kranth/_async.py ADDED
@@ -0,0 +1,416 @@
1
+ """Async client.
2
+
3
+ >>> async with AsyncKranth(api_key="kr_live_…") as client:
4
+ ... models = await client.models.list()
5
+ ... sim = await client.sims.create(idea_text="…", persona_count=50, model_id="claude-sonnet-4-6")
6
+ ... async for ev in client.sims.stream(sim.sim_id):
7
+ ... ...
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from typing import Any, AsyncIterator
14
+
15
+ import httpx
16
+
17
+ from kranth._models import (
18
+ ApiKey,
19
+ AudienceReaction,
20
+ CreateApiKeyResponse,
21
+ CreateReconResponse,
22
+ CreateSimResponse,
23
+ Debate,
24
+ DebateDetail,
25
+ DebateEvent,
26
+ DebateSynthesis,
27
+ DebateTurn,
28
+ ListDebatesResponse,
29
+ ListSimsResponse,
30
+ Me,
31
+ ModelInfo,
32
+ ReconDetail,
33
+ ReconEvent,
34
+ ReconFinding,
35
+ ReconSource,
36
+ ReconSummary,
37
+ ReconRun,
38
+ ReconTier,
39
+ SimEvent,
40
+ SimSummary,
41
+ SpeakerPreview,
42
+ Usage,
43
+ )
44
+ from kranth._transport import DEFAULT_BASE_URL, build_headers, raise_for_status
45
+
46
+
47
+ class AsyncKranth:
48
+ def __init__(
49
+ self,
50
+ api_key: str,
51
+ *,
52
+ base_url: str | None = None,
53
+ timeout: float = 30.0,
54
+ client: httpx.AsyncClient | None = None,
55
+ ):
56
+ if not api_key:
57
+ raise ValueError("api_key is required")
58
+ self.api_key = api_key
59
+ self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
60
+ self._owns_client = client is None
61
+ self._http = client or httpx.AsyncClient(timeout=timeout)
62
+ self.sims = _AsyncSims(self)
63
+ self.recon = _AsyncRecon(self)
64
+ self.debates = _AsyncDebates(self)
65
+ self.api_keys = _AsyncApiKeys(self)
66
+ self.models = _AsyncModels(self)
67
+ self.billing = _AsyncBilling(self)
68
+
69
+ async def __aenter__(self) -> "AsyncKranth":
70
+ return self
71
+
72
+ async def __aexit__(self, *exc: Any) -> None:
73
+ await self.aclose()
74
+
75
+ async def aclose(self) -> None:
76
+ if self._owns_client:
77
+ await self._http.aclose()
78
+
79
+ async def _request(
80
+ self,
81
+ method: str,
82
+ path: str,
83
+ *,
84
+ json_body: Any | None = None,
85
+ params: dict[str, Any] | None = None,
86
+ idempotency_key: str | None = None,
87
+ ) -> Any:
88
+ resp = await self._http.request(
89
+ method,
90
+ f"{self.base_url}{path}",
91
+ headers=build_headers(self.api_key, idempotency_key),
92
+ json=json_body,
93
+ params=params,
94
+ )
95
+ raise_for_status(resp)
96
+ if resp.status_code == 204 or not resp.content:
97
+ return None
98
+ return resp.json()
99
+
100
+ async def me(self) -> Me:
101
+ return Me(**await self._request("GET", "/v1/me"))
102
+
103
+
104
+ class _AsyncSims:
105
+ def __init__(self, parent: AsyncKranth):
106
+ self._p = parent
107
+
108
+ async def create(
109
+ self,
110
+ *,
111
+ idea_text: str,
112
+ persona_count: int,
113
+ model_id: str,
114
+ training_opt_in: bool | None = None,
115
+ idempotency_key: str | None = None,
116
+ ) -> CreateSimResponse:
117
+ body: dict[str, Any] = {
118
+ "idea_text": idea_text,
119
+ "persona_count": persona_count,
120
+ "model_id": model_id,
121
+ }
122
+ if training_opt_in is not None:
123
+ body["training_opt_in"] = training_opt_in
124
+ data = await self._p._request("POST", "/v1/sims", json_body=body, idempotency_key=idempotency_key)
125
+ return CreateSimResponse(**data)
126
+
127
+ async def get(self, sim_id: str) -> SimSummary:
128
+ return SimSummary(**await self._p._request("GET", f"/v1/sims/{sim_id}"))
129
+
130
+ async def list(
131
+ self,
132
+ *,
133
+ status: str | None = None,
134
+ cursor: str | None = None,
135
+ limit: int | None = None,
136
+ ) -> ListSimsResponse:
137
+ params: dict[str, Any] = {}
138
+ if status is not None:
139
+ params["status"] = status
140
+ if cursor is not None:
141
+ params["cursor"] = cursor
142
+ if limit is not None:
143
+ params["limit"] = limit
144
+ data = await self._p._request("GET", "/v1/sims", params=params)
145
+ return ListSimsResponse(
146
+ sims=[SimSummary(**s) for s in data["sims"]],
147
+ next_cursor=data.get("next_cursor"),
148
+ )
149
+
150
+ async def cancel(self, sim_id: str) -> dict[str, Any]:
151
+ return await self._p._request("POST", f"/v1/sims/{sim_id}/cancel")
152
+
153
+ async def export(self, sim_id: str) -> dict[str, Any]:
154
+ return await self._p._request("GET", f"/v1/sims/{sim_id}/export")
155
+
156
+ async def stream(self, sim_id: str) -> AsyncIterator[SimEvent]:
157
+ token_resp = await self._p._request("POST", f"/v1/sims/{sim_id}/stream-token")
158
+ token = token_resp["token"]
159
+ url = f"{self._p.base_url}/v1/sims/{sim_id}/events"
160
+ async with self._p._http.stream(
161
+ "GET",
162
+ url,
163
+ params={"token": token},
164
+ headers={"accept": "text/event-stream"},
165
+ ) as resp:
166
+ raise_for_status(resp)
167
+ buf: list[str] = []
168
+ async for raw in resp.aiter_lines():
169
+ if raw == "":
170
+ payload = "\n".join(l[5:].lstrip() for l in buf if l.startswith("data:"))
171
+ buf = []
172
+ if not payload:
173
+ continue
174
+ try:
175
+ obj = json.loads(payload)
176
+ except json.JSONDecodeError:
177
+ continue
178
+ yield SimEvent(
179
+ kind=obj.get("kind", ""),
180
+ sim_id=obj.get("sim_id", sim_id),
181
+ data=obj.get("data") or {},
182
+ )
183
+ else:
184
+ buf.append(raw)
185
+
186
+
187
+ class _AsyncRecon:
188
+ """Recon — web-grounded research mode (async)."""
189
+
190
+ def __init__(self, parent: AsyncKranth):
191
+ self._p = parent
192
+
193
+ async def create(
194
+ self,
195
+ *,
196
+ idea_text: str,
197
+ tier: str,
198
+ worker_model: str | None = None,
199
+ synth_model: str | None = None,
200
+ audience: str | None = None,
201
+ company_url: str | None = None,
202
+ idempotency_key: str | None = None,
203
+ ) -> CreateReconResponse:
204
+ body: dict[str, Any] = {"idea_text": idea_text, "tier": tier}
205
+ if worker_model is not None:
206
+ body["worker_model"] = worker_model
207
+ if synth_model is not None:
208
+ body["synth_model"] = synth_model
209
+ if audience is not None:
210
+ body["audience"] = audience
211
+ if company_url is not None:
212
+ body["company_url"] = company_url
213
+ data = await self._p._request("POST", "/v1/recon", json_body=body, idempotency_key=idempotency_key)
214
+ return CreateReconResponse(**data)
215
+
216
+ async def get(self, recon_id: str) -> ReconDetail:
217
+ data = await self._p._request("GET", f"/v1/recon/{recon_id}")
218
+ return ReconDetail(
219
+ run=ReconRun(**data["run"]),
220
+ findings=[ReconFinding(**f) for f in data.get("findings", [])],
221
+ sources=[ReconSource(**s) for s in data.get("sources", [])],
222
+ )
223
+
224
+ async def list(self) -> list[ReconSummary]:
225
+ data = await self._p._request("GET", "/v1/recon")
226
+ return [ReconSummary(**r) for r in data["recons"]]
227
+
228
+ async def tiers(self) -> list[ReconTier]:
229
+ data = await self._p._request("GET", "/v1/recon/tiers")
230
+ return [ReconTier(**t) for t in data["tiers"]]
231
+
232
+ async def export(self, recon_id: str) -> dict[str, Any]:
233
+ return await self._p._request("GET", f"/v1/recon/{recon_id}/export")
234
+
235
+ async def stream(self, recon_id: str) -> AsyncIterator[ReconEvent]:
236
+ token = (await self._p._request("POST", f"/v1/recon/{recon_id}/stream-token"))["token"]
237
+ url = f"{self._p.base_url}/v1/recon/{recon_id}/events"
238
+ async with self._p._http.stream(
239
+ "GET", url, params={"token": token}, headers={"accept": "text/event-stream"}
240
+ ) as resp:
241
+ raise_for_status(resp)
242
+ buf: list[str] = []
243
+ async for raw in resp.aiter_lines():
244
+ if raw == "":
245
+ payload = "\n".join(l[5:].lstrip() for l in buf if l.startswith("data:"))
246
+ buf = []
247
+ if not payload:
248
+ continue
249
+ try:
250
+ obj = json.loads(payload)
251
+ except json.JSONDecodeError:
252
+ continue
253
+ yield ReconEvent(
254
+ kind=obj.get("kind", ""),
255
+ recon_id=obj.get("recon_id", recon_id),
256
+ data=obj.get("data") or {},
257
+ )
258
+ else:
259
+ buf.append(raw)
260
+
261
+
262
+ def _parse_debate_detail(data: dict[str, Any]) -> DebateDetail:
263
+ data = dict(data)
264
+ synth = data.pop("synthesis", None)
265
+ audience = data.pop("audience", None) or []
266
+ return DebateDetail(
267
+ debate=Debate(**data),
268
+ synthesis=DebateSynthesis(**synth) if synth else None,
269
+ audience=[AudienceReaction(**a) for a in audience],
270
+ )
271
+
272
+
273
+ class _AsyncDebates:
274
+ """Debates — adversarial multi-persona panel mode (async)."""
275
+
276
+ def __init__(self, parent: AsyncKranth):
277
+ self._p = parent
278
+
279
+ async def create(
280
+ self,
281
+ *,
282
+ topic: str,
283
+ mode: str,
284
+ participants: int | None = None,
285
+ model_id: str | None = None,
286
+ training_opt_in: bool | None = None,
287
+ public: bool | None = None,
288
+ voice_enabled: bool | None = None,
289
+ archetype_slugs: list[str] | None = None,
290
+ idempotency_key: str | None = None,
291
+ ) -> Debate:
292
+ body: dict[str, Any] = {"topic": topic, "mode": mode}
293
+ if participants is not None:
294
+ body["participants"] = participants
295
+ if model_id is not None:
296
+ body["model_id"] = model_id
297
+ if training_opt_in is not None:
298
+ body["training_opt_in"] = training_opt_in
299
+ if public is not None:
300
+ body["public"] = public
301
+ if voice_enabled is not None:
302
+ body["voice_enabled"] = voice_enabled
303
+ if archetype_slugs is not None:
304
+ body["archetype_slugs"] = archetype_slugs
305
+ data = await self._p._request("POST", "/v1/debates", json_body=body, idempotency_key=idempotency_key)
306
+ return Debate(**data)
307
+
308
+ async def get(self, debate_id: str) -> DebateDetail:
309
+ return _parse_debate_detail(await self._p._request("GET", f"/v1/debates/{debate_id}"))
310
+
311
+ async def list(self) -> ListDebatesResponse:
312
+ data = await self._p._request("GET", "/v1/debates")
313
+ return ListDebatesResponse(
314
+ debates=[Debate(**d) for d in data["debates"]],
315
+ speakers={
316
+ k: [SpeakerPreview(**s) for s in v]
317
+ for k, v in (data.get("speakers") or {}).items()
318
+ },
319
+ )
320
+
321
+ async def turns(self, debate_id: str) -> list[DebateTurn]:
322
+ data = await self._p._request("GET", f"/v1/debates/{debate_id}/turns")
323
+ return [DebateTurn(**t) for t in data["turns"]]
324
+
325
+ async def set_public(self, debate_id: str, public: bool) -> DebateDetail:
326
+ return _parse_debate_detail(
327
+ await self._p._request("PATCH", f"/v1/debates/{debate_id}", json_body={"public": public})
328
+ )
329
+
330
+ async def cancel(self, debate_id: str) -> dict[str, Any]:
331
+ return await self._p._request("POST", f"/v1/debates/{debate_id}/cancel")
332
+
333
+ async def export(self, debate_id: str) -> dict[str, Any]:
334
+ return await self._p._request("GET", f"/v1/debates/{debate_id}/export")
335
+
336
+ async def stream(self, debate_id: str) -> AsyncIterator[DebateEvent]:
337
+ token = (await self._p._request("POST", f"/v1/debates/{debate_id}/stream-token"))["token"]
338
+ url = f"{self._p.base_url}/v1/debates/{debate_id}/events"
339
+ async with self._p._http.stream(
340
+ "GET", url, params={"token": token}, headers={"accept": "text/event-stream"}
341
+ ) as resp:
342
+ raise_for_status(resp)
343
+ buf: list[str] = []
344
+ async for raw in resp.aiter_lines():
345
+ if raw == "":
346
+ payload = "\n".join(l[5:].lstrip() for l in buf if l.startswith("data:"))
347
+ buf = []
348
+ if not payload:
349
+ continue
350
+ try:
351
+ obj = json.loads(payload)
352
+ except json.JSONDecodeError:
353
+ continue
354
+ yield DebateEvent(
355
+ kind=obj.get("kind", ""),
356
+ debate_id=obj.get("debate_id", debate_id),
357
+ data=obj.get("data") or {},
358
+ )
359
+ else:
360
+ buf.append(raw)
361
+
362
+
363
+ class _AsyncApiKeys:
364
+ def __init__(self, parent: AsyncKranth):
365
+ self._p = parent
366
+
367
+ async def create(self, name: str, *, env: str = "live") -> CreateApiKeyResponse:
368
+ data = await self._p._request("POST", "/v1/api-keys", json_body={"name": name, "env": env})
369
+ return CreateApiKeyResponse(**data)
370
+
371
+ async def list(self) -> list[ApiKey]:
372
+ data = await self._p._request("GET", "/v1/api-keys")
373
+ return [ApiKey(**k) for k in data["keys"]]
374
+
375
+ async def revoke(self, key_id: str) -> None:
376
+ await self._p._request("DELETE", f"/v1/api-keys/{key_id}")
377
+
378
+
379
+ class _AsyncModels:
380
+ def __init__(self, parent: AsyncKranth):
381
+ self._p = parent
382
+
383
+ async def list(self) -> list[ModelInfo]:
384
+ data = await self._p._request("GET", "/v1/models")
385
+ return [ModelInfo(**m) for m in data["models"]]
386
+
387
+
388
+ class _AsyncBilling:
389
+ def __init__(self, parent: AsyncKranth):
390
+ self._p = parent
391
+
392
+ async def usage(self) -> Usage:
393
+ return Usage(**await self._p._request("GET", "/v1/usage"))
394
+
395
+ async def checkout_url(
396
+ self,
397
+ plan: str,
398
+ *,
399
+ billing_period: str = "monthly",
400
+ success_url: str | None = None,
401
+ cancel_url: str | None = None,
402
+ ) -> str:
403
+ body: dict[str, Any] = {"plan": plan, "billing_period": billing_period}
404
+ if success_url:
405
+ body["success_url"] = success_url
406
+ if cancel_url:
407
+ body["cancel_url"] = cancel_url
408
+ data = await self._p._request("POST", "/v1/billing/checkout", json_body=body)
409
+ return data["url"]
410
+
411
+ async def portal_url(self, return_url: str | None = None) -> str:
412
+ body: dict[str, Any] = {}
413
+ if return_url:
414
+ body["return_url"] = return_url
415
+ data = await self._p._request("POST", "/v1/billing/portal", json_body=body)
416
+ return data["url"]