promptmenu-api 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Incognita Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: promptmenu-api
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the promptmenu API
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Author: Jesse Dhillon
8
+ Author-email: jesse@dhillon.com
9
+ Requires-Python: >=3.10
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Requires-Dist: httpx (>=0.25.0)
@@ -0,0 +1,35 @@
1
+ """Promptmenu Python SDK."""
2
+
3
+ from promptmenu_api.client import (
4
+ AsyncPromptmenuClient,
5
+ AuthenticationError,
6
+ AuthorizationError,
7
+ Flight,
8
+ FlightSummary,
9
+ NotFoundError,
10
+ PromptmenuClient,
11
+ PromptmenuError,
12
+ Survey,
13
+ SurveyDimension,
14
+ SurveySchema,
15
+ Template,
16
+ TokenResponse,
17
+ ValidationError,
18
+ )
19
+
20
+ __all__ = [
21
+ "AsyncPromptmenuClient",
22
+ "AuthenticationError",
23
+ "AuthorizationError",
24
+ "Flight",
25
+ "FlightSummary",
26
+ "NotFoundError",
27
+ "PromptmenuClient",
28
+ "PromptmenuError",
29
+ "Survey",
30
+ "SurveyDimension",
31
+ "SurveySchema",
32
+ "Template",
33
+ "TokenResponse",
34
+ "ValidationError",
35
+ ]
@@ -0,0 +1,933 @@
1
+ """Promptmenu Python SDK — sync and async clients for the promptmenu API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import typing as t
7
+ from dataclasses import dataclass, field
8
+
9
+ import httpx
10
+
11
+
12
+ class PromptmenuError(Exception):
13
+ """Base exception for promptmenu SDK errors."""
14
+
15
+ def __init__(self, message: str, status_code: int | None = None, detail: str | None = None) -> None:
16
+ self.status_code = status_code
17
+ self.detail = detail
18
+ super().__init__(message)
19
+
20
+
21
+ class AuthenticationError(PromptmenuError):
22
+ """Raised on 401 Unauthorized responses."""
23
+
24
+
25
+ class AuthorizationError(PromptmenuError):
26
+ """Raised on 403 Forbidden responses."""
27
+
28
+
29
+ class NotFoundError(PromptmenuError):
30
+ """Raised on 404 Not Found responses."""
31
+
32
+
33
+ class ValidationError(PromptmenuError):
34
+ """Raised on 400/422 Bad Request responses."""
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Response dataclasses
39
+ # ---------------------------------------------------------------------------
40
+
41
+
42
+ @dataclass
43
+ class Template(object):
44
+ template_id: str
45
+ name: str
46
+ version: int
47
+ content: str
48
+ description: str | None
49
+ is_active: bool
50
+ create_time: str
51
+ update_time: str
52
+
53
+
54
+ @dataclass
55
+ class Flight(object):
56
+ flight_id: str
57
+ template_id: str
58
+ template_name: str | None
59
+ template_version: int | None
60
+ created_by: str
61
+ feature_flags: dict[str, t.Any]
62
+ context: dict[str, t.Any]
63
+ rendered_content: str
64
+ model_provider: str
65
+ model_name: str
66
+ model_config_data: dict[str, t.Any]
67
+ status: str
68
+ started_at: str
69
+ completed_at: str | None
70
+ labels: dict[str, t.Any]
71
+ outcome_metadata: dict[str, t.Any] | None
72
+ create_time: str
73
+ update_time: str
74
+
75
+
76
+ @dataclass
77
+ class FlightSummary(object):
78
+ flight_id: str
79
+ template_id: str
80
+ template_name: str | None
81
+ template_version: int | None
82
+ created_by: str
83
+ feature_flags: dict[str, t.Any]
84
+ context: dict[str, t.Any]
85
+ model_provider: str
86
+ model_name: str
87
+ model_config_data: dict[str, t.Any]
88
+ status: str
89
+ started_at: str
90
+ completed_at: str | None
91
+ labels: dict[str, t.Any]
92
+ outcome_metadata: dict[str, t.Any] | None
93
+ create_time: str
94
+ update_time: str
95
+
96
+
97
+ @dataclass
98
+ class SurveyDimension(object):
99
+ name: str
100
+ label: str
101
+ spec: dict[str, t.Any]
102
+ required: bool = True
103
+ help: str | None = None
104
+ tags: list[str] = field(default_factory=list)
105
+ reverse_scored: bool = False
106
+
107
+
108
+ @dataclass
109
+ class SurveySchema(object):
110
+ schema_id: str
111
+ name: str
112
+ version: int
113
+ dimensions: list[dict[str, t.Any]]
114
+ dimensions_hash: str
115
+ create_time: str
116
+
117
+
118
+ @dataclass
119
+ class Survey(object):
120
+ survey_id: str
121
+ flight_id: str
122
+ schema_id: str | None
123
+ submitted_by: str
124
+ ratings: dict[str, t.Any]
125
+ notes: str | None
126
+ tags: list[str]
127
+ create_time: str
128
+
129
+
130
+ @dataclass
131
+ class TokenResponse(object):
132
+ access_token: str
133
+ token_type: str
134
+ expires_in: int
135
+ scopes: list[str]
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Internal helpers
140
+ # ---------------------------------------------------------------------------
141
+
142
+ _ERROR_MAP: dict[int, type[PromptmenuError]] = {
143
+ 400: ValidationError,
144
+ 401: AuthenticationError,
145
+ 403: AuthorizationError,
146
+ 404: NotFoundError,
147
+ 422: ValidationError,
148
+ }
149
+
150
+
151
+ def _raise_for_status(response: httpx.Response) -> None:
152
+ if response.is_success:
153
+ return
154
+ detail = None
155
+ try:
156
+ body = response.json()
157
+ detail = body.get("detail")
158
+ except Exception:
159
+ pass
160
+ exc_cls = _ERROR_MAP.get(response.status_code, PromptmenuError)
161
+ raise exc_cls(
162
+ f"HTTP {response.status_code}: {detail or response.reason_phrase}",
163
+ status_code=response.status_code,
164
+ detail=detail,
165
+ )
166
+
167
+
168
+ def _parse_template(data: dict[str, t.Any]) -> Template:
169
+ return Template(
170
+ template_id=data["template_id"],
171
+ name=data["name"],
172
+ version=data["version"],
173
+ content=data["content"],
174
+ description=data.get("description"),
175
+ is_active=data["is_active"],
176
+ create_time=data["create_time"],
177
+ update_time=data["update_time"],
178
+ )
179
+
180
+
181
+ def _parse_flight(data: dict[str, t.Any]) -> Flight:
182
+ return Flight(
183
+ flight_id=data["flight_id"],
184
+ template_id=data["template_id"],
185
+ template_name=data.get("template_name"),
186
+ template_version=data.get("template_version"),
187
+ created_by=data["created_by"],
188
+ feature_flags=data["feature_flags"],
189
+ context=data["context"],
190
+ rendered_content=data["rendered_content"],
191
+ model_provider=data["model_provider"],
192
+ model_name=data["model_name"],
193
+ model_config_data=data["model_config_data"],
194
+ status=data["status"],
195
+ started_at=data["started_at"],
196
+ completed_at=data.get("completed_at"),
197
+ labels=data["labels"],
198
+ outcome_metadata=data.get("outcome_metadata"),
199
+ create_time=data["create_time"],
200
+ update_time=data["update_time"],
201
+ )
202
+
203
+
204
+ def _parse_flight_summary(data: dict[str, t.Any]) -> FlightSummary:
205
+ return FlightSummary(
206
+ flight_id=data["flight_id"],
207
+ template_id=data["template_id"],
208
+ template_name=data.get("template_name"),
209
+ template_version=data.get("template_version"),
210
+ created_by=data["created_by"],
211
+ feature_flags=data["feature_flags"],
212
+ context=data["context"],
213
+ model_provider=data["model_provider"],
214
+ model_name=data["model_name"],
215
+ model_config_data=data["model_config_data"],
216
+ status=data["status"],
217
+ started_at=data["started_at"],
218
+ completed_at=data.get("completed_at"),
219
+ labels=data["labels"],
220
+ outcome_metadata=data.get("outcome_metadata"),
221
+ create_time=data["create_time"],
222
+ update_time=data["update_time"],
223
+ )
224
+
225
+
226
+ def _parse_survey_schema(data: dict[str, t.Any]) -> SurveySchema:
227
+ return SurveySchema(
228
+ schema_id=data["schema_id"],
229
+ name=data["name"],
230
+ version=data["version"],
231
+ dimensions=data["dimensions"],
232
+ dimensions_hash=data["dimensions_hash"],
233
+ create_time=data["create_time"],
234
+ )
235
+
236
+
237
+ def _parse_survey(data: dict[str, t.Any]) -> Survey:
238
+ return Survey(
239
+ survey_id=data["survey_id"],
240
+ flight_id=data["flight_id"],
241
+ schema_id=data.get("schema_id"),
242
+ submitted_by=data["submitted_by"],
243
+ ratings=data["ratings"],
244
+ notes=data.get("notes"),
245
+ tags=data.get("tags", []),
246
+ create_time=data["create_time"],
247
+ )
248
+
249
+
250
+ # ---------------------------------------------------------------------------
251
+ # Token cache
252
+ # ---------------------------------------------------------------------------
253
+
254
+
255
+ class _TokenCache(object):
256
+ """Caches a JWT token and auto-refreshes before expiry."""
257
+
258
+ def __init__(self) -> None:
259
+ self._token: str | None = None
260
+ self._expires_at: float = 0.0
261
+
262
+ @property
263
+ def valid(self) -> bool:
264
+ return self._token is not None and time.monotonic() < self._expires_at
265
+
266
+ @property
267
+ def token(self) -> str | None:
268
+ if self.valid:
269
+ return self._token
270
+ return None
271
+
272
+ def store(self, token_response: TokenResponse) -> None:
273
+ self._token = token_response.access_token
274
+ # Refresh 60s before actual expiry to avoid edge cases
275
+ self._expires_at = time.monotonic() + max(token_response.expires_in - 60, 0)
276
+
277
+ def clear(self) -> None:
278
+ self._token = None
279
+ self._expires_at = 0.0
280
+
281
+
282
+ # ---------------------------------------------------------------------------
283
+ # Synchronous client
284
+ # ---------------------------------------------------------------------------
285
+
286
+
287
+ class PromptmenuClient(object):
288
+ """Synchronous client for the promptmenu API.
289
+
290
+ Usage::
291
+
292
+ client = PromptmenuClient("https://promptmenu.ai", api_key="your-api-key")
293
+
294
+ # Server-side operations (API key auth)
295
+ template = client.create_template(name="greeting", content="Hello {{ name }}!")
296
+ flight = client.create_flight(
297
+ template="greeting",
298
+ created_by="my-app",
299
+ context={"name": "World"},
300
+ model_provider="anthropic",
301
+ model_name="claude-sonnet-4-5-20250929",
302
+ )
303
+
304
+ # Browser-facing operations (auto-obtains JWT)
305
+ schemas = client.list_survey_schemas()
306
+ client.create_survey(
307
+ flight_id=flight.flight_id,
308
+ submitted_by="user@example.com",
309
+ ratings={"quality": 5},
310
+ )
311
+ """
312
+
313
+ def __init__(
314
+ self,
315
+ base_url: str,
316
+ *,
317
+ api_key: str,
318
+ timeout: float = 30.0,
319
+ ) -> None:
320
+ self._base_url = str(base_url).rstrip("/")
321
+ self._api_key = api_key
322
+ self._timeout = timeout
323
+ self._token_cache = _TokenCache()
324
+
325
+ def _api_key_headers(self) -> dict[str, str]:
326
+ return {"Authorization": f"Bearer {self._api_key}"}
327
+
328
+ def _jwt_headers(self) -> dict[str, str]:
329
+ token = self._token_cache.token
330
+ if token is None:
331
+ resp = self.create_token()
332
+ self._token_cache.store(resp)
333
+ token = resp.access_token
334
+ return {"Authorization": f"Bearer {token}"}
335
+
336
+ # -- Auth ---------------------------------------------------------------
337
+
338
+ def create_token(self, *, scopes: list[str] | None = None) -> TokenResponse:
339
+ """Issue a scoped JWT for browser-facing requests."""
340
+ payload: dict[str, t.Any] = {}
341
+ if scopes is not None:
342
+ payload["scopes"] = scopes
343
+ with httpx.Client(base_url=self._base_url, timeout=self._timeout) as http:
344
+ resp = http.post("/auth/token", json=payload, headers=self._api_key_headers())
345
+ _raise_for_status(resp)
346
+ data = resp.json()
347
+ return TokenResponse(
348
+ access_token=data["access_token"],
349
+ token_type=data["token_type"],
350
+ expires_in=data["expires_in"],
351
+ scopes=data["scopes"],
352
+ )
353
+
354
+ # -- Templates ----------------------------------------------------------
355
+
356
+ def create_template(
357
+ self,
358
+ *,
359
+ name: str,
360
+ content: str,
361
+ description: str | None = None,
362
+ ) -> Template:
363
+ """Create a prompt template."""
364
+ payload: dict[str, t.Any] = {"name": name, "content": content}
365
+ if description is not None:
366
+ payload["description"] = description
367
+ with httpx.Client(base_url=self._base_url, timeout=self._timeout) as http:
368
+ resp = http.post("/templates", json=payload, headers=self._api_key_headers())
369
+ _raise_for_status(resp)
370
+ return _parse_template(resp.json())
371
+
372
+ def get_template(self, template_id: str) -> Template:
373
+ """Get a template by ID."""
374
+ with httpx.Client(base_url=self._base_url, timeout=self._timeout) as http:
375
+ resp = http.get(f"/templates/{template_id}", headers=self._api_key_headers())
376
+ _raise_for_status(resp)
377
+ return _parse_template(resp.json())
378
+
379
+ def list_templates(
380
+ self,
381
+ *,
382
+ name: str | None = None,
383
+ is_active: bool | None = None,
384
+ ) -> list[Template]:
385
+ """List templates, optionally filtered."""
386
+ params: dict[str, t.Any] = {}
387
+ if name is not None:
388
+ params["name"] = name
389
+ if is_active is not None:
390
+ params["is_active"] = is_active
391
+ with httpx.Client(base_url=self._base_url, timeout=self._timeout) as http:
392
+ resp = http.get("/templates", params=params, headers=self._api_key_headers())
393
+ _raise_for_status(resp)
394
+ return [_parse_template(t_) for t_ in resp.json()["templates"]]
395
+
396
+ def update_template(
397
+ self,
398
+ template_id: str,
399
+ *,
400
+ description: str | None = None,
401
+ is_active: bool | None = None,
402
+ ) -> Template:
403
+ """Update a template's metadata."""
404
+ params: dict[str, t.Any] = {}
405
+ if description is not None:
406
+ params["description"] = description
407
+ if is_active is not None:
408
+ params["is_active"] = is_active
409
+ with httpx.Client(base_url=self._base_url, timeout=self._timeout) as http:
410
+ resp = http.patch(f"/templates/{template_id}", params=params, headers=self._api_key_headers())
411
+ _raise_for_status(resp)
412
+ return _parse_template(resp.json())
413
+
414
+ # -- Flights ------------------------------------------------------------
415
+
416
+ def create_flight(
417
+ self,
418
+ *,
419
+ template: str,
420
+ created_by: str,
421
+ model_provider: str,
422
+ model_name: str,
423
+ template_bundle: dict[str, str] | None = None,
424
+ feature_flags: dict[str, t.Any] | None = None,
425
+ context: dict[str, t.Any] | None = None,
426
+ model_config_data: dict[str, t.Any] | None = None,
427
+ labels: dict[str, t.Any] | None = None,
428
+ ) -> Flight:
429
+ """Create a flight by rendering a template."""
430
+ payload: dict[str, t.Any] = {
431
+ "template": template,
432
+ "created_by": created_by,
433
+ "model_provider": model_provider,
434
+ "model_name": model_name,
435
+ }
436
+ if template_bundle is not None:
437
+ payload["template_bundle"] = template_bundle
438
+ if feature_flags is not None:
439
+ payload["feature_flags"] = feature_flags
440
+ if context is not None:
441
+ payload["context"] = context
442
+ if model_config_data is not None:
443
+ payload["model_config_data"] = model_config_data
444
+ if labels is not None:
445
+ payload["labels"] = labels
446
+ with httpx.Client(base_url=self._base_url, timeout=self._timeout) as http:
447
+ resp = http.post("/flights", json=payload, headers=self._api_key_headers())
448
+ _raise_for_status(resp)
449
+ return _parse_flight(resp.json())
450
+
451
+ def get_flight(self, flight_id: str) -> Flight:
452
+ """Get a flight by ID."""
453
+ with httpx.Client(base_url=self._base_url, timeout=self._timeout) as http:
454
+ resp = http.get(f"/flights/{flight_id}", headers=self._api_key_headers())
455
+ _raise_for_status(resp)
456
+ return _parse_flight(resp.json())
457
+
458
+ def list_flights(
459
+ self,
460
+ *,
461
+ template_id: str | None = None,
462
+ status: str | None = None,
463
+ created_by: str | None = None,
464
+ limit: int | None = None,
465
+ ) -> list[FlightSummary]:
466
+ """List flights, optionally filtered."""
467
+ params: dict[str, t.Any] = {}
468
+ if template_id is not None:
469
+ params["template_id"] = template_id
470
+ if status is not None:
471
+ params["status_filter"] = status
472
+ if created_by is not None:
473
+ params["created_by"] = created_by
474
+ if limit is not None:
475
+ params["limit"] = limit
476
+ with httpx.Client(base_url=self._base_url, timeout=self._timeout) as http:
477
+ resp = http.get("/flights", params=params, headers=self._api_key_headers())
478
+ _raise_for_status(resp)
479
+ return [_parse_flight_summary(f) for f in resp.json()["flights"]]
480
+
481
+ def update_flight(
482
+ self,
483
+ flight_id: str,
484
+ *,
485
+ status: str | None = None,
486
+ outcome_metadata: dict[str, t.Any] | None = None,
487
+ ) -> Flight:
488
+ """Update a flight's status and/or outcome metadata."""
489
+ payload: dict[str, t.Any] = {}
490
+ if status is not None:
491
+ payload["status"] = status
492
+ if outcome_metadata is not None:
493
+ payload["outcome_metadata"] = outcome_metadata
494
+ with httpx.Client(base_url=self._base_url, timeout=self._timeout) as http:
495
+ resp = http.patch(f"/flights/{flight_id}", json=payload, headers=self._api_key_headers())
496
+ _raise_for_status(resp)
497
+ return _parse_flight(resp.json())
498
+
499
+ def complete_flight(
500
+ self,
501
+ flight_id: str,
502
+ *,
503
+ outcome_metadata: dict[str, t.Any] | None = None,
504
+ ) -> Flight:
505
+ """Mark a flight as completed."""
506
+ params: dict[str, t.Any] = {}
507
+ if outcome_metadata is not None:
508
+ params["outcome_metadata"] = outcome_metadata
509
+ with httpx.Client(base_url=self._base_url, timeout=self._timeout) as http:
510
+ resp = http.post(f"/flights/{flight_id}/complete", params=params, headers=self._api_key_headers())
511
+ _raise_for_status(resp)
512
+ return _parse_flight(resp.json())
513
+
514
+ # -- Survey schemas -----------------------------------------------------
515
+
516
+ def create_survey_schema(
517
+ self,
518
+ *,
519
+ name: str,
520
+ dimensions: list[SurveyDimension | dict[str, t.Any]],
521
+ ) -> SurveySchema:
522
+ """Create a survey schema."""
523
+ dims = []
524
+ for d in dimensions:
525
+ if isinstance(d, SurveyDimension):
526
+ dim: dict[str, t.Any] = {
527
+ "name": d.name,
528
+ "label": d.label,
529
+ "spec": d.spec,
530
+ "required": d.required,
531
+ "tags": d.tags,
532
+ "reverse_scored": d.reverse_scored,
533
+ }
534
+ if d.help is not None:
535
+ dim["help"] = d.help
536
+ dims.append(dim)
537
+ else:
538
+ dims.append(d)
539
+ payload: dict[str, t.Any] = {"name": name, "dimensions": dims}
540
+ with httpx.Client(base_url=self._base_url, timeout=self._timeout) as http:
541
+ resp = http.post("/survey-schemas", json=payload, headers=self._api_key_headers())
542
+ _raise_for_status(resp)
543
+ return _parse_survey_schema(resp.json())
544
+
545
+ def get_survey_schema(self, schema_id: str) -> SurveySchema:
546
+ """Get a survey schema by ID (requires JWT)."""
547
+ with httpx.Client(base_url=self._base_url, timeout=self._timeout) as http:
548
+ resp = http.get(f"/survey-schemas/{schema_id}", headers=self._jwt_headers())
549
+ _raise_for_status(resp)
550
+ return _parse_survey_schema(resp.json())
551
+
552
+ def get_survey_schema_by_name(self, name: str, *, version: int | None = None) -> SurveySchema:
553
+ """Get a survey schema by name (requires JWT)."""
554
+ params: dict[str, t.Any] = {}
555
+ if version is not None:
556
+ params["version"] = version
557
+ with httpx.Client(base_url=self._base_url, timeout=self._timeout) as http:
558
+ resp = http.get(f"/survey-schemas/by-name/{name}", params=params, headers=self._jwt_headers())
559
+ _raise_for_status(resp)
560
+ return _parse_survey_schema(resp.json())
561
+
562
+ def list_survey_schemas(self) -> list[SurveySchema]:
563
+ """List all survey schemas (requires JWT)."""
564
+ with httpx.Client(base_url=self._base_url, timeout=self._timeout) as http:
565
+ resp = http.get("/survey-schemas", headers=self._jwt_headers())
566
+ _raise_for_status(resp)
567
+ return [_parse_survey_schema(s) for s in resp.json()["schemas"]]
568
+
569
+ # -- Surveys ------------------------------------------------------------
570
+
571
+ def create_survey(
572
+ self,
573
+ flight_id: str,
574
+ *,
575
+ submitted_by: str,
576
+ ratings: dict[str, t.Any],
577
+ schema_id: str | None = None,
578
+ notes: str | None = None,
579
+ tags: list[str] | None = None,
580
+ ) -> Survey:
581
+ """Submit a survey for a flight (requires JWT)."""
582
+ payload: dict[str, t.Any] = {
583
+ "submitted_by": submitted_by,
584
+ "ratings": ratings,
585
+ }
586
+ if schema_id is not None:
587
+ payload["schema_id"] = schema_id
588
+ if notes is not None:
589
+ payload["notes"] = notes
590
+ if tags is not None:
591
+ payload["tags"] = tags
592
+ with httpx.Client(base_url=self._base_url, timeout=self._timeout) as http:
593
+ resp = http.post(f"/flights/{flight_id}/surveys", json=payload, headers=self._jwt_headers())
594
+ _raise_for_status(resp)
595
+ return _parse_survey(resp.json())
596
+
597
+ def list_surveys(self, flight_id: str) -> list[Survey]:
598
+ """List surveys for a flight."""
599
+ with httpx.Client(base_url=self._base_url, timeout=self._timeout) as http:
600
+ resp = http.get(f"/flights/{flight_id}/surveys", headers=self._api_key_headers())
601
+ _raise_for_status(resp)
602
+ return [_parse_survey(s) for s in resp.json()["surveys"]]
603
+
604
+ def get_survey(self, survey_id: str) -> Survey:
605
+ """Get a survey by ID."""
606
+ with httpx.Client(base_url=self._base_url, timeout=self._timeout) as http:
607
+ resp = http.get(f"/surveys/{survey_id}", headers=self._api_key_headers())
608
+ _raise_for_status(resp)
609
+ return _parse_survey(resp.json())
610
+
611
+
612
+ # ---------------------------------------------------------------------------
613
+ # Async client
614
+ # ---------------------------------------------------------------------------
615
+
616
+
617
+ class AsyncPromptmenuClient(object):
618
+ """Async client for the promptmenu API.
619
+
620
+ Usage::
621
+
622
+ async with AsyncPromptmenuClient("https://promptmenu.ai", api_key="key") as client:
623
+ flight = await client.create_flight(...)
624
+
625
+ Can also be used without the context manager::
626
+
627
+ client = AsyncPromptmenuClient("https://promptmenu.ai", api_key="key")
628
+ flight = await client.create_flight(...)
629
+ """
630
+
631
+ def __init__(
632
+ self,
633
+ base_url: str,
634
+ *,
635
+ api_key: str,
636
+ timeout: float = 30.0,
637
+ ) -> None:
638
+ self._base_url = str(base_url).rstrip("/")
639
+ self._api_key = api_key
640
+ self._timeout = timeout
641
+ self._token_cache = _TokenCache()
642
+
643
+ async def __aenter__(self) -> AsyncPromptmenuClient:
644
+ return self
645
+
646
+ async def __aexit__(self, *args: t.Any) -> None:
647
+ pass
648
+
649
+ def _api_key_headers(self) -> dict[str, str]:
650
+ return {"Authorization": f"Bearer {self._api_key}"}
651
+
652
+ async def _jwt_headers(self) -> dict[str, str]:
653
+ token = self._token_cache.token
654
+ if token is None:
655
+ resp = await self.create_token()
656
+ self._token_cache.store(resp)
657
+ token = resp.access_token
658
+ return {"Authorization": f"Bearer {token}"}
659
+
660
+ # -- Auth ---------------------------------------------------------------
661
+
662
+ async def create_token(self, *, scopes: list[str] | None = None) -> TokenResponse:
663
+ """Issue a scoped JWT for browser-facing requests."""
664
+ payload: dict[str, t.Any] = {}
665
+ if scopes is not None:
666
+ payload["scopes"] = scopes
667
+ async with httpx.AsyncClient(base_url=self._base_url, timeout=self._timeout) as http:
668
+ resp = await http.post("/auth/token", json=payload, headers=self._api_key_headers())
669
+ _raise_for_status(resp)
670
+ data = resp.json()
671
+ return TokenResponse(
672
+ access_token=data["access_token"],
673
+ token_type=data["token_type"],
674
+ expires_in=data["expires_in"],
675
+ scopes=data["scopes"],
676
+ )
677
+
678
+ # -- Templates ----------------------------------------------------------
679
+
680
+ async def create_template(
681
+ self,
682
+ *,
683
+ name: str,
684
+ content: str,
685
+ description: str | None = None,
686
+ ) -> Template:
687
+ """Create a prompt template."""
688
+ payload: dict[str, t.Any] = {"name": name, "content": content}
689
+ if description is not None:
690
+ payload["description"] = description
691
+ async with httpx.AsyncClient(base_url=self._base_url, timeout=self._timeout) as http:
692
+ resp = await http.post("/templates", json=payload, headers=self._api_key_headers())
693
+ _raise_for_status(resp)
694
+ return _parse_template(resp.json())
695
+
696
+ async def get_template(self, template_id: str) -> Template:
697
+ """Get a template by ID."""
698
+ async with httpx.AsyncClient(base_url=self._base_url, timeout=self._timeout) as http:
699
+ resp = await http.get(f"/templates/{template_id}", headers=self._api_key_headers())
700
+ _raise_for_status(resp)
701
+ return _parse_template(resp.json())
702
+
703
+ async def list_templates(
704
+ self,
705
+ *,
706
+ name: str | None = None,
707
+ is_active: bool | None = None,
708
+ ) -> list[Template]:
709
+ """List templates, optionally filtered."""
710
+ params: dict[str, t.Any] = {}
711
+ if name is not None:
712
+ params["name"] = name
713
+ if is_active is not None:
714
+ params["is_active"] = is_active
715
+ async with httpx.AsyncClient(base_url=self._base_url, timeout=self._timeout) as http:
716
+ resp = await http.get("/templates", params=params, headers=self._api_key_headers())
717
+ _raise_for_status(resp)
718
+ return [_parse_template(t_) for t_ in resp.json()["templates"]]
719
+
720
+ async def update_template(
721
+ self,
722
+ template_id: str,
723
+ *,
724
+ description: str | None = None,
725
+ is_active: bool | None = None,
726
+ ) -> Template:
727
+ """Update a template's metadata."""
728
+ params: dict[str, t.Any] = {}
729
+ if description is not None:
730
+ params["description"] = description
731
+ if is_active is not None:
732
+ params["is_active"] = is_active
733
+ async with httpx.AsyncClient(base_url=self._base_url, timeout=self._timeout) as http:
734
+ resp = await http.patch(f"/templates/{template_id}", params=params, headers=self._api_key_headers())
735
+ _raise_for_status(resp)
736
+ return _parse_template(resp.json())
737
+
738
+ # -- Flights ------------------------------------------------------------
739
+
740
+ async def create_flight(
741
+ self,
742
+ *,
743
+ template: str,
744
+ created_by: str,
745
+ model_provider: str,
746
+ model_name: str,
747
+ template_bundle: dict[str, str] | None = None,
748
+ feature_flags: dict[str, t.Any] | None = None,
749
+ context: dict[str, t.Any] | None = None,
750
+ model_config_data: dict[str, t.Any] | None = None,
751
+ labels: dict[str, t.Any] | None = None,
752
+ ) -> Flight:
753
+ """Create a flight by rendering a template."""
754
+ payload: dict[str, t.Any] = {
755
+ "template": template,
756
+ "created_by": created_by,
757
+ "model_provider": model_provider,
758
+ "model_name": model_name,
759
+ }
760
+ if template_bundle is not None:
761
+ payload["template_bundle"] = template_bundle
762
+ if feature_flags is not None:
763
+ payload["feature_flags"] = feature_flags
764
+ if context is not None:
765
+ payload["context"] = context
766
+ if model_config_data is not None:
767
+ payload["model_config_data"] = model_config_data
768
+ if labels is not None:
769
+ payload["labels"] = labels
770
+ async with httpx.AsyncClient(base_url=self._base_url, timeout=self._timeout) as http:
771
+ resp = await http.post("/flights", json=payload, headers=self._api_key_headers())
772
+ _raise_for_status(resp)
773
+ return _parse_flight(resp.json())
774
+
775
+ async def get_flight(self, flight_id: str) -> Flight:
776
+ """Get a flight by ID."""
777
+ async with httpx.AsyncClient(base_url=self._base_url, timeout=self._timeout) as http:
778
+ resp = await http.get(f"/flights/{flight_id}", headers=self._api_key_headers())
779
+ _raise_for_status(resp)
780
+ return _parse_flight(resp.json())
781
+
782
+ async def list_flights(
783
+ self,
784
+ *,
785
+ template_id: str | None = None,
786
+ status: str | None = None,
787
+ created_by: str | None = None,
788
+ limit: int | None = None,
789
+ ) -> list[FlightSummary]:
790
+ """List flights, optionally filtered."""
791
+ params: dict[str, t.Any] = {}
792
+ if template_id is not None:
793
+ params["template_id"] = template_id
794
+ if status is not None:
795
+ params["status_filter"] = status
796
+ if created_by is not None:
797
+ params["created_by"] = created_by
798
+ if limit is not None:
799
+ params["limit"] = limit
800
+ async with httpx.AsyncClient(base_url=self._base_url, timeout=self._timeout) as http:
801
+ resp = await http.get("/flights", params=params, headers=self._api_key_headers())
802
+ _raise_for_status(resp)
803
+ return [_parse_flight_summary(f) for f in resp.json()["flights"]]
804
+
805
+ async def update_flight(
806
+ self,
807
+ flight_id: str,
808
+ *,
809
+ status: str | None = None,
810
+ outcome_metadata: dict[str, t.Any] | None = None,
811
+ ) -> Flight:
812
+ """Update a flight's status and/or outcome metadata."""
813
+ payload: dict[str, t.Any] = {}
814
+ if status is not None:
815
+ payload["status"] = status
816
+ if outcome_metadata is not None:
817
+ payload["outcome_metadata"] = outcome_metadata
818
+ async with httpx.AsyncClient(base_url=self._base_url, timeout=self._timeout) as http:
819
+ resp = await http.patch(f"/flights/{flight_id}", json=payload, headers=self._api_key_headers())
820
+ _raise_for_status(resp)
821
+ return _parse_flight(resp.json())
822
+
823
+ async def complete_flight(
824
+ self,
825
+ flight_id: str,
826
+ *,
827
+ outcome_metadata: dict[str, t.Any] | None = None,
828
+ ) -> Flight:
829
+ """Mark a flight as completed."""
830
+ params: dict[str, t.Any] = {}
831
+ if outcome_metadata is not None:
832
+ params["outcome_metadata"] = outcome_metadata
833
+ async with httpx.AsyncClient(base_url=self._base_url, timeout=self._timeout) as http:
834
+ resp = await http.post(f"/flights/{flight_id}/complete", params=params, headers=self._api_key_headers())
835
+ _raise_for_status(resp)
836
+ return _parse_flight(resp.json())
837
+
838
+ # -- Survey schemas -----------------------------------------------------
839
+
840
+ async def create_survey_schema(
841
+ self,
842
+ *,
843
+ name: str,
844
+ dimensions: list[SurveyDimension | dict[str, t.Any]],
845
+ ) -> SurveySchema:
846
+ """Create a survey schema."""
847
+ dims = []
848
+ for d in dimensions:
849
+ if isinstance(d, SurveyDimension):
850
+ dim: dict[str, t.Any] = {
851
+ "name": d.name,
852
+ "label": d.label,
853
+ "spec": d.spec,
854
+ "required": d.required,
855
+ "tags": d.tags,
856
+ "reverse_scored": d.reverse_scored,
857
+ }
858
+ if d.help is not None:
859
+ dim["help"] = d.help
860
+ dims.append(dim)
861
+ else:
862
+ dims.append(d)
863
+ payload: dict[str, t.Any] = {"name": name, "dimensions": dims}
864
+ async with httpx.AsyncClient(base_url=self._base_url, timeout=self._timeout) as http:
865
+ resp = await http.post("/survey-schemas", json=payload, headers=self._api_key_headers())
866
+ _raise_for_status(resp)
867
+ return _parse_survey_schema(resp.json())
868
+
869
+ async def get_survey_schema(self, schema_id: str) -> SurveySchema:
870
+ """Get a survey schema by ID (requires JWT)."""
871
+ async with httpx.AsyncClient(base_url=self._base_url, timeout=self._timeout) as http:
872
+ resp = await http.get(f"/survey-schemas/{schema_id}", headers=await self._jwt_headers())
873
+ _raise_for_status(resp)
874
+ return _parse_survey_schema(resp.json())
875
+
876
+ async def get_survey_schema_by_name(self, name: str, *, version: int | None = None) -> SurveySchema:
877
+ """Get a survey schema by name (requires JWT)."""
878
+ params: dict[str, t.Any] = {}
879
+ if version is not None:
880
+ params["version"] = version
881
+ async with httpx.AsyncClient(base_url=self._base_url, timeout=self._timeout) as http:
882
+ resp = await http.get(f"/survey-schemas/by-name/{name}", params=params, headers=await self._jwt_headers())
883
+ _raise_for_status(resp)
884
+ return _parse_survey_schema(resp.json())
885
+
886
+ async def list_survey_schemas(self) -> list[SurveySchema]:
887
+ """List all survey schemas (requires JWT)."""
888
+ async with httpx.AsyncClient(base_url=self._base_url, timeout=self._timeout) as http:
889
+ resp = await http.get("/survey-schemas", headers=await self._jwt_headers())
890
+ _raise_for_status(resp)
891
+ return [_parse_survey_schema(s) for s in resp.json()["schemas"]]
892
+
893
+ # -- Surveys ------------------------------------------------------------
894
+
895
+ async def create_survey(
896
+ self,
897
+ flight_id: str,
898
+ *,
899
+ submitted_by: str,
900
+ ratings: dict[str, t.Any],
901
+ schema_id: str | None = None,
902
+ notes: str | None = None,
903
+ tags: list[str] | None = None,
904
+ ) -> Survey:
905
+ """Submit a survey for a flight (requires JWT)."""
906
+ payload: dict[str, t.Any] = {
907
+ "submitted_by": submitted_by,
908
+ "ratings": ratings,
909
+ }
910
+ if schema_id is not None:
911
+ payload["schema_id"] = schema_id
912
+ if notes is not None:
913
+ payload["notes"] = notes
914
+ if tags is not None:
915
+ payload["tags"] = tags
916
+ async with httpx.AsyncClient(base_url=self._base_url, timeout=self._timeout) as http:
917
+ resp = await http.post(f"/flights/{flight_id}/surveys", json=payload, headers=await self._jwt_headers())
918
+ _raise_for_status(resp)
919
+ return _parse_survey(resp.json())
920
+
921
+ async def list_surveys(self, flight_id: str) -> list[Survey]:
922
+ """List surveys for a flight."""
923
+ async with httpx.AsyncClient(base_url=self._base_url, timeout=self._timeout) as http:
924
+ resp = await http.get(f"/flights/{flight_id}/surveys", headers=self._api_key_headers())
925
+ _raise_for_status(resp)
926
+ return [_parse_survey(s) for s in resp.json()["surveys"]]
927
+
928
+ async def get_survey(self, survey_id: str) -> Survey:
929
+ """Get a survey by ID."""
930
+ async with httpx.AsyncClient(base_url=self._base_url, timeout=self._timeout) as http:
931
+ resp = await http.get(f"/surveys/{survey_id}", headers=self._api_key_headers())
932
+ _raise_for_status(resp)
933
+ return _parse_survey(resp.json())
File without changes
@@ -0,0 +1,83 @@
1
+ [build-system]
2
+ requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"]
3
+ build-backend = "poetry_dynamic_versioning.backend"
4
+
5
+ [project]
6
+ name = "promptmenu-api"
7
+ description = "Python SDK for the promptmenu API"
8
+ dynamic = [ "version" ]
9
+ requires-python = ">=3.10"
10
+ authors = [
11
+ {name = "Jesse Dhillon", email = "jesse@dhillon.com" },
12
+ ]
13
+ license = "MIT"
14
+ dependencies = [
15
+ "httpx>=0.25.0",
16
+ ]
17
+
18
+ [dependency-groups]
19
+ test = [
20
+ "anyio[trio]~=4.0",
21
+ "pytest~=8.3.0",
22
+ "pytest-anyio~=0.0.0",
23
+ ]
24
+ dev = [
25
+ "isort~=6.0.0",
26
+ "pyright~=1.1.0",
27
+ "ruff~=0.11.0",
28
+ ]
29
+
30
+ [tool.pytest.ini_options]
31
+ testpaths = ["tests"]
32
+ python_files = ["test_*.py"]
33
+ addopts = "--strict-markers -svx"
34
+
35
+ [tool.pyright]
36
+ exclude = [
37
+ "**/.venv/**",
38
+ ]
39
+ venvPath = "."
40
+ venv = ".venv"
41
+ typeCheckingMode = "strict"
42
+
43
+ reportUnusedImport = false # let ruff catch this
44
+ reportUnusedVariable = false # let ruff catch this
45
+
46
+ reportUnknownParameterType = true
47
+ reportUnknownArgumentType = true
48
+ reportUnknownLambdaType = true
49
+ reportUnknownVariableType = true
50
+ reportUnknownMemberType = false
51
+ reportMissingTypeStubs = false
52
+
53
+ [tool.ruff]
54
+ exclude = [
55
+ ".venv",
56
+ ]
57
+ line-length = 120
58
+ lint.select = [ "E", "F", "B", "T" ]
59
+ lint.extend-ignore = [
60
+ "I",
61
+ ]
62
+ preview = true
63
+ target-version = "py310"
64
+
65
+ [tool.black]
66
+ line-length = 120
67
+ target-version = ["py310"]
68
+ skip-string-normalization = true
69
+
70
+ [tool.isort]
71
+ line_length = 120
72
+ multi_line_output = 2 # HANGING_INDENT
73
+
74
+ [tool.poetry]
75
+ name = "promptmenu"
76
+ version = "0.1.0" # placeholder, read from VERSION.txt by poetry-dynamic-versioning
77
+ packages = [{ include = "promptmenu_api" }]
78
+
79
+ [tool.poetry-dynamic-versioning]
80
+ enable = false
81
+
82
+ [tool.poetry-dynamic-versioning.from-file]
83
+ source = "VERSION.txt"