general-augment-sdk 0.1.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.
genaug/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """General Augment Python SDK public import path."""
2
+
3
+ from genaug.agent import AgentClient
4
+ from genaug.client import (
5
+ GeneralAugmentAPIError,
6
+ GeneralAugmentClient,
7
+ response_output_text,
8
+ response_structured_output,
9
+ )
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ __all__ = [
14
+ "AgentClient",
15
+ "GeneralAugmentAPIError",
16
+ "GeneralAugmentClient",
17
+ "__version__",
18
+ "response_output_text",
19
+ "response_structured_output",
20
+ ]
genaug/agent.py ADDED
@@ -0,0 +1,48 @@
1
+ """Agent test helpers for General Augment SDK users."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from genaug.client import GeneralAugmentClient
8
+
9
+
10
+ class AgentClient:
11
+ """Convenience wrapper scoped to one General Augment project."""
12
+
13
+ def __init__(self, client: GeneralAugmentClient, project_id: str) -> None:
14
+ """Initialize a project-scoped agent client."""
15
+ self.client = client
16
+ self.project_id = project_id
17
+
18
+ def test(
19
+ self,
20
+ message: str,
21
+ *,
22
+ phone_e164: str = "+15550000000",
23
+ channel: str = "whatsapp",
24
+ ) -> dict[str, Any]:
25
+ """Send a test message to the configured agent."""
26
+ return self.client.test_agent(
27
+ self.project_id,
28
+ message,
29
+ phone_e164=phone_e164,
30
+ channel=channel,
31
+ )
32
+
33
+
34
+ def test(
35
+ client: GeneralAugmentClient,
36
+ project_id: str,
37
+ message: str,
38
+ *,
39
+ phone_e164: str = "+15550000000",
40
+ channel: str = "whatsapp",
41
+ ) -> dict[str, Any]:
42
+ """Send a one-off test message to a General Augment project."""
43
+ return client.test_agent(
44
+ project_id,
45
+ message,
46
+ phone_e164=phone_e164,
47
+ channel=channel,
48
+ )
genaug/client.py ADDED
@@ -0,0 +1,726 @@
1
+ """Typed API client for General Augment.
2
+
3
+ The SDK targets the public admin and integration APIs exposed by the General Augment platform:
4
+
5
+ - `/api/v1/admin/*` for project, usage, logs, config, and test-message operations
6
+ - `/api/v1/integrations/*` for app-user identity linking
7
+
8
+ It also wraps `/v1/responses` and `/api/v1/agent/memory/*` for app backend
9
+ integrations. See `docs/public/SDK-REFERENCE.md` in the monorepo for end-to-end
10
+ examples.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json as json_module
16
+ from collections.abc import Iterator, Mapping
17
+ from pathlib import Path
18
+ from typing import Any
19
+ from urllib.parse import quote
20
+
21
+ import httpx
22
+
23
+ ADMIN_API_KEY_HEADER = "X-Admin-Key"
24
+ BEARER_AUTH_HEADER = "Authorization"
25
+ ADMIN_PREFIX = "/api/v1/admin"
26
+ INTEGRATIONS_PREFIX = "/api/v1/integrations"
27
+ DEFAULT_BASE_URL = "https://api.generalaugment.com"
28
+
29
+
30
+ class GeneralAugmentAPIError(RuntimeError):
31
+ """Raised when the General Augment API returns an error response."""
32
+
33
+ def __init__(
34
+ self,
35
+ status_code: int,
36
+ detail: str,
37
+ *,
38
+ code: str | None = None,
39
+ reason: str | None = None,
40
+ request_id: str | None = None,
41
+ trace_id: str | None = None,
42
+ retry_after: str | None = None,
43
+ rate_limit: Mapping[str, str] | None = None,
44
+ body: Any | None = None,
45
+ ) -> None:
46
+ """Create an API error with the HTTP status and response detail."""
47
+ super().__init__(f"General Augment API returned {status_code}: {detail}")
48
+ self.status_code = status_code
49
+ self.detail = detail
50
+ self.code = code
51
+ self.reason = reason
52
+ self.request_id = request_id
53
+ self.trace_id = trace_id
54
+ self.retry_after = retry_after
55
+ self.rate_limit = dict(rate_limit or {})
56
+ self.body = body
57
+
58
+
59
+ class GeneralAugmentClient:
60
+ """Synchronous client for the General Augment API.
61
+
62
+ Args:
63
+ api_key: Admin API key. Project-scoped keys are supported.
64
+ base_url: General Augment API base URL.
65
+ timeout: Request timeout in seconds.
66
+ client: Optional injected `httpx.Client`, useful for tests.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ api_key: str,
72
+ *,
73
+ base_url: str = DEFAULT_BASE_URL,
74
+ timeout: float = 30.0,
75
+ client: httpx.Client | None = None,
76
+ ) -> None:
77
+ """Initialize the General Augment API client."""
78
+ self.api_key = api_key
79
+ self.base_url = base_url.rstrip("/")
80
+ self.timeout = timeout
81
+ self._client = client or httpx.Client(timeout=timeout)
82
+ self._owns_client = client is None
83
+
84
+ def close(self) -> None:
85
+ """Close the underlying HTTP client if the SDK created it."""
86
+ if self._owns_client:
87
+ self._client.close()
88
+
89
+ def __enter__(self) -> GeneralAugmentClient:
90
+ """Return the context-managed client."""
91
+ return self
92
+
93
+ def __exit__(self, *_: object) -> None:
94
+ """Close the client on context-manager exit."""
95
+ self.close()
96
+
97
+ def admin_request(
98
+ self,
99
+ method: str,
100
+ path: str,
101
+ *,
102
+ json: Mapping[str, Any] | None = None,
103
+ params: Mapping[str, Any] | None = None,
104
+ ) -> Any:
105
+ """Call an admin API endpoint and return decoded JSON."""
106
+ return self._request(method, f"{ADMIN_PREFIX}{path}", json=json, params=params)
107
+
108
+ def integration_request(
109
+ self,
110
+ method: str,
111
+ path: str,
112
+ *,
113
+ json: Mapping[str, Any] | None = None,
114
+ params: Mapping[str, Any] | None = None,
115
+ ) -> Any:
116
+ """Call a developer integration API endpoint and return decoded JSON."""
117
+ return self._request(method, f"{INTEGRATIONS_PREFIX}{path}", json=json, params=params)
118
+
119
+ def list_projects(
120
+ self,
121
+ *,
122
+ limit: int | None = None,
123
+ offset: int | None = None,
124
+ ) -> list[dict[str, Any]]:
125
+ """Return projects visible to this API key."""
126
+ payload = self.admin_request(
127
+ "GET",
128
+ "/projects",
129
+ params=_defined_params(
130
+ {
131
+ "limit": limit,
132
+ "offset": offset,
133
+ }
134
+ ),
135
+ )
136
+ if isinstance(payload, dict):
137
+ items = payload.get("items", [])
138
+ return [item for item in items if isinstance(item, dict)]
139
+ return []
140
+
141
+ def create_response(
142
+ self,
143
+ payload: Mapping[str, Any],
144
+ *,
145
+ idempotency_key: str | None = None,
146
+ request_id: str | None = None,
147
+ traceparent: str | None = None,
148
+ tracestate: str | None = None,
149
+ ) -> dict[str, Any]:
150
+ """Create one Responses-compatible General Augment turn."""
151
+ return _as_dict(
152
+ self._request(
153
+ "POST",
154
+ "/v1/responses",
155
+ json=payload,
156
+ headers=_response_headers(
157
+ idempotency_key=idempotency_key,
158
+ request_id=request_id,
159
+ traceparent=traceparent,
160
+ tracestate=tracestate,
161
+ ),
162
+ auth="bearer",
163
+ )
164
+ )
165
+
166
+ def stream_response(
167
+ self,
168
+ payload: Mapping[str, Any],
169
+ *,
170
+ idempotency_key: str | None = None,
171
+ request_id: str | None = None,
172
+ traceparent: str | None = None,
173
+ tracestate: str | None = None,
174
+ ) -> Iterator[dict[str, Any]]:
175
+ """Stream semantic Responses SSE events."""
176
+ body = dict(payload)
177
+ body["stream"] = True
178
+ try:
179
+ with self._client.stream(
180
+ "POST",
181
+ f"{self.base_url}/v1/responses",
182
+ headers=self._headers(
183
+ _response_headers(
184
+ idempotency_key=idempotency_key,
185
+ request_id=request_id,
186
+ traceparent=traceparent,
187
+ tracestate=tracestate,
188
+ ),
189
+ auth="bearer",
190
+ ),
191
+ json=body,
192
+ ) as response:
193
+ if response.is_error:
194
+ response.read()
195
+ raise _api_error_from_response(response)
196
+ yield from _iter_sse_events(response.iter_lines())
197
+ except (httpx.ConnectError, httpx.TimeoutException, httpx.RequestError) as exc:
198
+ raise _api_error_from_transport(exc) from exc
199
+
200
+ def store_memory(self, payload: Mapping[str, Any]) -> dict[str, Any]:
201
+ """Store one durable memory fact for an app user."""
202
+ return _as_dict(
203
+ self._request(
204
+ "POST",
205
+ "/api/v1/agent/memory/store",
206
+ json=payload,
207
+ auth="bearer",
208
+ )
209
+ )
210
+
211
+ def search_memory(self, payload: Mapping[str, Any]) -> dict[str, Any]:
212
+ """Search memory facts for an app user."""
213
+ return _as_dict(
214
+ self._request(
215
+ "POST",
216
+ "/api/v1/agent/memory/search",
217
+ json=payload,
218
+ auth="bearer",
219
+ )
220
+ )
221
+
222
+ def memory_profile(self, user_id: str) -> dict[str, Any]:
223
+ """Return profile and recent facts for one app user."""
224
+ return _as_dict(
225
+ self._request(
226
+ "GET",
227
+ f"/api/v1/agent/memory/profile/{_path_segment(user_id)}",
228
+ auth="bearer",
229
+ )
230
+ )
231
+
232
+ def delete_memory(self, memory_id: str, *, user_id: str) -> dict[str, Any]:
233
+ """Delete one memory fact for the scoped app user."""
234
+ return _as_dict(
235
+ self._request(
236
+ "DELETE",
237
+ f"/api/v1/agent/memory/{_path_segment(memory_id)}",
238
+ params={"user_id": user_id},
239
+ auth="bearer",
240
+ )
241
+ )
242
+
243
+ def purge_user_memory(self, user_id: str) -> dict[str, Any]:
244
+ """Delete all memory facts for one app user."""
245
+ return _as_dict(
246
+ self._request(
247
+ "DELETE",
248
+ f"/api/v1/agent/memory/user/{_path_segment(user_id)}",
249
+ auth="bearer",
250
+ )
251
+ )
252
+
253
+ def get_project(self, project_id: str) -> dict[str, Any]:
254
+ """Return one project by ID."""
255
+ return _as_dict(self.admin_request("GET", f"/projects/{_path_segment(project_id)}"))
256
+
257
+ def create_project_from_config(
258
+ self,
259
+ yaml_content: str,
260
+ *,
261
+ soul_content: str | None = None,
262
+ skills: list[str] | None = None,
263
+ ) -> dict[str, Any]:
264
+ """Create a project from a General Augment project YAML document."""
265
+ return _as_dict(
266
+ self.admin_request(
267
+ "POST",
268
+ "/projects/from-config",
269
+ json={
270
+ "yaml_content": yaml_content,
271
+ "soul_content": soul_content,
272
+ "skills": skills or [],
273
+ },
274
+ )
275
+ )
276
+
277
+ def deploy_config_file(self, config_path: str | Path) -> dict[str, Any]:
278
+ """Create a project from a local General Augment project YAML file."""
279
+ content = Path(config_path).read_text(encoding="utf-8")
280
+ return self.create_project_from_config(content)
281
+
282
+ def update_project(self, project_id: str, **fields: Any) -> dict[str, Any]:
283
+ """Patch mutable project fields."""
284
+ payload = {key: value for key, value in fields.items() if value is not None}
285
+ return _as_dict(
286
+ self.admin_request("PATCH", f"/projects/{_path_segment(project_id)}", json=payload)
287
+ )
288
+
289
+ def integration_prompt(self, project_id: str) -> str:
290
+ """Return the copy-paste AI coding agent integration prompt."""
291
+ payload = _as_dict(
292
+ self.admin_request("GET", f"/projects/{_path_segment(project_id)}/integration-prompt")
293
+ )
294
+ return str(payload.get("prompt", ""))
295
+
296
+ def usage(
297
+ self,
298
+ project_id: str,
299
+ *,
300
+ start_date: str | None = None,
301
+ end_date: str | None = None,
302
+ ) -> dict[str, Any]:
303
+ """Return daily usage and billing aggregates for a project."""
304
+ params = {
305
+ key: value
306
+ for key, value in {"start_date": start_date, "end_date": end_date}.items()
307
+ if value is not None
308
+ }
309
+ return _as_dict(
310
+ self.admin_request("GET", f"/projects/{_path_segment(project_id)}/usage", params=params)
311
+ )
312
+
313
+ def test_agent(
314
+ self,
315
+ project_id: str,
316
+ message: str,
317
+ *,
318
+ phone_e164: str = "+15550000000",
319
+ channel: str = "whatsapp",
320
+ ) -> dict[str, Any]:
321
+ """Send a test message to an agent without using a live channel webhook."""
322
+ return _as_dict(
323
+ self.admin_request(
324
+ "POST",
325
+ f"/projects/{_path_segment(project_id)}/test",
326
+ json={"message": message, "phone_e164": phone_e164, "channel": channel},
327
+ )
328
+ )
329
+
330
+ def link_user(
331
+ self,
332
+ project_id: str,
333
+ *,
334
+ phone: str,
335
+ app_user_id: str,
336
+ provider_name: str = "app",
337
+ metadata: Mapping[str, Any] | None = None,
338
+ ) -> dict[str, Any]:
339
+ """Link an app user account to a WhatsApp/SMS phone number."""
340
+ return _as_dict(
341
+ self.integration_request(
342
+ "POST",
343
+ f"/{_path_segment(project_id)}/link-user",
344
+ json={
345
+ "phone_e164": phone,
346
+ "provider_user_id": app_user_id,
347
+ "provider_name": provider_name,
348
+ "metadata": dict(metadata or {}),
349
+ },
350
+ )
351
+ )
352
+
353
+ def resolve_user(self, project_id: str, phone: str) -> dict[str, Any]:
354
+ """Resolve a linked phone number to the external app user ID."""
355
+ return _as_dict(
356
+ self.integration_request(
357
+ "GET",
358
+ f"/{_path_segment(project_id)}/resolve/{_path_segment(phone)}",
359
+ )
360
+ )
361
+
362
+ def unlink_user(self, project_id: str, phone: str) -> dict[str, Any]:
363
+ """Remove a phone-to-app identity link."""
364
+ return _as_dict(
365
+ self.integration_request(
366
+ "DELETE",
367
+ f"/{_path_segment(project_id)}/unlink/{_path_segment(phone)}",
368
+ )
369
+ )
370
+
371
+ def register_openapi_tools(
372
+ self,
373
+ project_id: str,
374
+ spec_url: str,
375
+ *,
376
+ include_paths: list[str] | None = None,
377
+ exclude_paths: list[str] | None = None,
378
+ target_count: int = 15,
379
+ auto_deploy: bool = True,
380
+ ) -> dict[str, Any]:
381
+ """Ask General Augment to parse an OpenAPI spec and register curated generated tools."""
382
+ return _as_dict(
383
+ self.admin_request(
384
+ "POST",
385
+ f"/projects/{_path_segment(project_id)}/tools/from-openapi",
386
+ json={
387
+ "spec_url": spec_url,
388
+ "include_paths": include_paths or [],
389
+ "exclude_paths": exclude_paths or [],
390
+ "target_count": target_count,
391
+ "auto_deploy": auto_deploy,
392
+ },
393
+ )
394
+ )
395
+
396
+ def _request(
397
+ self,
398
+ method: str,
399
+ path: str,
400
+ *,
401
+ json: Mapping[str, Any] | None = None,
402
+ params: Mapping[str, Any] | None = None,
403
+ headers: Mapping[str, str] | None = None,
404
+ auth: str = "admin",
405
+ ) -> Any:
406
+ """Execute a raw request against the General Augment API."""
407
+ try:
408
+ response = self._client.request(
409
+ method,
410
+ f"{self.base_url}{path}",
411
+ headers=self._headers(headers, auth=auth),
412
+ json=json,
413
+ params=params,
414
+ )
415
+ except (httpx.ConnectError, httpx.TimeoutException, httpx.RequestError) as exc:
416
+ raise _api_error_from_transport(exc) from exc
417
+ if response.is_error:
418
+ raise _api_error_from_response(response)
419
+ if response.status_code == 204:
420
+ return None
421
+ return _success_body(response)
422
+
423
+ def _headers(self, extra: Mapping[str, str] | None = None, *, auth: str) -> dict[str, str]:
424
+ """Build request headers for admin or project-key app calls."""
425
+ headers = {"Content-Type": "application/json"}
426
+ if auth == "bearer":
427
+ headers[BEARER_AUTH_HEADER] = f"Bearer {self.api_key}"
428
+ else:
429
+ headers[ADMIN_API_KEY_HEADER] = self.api_key
430
+ headers.update(dict(extra or {}))
431
+ return headers
432
+
433
+
434
+ __all__ = [
435
+ "GeneralAugmentAPIError",
436
+ "GeneralAugmentClient",
437
+ "response_output_text",
438
+ "response_structured_output",
439
+ ]
440
+
441
+
442
+ def response_output_text(response: Mapping[str, Any]) -> str:
443
+ """Return concatenated assistant output text from a Responses object."""
444
+ output_text = response.get("output_text")
445
+ if isinstance(output_text, str):
446
+ return output_text
447
+ text_parts: list[str] = []
448
+ for part in _response_content_parts(response):
449
+ if not isinstance(part, Mapping):
450
+ continue
451
+ part_type = part.get("type")
452
+ if part_type in {"output_text", "text"} and isinstance(part.get("text"), str):
453
+ text_parts.append(part["text"])
454
+ return "".join(text_parts)
455
+
456
+
457
+ def response_structured_output(response: Mapping[str, Any]) -> Any:
458
+ """Return parsed structured output from a Responses object.
459
+
460
+ The API may expose parsed structured output directly on a content part or as
461
+ JSON text. This helper keeps app code from hand-walking the Responses shape.
462
+ """
463
+ if "output_parsed" in response:
464
+ return response["output_parsed"]
465
+ for part in _response_content_parts(response):
466
+ if isinstance(part, Mapping) and "parsed" in part:
467
+ return part["parsed"]
468
+ text = response_output_text(response).strip()
469
+ if not text:
470
+ raise ValueError("Response output text is empty; no structured JSON to parse.")
471
+ try:
472
+ return json_module.loads(text)
473
+ except json_module.JSONDecodeError as exc:
474
+ raise ValueError("Response output text is not valid JSON.") from exc
475
+
476
+
477
+ def _as_dict(payload: Any) -> dict[str, Any]:
478
+ """Return a JSON object payload or fail with a useful SDK error."""
479
+ if isinstance(payload, dict):
480
+ return payload
481
+ raise TypeError(f"Expected General Augment API object response, got {type(payload).__name__}")
482
+
483
+
484
+ def _response_content_parts(response: Mapping[str, Any]) -> Iterator[Any]:
485
+ """Yield content parts from a Responses object."""
486
+ output = response.get("output")
487
+ if not isinstance(output, list):
488
+ return
489
+ for item in output:
490
+ if not isinstance(item, Mapping):
491
+ continue
492
+ content = item.get("content")
493
+ if not isinstance(content, list):
494
+ continue
495
+ yield from content
496
+
497
+
498
+ def _path_segment(value: str) -> str:
499
+ """Encode one URL path segment safely."""
500
+ return quote(value, safe="")
501
+
502
+
503
+ def _defined_params(params: Mapping[str, Any]) -> dict[str, Any]:
504
+ """Return query params with omitted optional values removed."""
505
+ return {key: value for key, value in params.items() if value is not None}
506
+
507
+
508
+ def _api_error_from_response(response: httpx.Response) -> GeneralAugmentAPIError:
509
+ """Build a rich SDK exception from a General Augment error response."""
510
+ body = _response_body(response)
511
+ return GeneralAugmentAPIError(
512
+ response.status_code,
513
+ _error_detail(body, response.text),
514
+ code=_error_code(body),
515
+ reason=_error_reason(body),
516
+ request_id=response.headers.get("X-Request-ID") or _error_string(body, "request_id"),
517
+ trace_id=(
518
+ response.headers.get("X-Trace-ID")
519
+ or response.headers.get("X-Trace-Id")
520
+ or _error_string(body, "trace_id")
521
+ ),
522
+ retry_after=response.headers.get("Retry-After") or _error_retry_after(body),
523
+ rate_limit=_rate_limit_headers(response.headers),
524
+ body=body,
525
+ )
526
+
527
+
528
+ def _api_error_from_transport(exc: httpx.HTTPError) -> GeneralAugmentAPIError:
529
+ """Build a typed SDK exception for transport-level API failures."""
530
+ if isinstance(exc, httpx.TimeoutException):
531
+ detail = "General Augment API request timed out."
532
+ reason = "request_timeout"
533
+ elif isinstance(exc, httpx.ConnectError):
534
+ detail = "General Augment API could not be reached."
535
+ reason = "connection_failed"
536
+ else:
537
+ detail = "General Augment API request failed."
538
+ reason = "request_failed"
539
+ return GeneralAugmentAPIError(
540
+ 0,
541
+ detail,
542
+ reason=reason,
543
+ body=None,
544
+ )
545
+
546
+
547
+ def _success_body(response: httpx.Response) -> Any:
548
+ """Decode a successful API response or raise a typed SDK parse error."""
549
+ try:
550
+ return response.json()
551
+ except ValueError as exc:
552
+ raise GeneralAugmentAPIError(
553
+ response.status_code,
554
+ "General Augment API returned malformed JSON.",
555
+ reason="malformed_json",
556
+ request_id=response.headers.get("X-Request-ID"),
557
+ trace_id=response.headers.get("X-Trace-ID") or response.headers.get("X-Trace-Id"),
558
+ body=response.text,
559
+ ) from exc
560
+
561
+
562
+ def _response_body(response: httpx.Response) -> Any:
563
+ """Decode an error response body when it is JSON."""
564
+ try:
565
+ return response.json()
566
+ except ValueError:
567
+ return None
568
+
569
+
570
+ def _error_detail(body: Any, fallback: str) -> str:
571
+ """Extract a compact error detail from a decoded response body."""
572
+ if isinstance(body, dict):
573
+ detail = body.get("detail")
574
+ if isinstance(detail, str):
575
+ return detail
576
+ if detail is not None:
577
+ return json_module.dumps(detail, sort_keys=True)
578
+ message = body.get("message")
579
+ if isinstance(message, str):
580
+ return message
581
+ return json_module.dumps(body, sort_keys=True)
582
+ if body is not None:
583
+ return str(body)
584
+ return fallback
585
+
586
+
587
+ def _error_reason(body: Any) -> str | None:
588
+ """Return the stable reason/code field from an API error body when present."""
589
+ error = _structured_error(body)
590
+ if error is not None:
591
+ reason = error.get("reason")
592
+ if isinstance(reason, str):
593
+ return reason
594
+ code = error.get("code")
595
+ if isinstance(code, str):
596
+ return code
597
+ if not isinstance(body, dict):
598
+ return None
599
+ for key in ("reason", "code", "error"):
600
+ value = body.get(key)
601
+ if isinstance(value, str):
602
+ return value
603
+ return None
604
+
605
+
606
+ def _error_code(body: Any) -> str | None:
607
+ """Return a machine-readable API error code when present."""
608
+ error = _structured_error(body)
609
+ if error is not None:
610
+ code = error.get("code")
611
+ if isinstance(code, str):
612
+ return code
613
+ if isinstance(body, dict):
614
+ code = body.get("code")
615
+ if isinstance(code, str):
616
+ return code
617
+ return None
618
+
619
+
620
+ def _error_string(body: Any, key: str) -> str | None:
621
+ """Return one string field from structured or flat API error bodies."""
622
+ error = _structured_error(body)
623
+ if error is not None:
624
+ value = error.get(key)
625
+ if isinstance(value, str):
626
+ return value
627
+ if isinstance(body, dict):
628
+ value = body.get(key)
629
+ if isinstance(value, str):
630
+ return value
631
+ return None
632
+
633
+
634
+ def _error_retry_after(body: Any) -> str | None:
635
+ """Return retry-after seconds from structured error JSON when present."""
636
+ for key in ("retry_after", "retry_after_seconds"):
637
+ value = _error_string(body, key)
638
+ if value is not None:
639
+ return value
640
+ error = _structured_error(body)
641
+ if error is not None and isinstance(error.get(key), (int, float)):
642
+ return str(error[key])
643
+ if isinstance(body, dict) and isinstance(body.get(key), (int, float)):
644
+ return str(body[key])
645
+ return None
646
+
647
+
648
+ def _structured_error(body: Any) -> dict[str, Any] | None:
649
+ """Return the nested or flat structured API error object when present."""
650
+ if not isinstance(body, dict):
651
+ return None
652
+ detail = body.get("detail")
653
+ if isinstance(detail, dict):
654
+ return detail
655
+ error = body.get("error")
656
+ if isinstance(error, dict):
657
+ return error
658
+ if any(isinstance(body.get(key), str) for key in ("code", "reason", "message")):
659
+ return body
660
+ return None
661
+
662
+
663
+ def _rate_limit_headers(headers: httpx.Headers) -> dict[str, str]:
664
+ """Return rate-limit headers using stable snake_case keys."""
665
+ mapping = {
666
+ "limit": "X-RateLimit-Limit",
667
+ "remaining": "X-RateLimit-Remaining",
668
+ "reset": "X-RateLimit-Reset",
669
+ "policy": "X-RateLimit-Policy",
670
+ }
671
+ return {key: value for key, header in mapping.items() if (value := headers.get(header))}
672
+
673
+
674
+ def _response_headers(
675
+ *,
676
+ idempotency_key: str | None,
677
+ request_id: str | None,
678
+ traceparent: str | None,
679
+ tracestate: str | None,
680
+ ) -> dict[str, str]:
681
+ """Build optional Responses correlation headers."""
682
+ headers: dict[str, str] = {}
683
+ if idempotency_key:
684
+ headers["X-Idempotency-Key"] = idempotency_key
685
+ if request_id:
686
+ headers["X-Request-ID"] = request_id
687
+ if traceparent:
688
+ headers["traceparent"] = traceparent
689
+ if tracestate:
690
+ headers["tracestate"] = tracestate
691
+ return headers
692
+
693
+
694
+ def _iter_sse_events(lines: Iterator[str]) -> Iterator[dict[str, Any]]:
695
+ """Parse semantic SSE events from an iterator of text lines."""
696
+ event = "message"
697
+ data_lines: list[str] = []
698
+ for line in lines:
699
+ if line == "":
700
+ parsed = _sse_event(event, data_lines)
701
+ if parsed is not None:
702
+ yield parsed
703
+ event = "message"
704
+ data_lines = []
705
+ continue
706
+ if line.startswith(":"):
707
+ continue
708
+ if line.startswith("event:"):
709
+ event = line.removeprefix("event:").strip()
710
+ elif line.startswith("data:"):
711
+ data_lines.append(line.removeprefix("data:").lstrip())
712
+ parsed = _sse_event(event, data_lines)
713
+ if parsed is not None:
714
+ yield parsed
715
+
716
+
717
+ def _sse_event(event: str, data_lines: list[str]) -> dict[str, Any] | None:
718
+ """Return one parsed SSE event or None for empty blocks."""
719
+ if not data_lines:
720
+ return None
721
+ data = "\n".join(data_lines)
722
+ try:
723
+ parsed_data: Any = json_module.loads(data)
724
+ except json_module.JSONDecodeError:
725
+ parsed_data = data
726
+ return {"event": event, "data": parsed_data}
genaug/identity.py ADDED
@@ -0,0 +1,37 @@
1
+ """Identity-linking helpers for app backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ from genaug.client import GeneralAugmentClient
9
+
10
+
11
+ def link_user(
12
+ client: GeneralAugmentClient,
13
+ project_id: str,
14
+ *,
15
+ phone: str,
16
+ app_user_id: str,
17
+ provider_name: str = "app",
18
+ metadata: Mapping[str, Any] | None = None,
19
+ ) -> dict[str, Any]:
20
+ """Link a WhatsApp/SMS phone number to the app's user ID."""
21
+ return client.link_user(
22
+ project_id,
23
+ phone=phone,
24
+ app_user_id=app_user_id,
25
+ provider_name=provider_name,
26
+ metadata=metadata,
27
+ )
28
+
29
+
30
+ def resolve_user(client: GeneralAugmentClient, project_id: str, *, phone: str) -> dict[str, Any]:
31
+ """Resolve a linked phone number to app identity metadata."""
32
+ return client.resolve_user(project_id, phone)
33
+
34
+
35
+ def unlink_user(client: GeneralAugmentClient, project_id: str, *, phone: str) -> dict[str, Any]:
36
+ """Remove a phone-to-app identity link."""
37
+ return client.unlink_user(project_id, phone)
genaug/tools.py ADDED
@@ -0,0 +1,26 @@
1
+ """Tool registration helpers for General Augment integrations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from genaug.client import GeneralAugmentClient
6
+
7
+
8
+ def register_from_openapi(
9
+ spec_url: str,
10
+ *,
11
+ client: GeneralAugmentClient,
12
+ project_id: str,
13
+ include_paths: list[str] | None = None,
14
+ exclude_paths: list[str] | None = None,
15
+ target_count: int = 15,
16
+ auto_deploy: bool = True,
17
+ ) -> dict[str, object]:
18
+ """Generate and register curated MCP tools from an OpenAPI specification."""
19
+ return client.register_openapi_tools(
20
+ project_id,
21
+ spec_url,
22
+ include_paths=include_paths,
23
+ exclude_paths=exclude_paths,
24
+ target_count=target_count,
25
+ auto_deploy=auto_deploy,
26
+ )
@@ -0,0 +1,165 @@
1
+ Metadata-Version: 2.4
2
+ Name: general-augment-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the General Augment admin and integration APIs.
5
+ Project-URL: Documentation, https://docs.generalaugment.com
6
+ Project-URL: Source, https://github.com/Bikz/general-augment-platform
7
+ Author: General Augment
8
+ License-Expression: MIT
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: httpx>=0.28.0
11
+ Requires-Dist: pyyaml>=6.0.0
12
+ Description-Content-Type: text/markdown
13
+
14
+ # General Augment Python SDK
15
+
16
+ Backend SDK for General Augment app integrations. Use it from trusted server code.
17
+ Project-scoped keys are for app traffic such as Responses and memory calls. Admin and
18
+ setup helpers require a management/admin-capable key and send it as `X-Admin-Key`.
19
+
20
+ During private beta, package publishing may not be available in every package index. If
21
+ installing the package fails, use the repository package path for local tests or raw HTTP
22
+ examples in the public docs until `scripts/package-registry-readiness.py` reports the
23
+ expected package version as published.
24
+
25
+ ```bash
26
+ pip install general-augment-sdk
27
+ ```
28
+
29
+ ```python
30
+ import os
31
+
32
+ from genaug import (
33
+ GeneralAugmentClient,
34
+ __version__,
35
+ response_output_text,
36
+ response_structured_output,
37
+ )
38
+
39
+ client = GeneralAugmentClient(
40
+ api_key=os.environ["GENAUG_API_KEY"],
41
+ base_url=os.getenv("GENAUG_API_BASE_URL", "https://api.generalaugment.com"),
42
+ )
43
+
44
+ print(f"General Augment SDK {__version__}")
45
+ ```
46
+
47
+ ## Responses
48
+
49
+ ```python
50
+ response = client.create_response(
51
+ {
52
+ "model": "balanced",
53
+ "user": "app-user-123",
54
+ "input": "Reply with a concise onboarding summary.",
55
+ "metadata": {"feature": "onboarding"},
56
+ },
57
+ idempotency_key="onboarding-turn-1",
58
+ request_id="req_app_123",
59
+ )
60
+
61
+ print(response_output_text(response))
62
+ ```
63
+
64
+ Structured output:
65
+
66
+ ```python
67
+ structured_response = client.create_response(
68
+ {
69
+ "model": "balanced",
70
+ "user": "app-user-123",
71
+ "input": "Extract the user's preference: window seat.",
72
+ "text": {
73
+ "format": {
74
+ "type": "json_schema",
75
+ "name": "preference",
76
+ "strict": True,
77
+ "schema": {
78
+ "type": "object",
79
+ "required": ["seat"],
80
+ "properties": {"seat": {"type": "string"}},
81
+ "additionalProperties": False,
82
+ },
83
+ }
84
+ },
85
+ }
86
+ )
87
+
88
+ preference = response_structured_output(structured_response)
89
+ ```
90
+
91
+ Streaming:
92
+
93
+ ```python
94
+ for event in client.stream_response(
95
+ {
96
+ "model": "balanced",
97
+ "user": "app-user-123",
98
+ "input": "Draft a two sentence welcome message.",
99
+ }
100
+ ):
101
+ if event["event"] == "response.output_text.delta":
102
+ print(event["data"].get("delta", ""), end="")
103
+ ```
104
+
105
+ ## Memory
106
+
107
+ ```python
108
+ stored = client.store_memory(
109
+ {
110
+ "user_id": "app-user-123",
111
+ "fact": "User prefers window seats",
112
+ "fact_type": "preference",
113
+ "importance_score": 0.9,
114
+ "idempotency_key": "memory-window-seat-1",
115
+ }
116
+ )
117
+
118
+ client.search_memory({"user_id": "app-user-123", "query": "seat preference"})
119
+ client.memory_profile("app-user-123")
120
+ client.delete_memory(str(stored["memory_id"]), user_id="app-user-123")
121
+ client.purge_user_memory("app-user-123")
122
+ ```
123
+
124
+ ## Usage
125
+
126
+ ```python
127
+ usage = client.usage("project_123", start_date="2026-04-01", end_date="2026-04-24")
128
+ print(usage["totals"])
129
+ ```
130
+
131
+ ## Error Handling
132
+
133
+ ```python
134
+ from genaug import GeneralAugmentAPIError
135
+
136
+ try:
137
+ client.create_response({"model": "balanced", "input": "Hello"})
138
+ except GeneralAugmentAPIError as exc:
139
+ if exc.reason == "rate_limit_exceeded":
140
+ print(f"Retry after {exc.retry_after} seconds")
141
+ print(exc.request_id, exc.trace_id, exc.detail)
142
+ ```
143
+
144
+ `GeneralAugmentAPIError` preserves the HTTP status, stable `reason`/`code` when the
145
+ API returns one, `Retry-After`, `X-RateLimit-*`, request/trace IDs, and the decoded JSON
146
+ body. Existing code that only reads `status_code` or `detail` keeps working.
147
+
148
+ ## Local Tests
149
+
150
+ Run the local mock server and point the SDK at it:
151
+
152
+ ```bash
153
+ uv run --project packages/cli genaug mock --host 127.0.0.1 --port 8787 --quiet
154
+ export GENAUG_API_BASE_URL="http://127.0.0.1:8787"
155
+ export GENAUG_API_KEY="local-test"
156
+ PYTHONPATH=src python examples/contract_test.py
157
+ ```
158
+
159
+ The contract example covers a Responses turn plus memory store/search against the same
160
+ deterministic routes used by app backend CI.
161
+
162
+ ## Other Helpers
163
+
164
+ The SDK also includes `create_project_from_config`, `register_openapi_tools`,
165
+ `link_user`, `usage`, and `test_agent` for admin and integration workflows.
@@ -0,0 +1,8 @@
1
+ genaug/__init__.py,sha256=drXrvkEckJh5MSPAZwiKcrrG6CAjffj8YKRM1wC-q3s,429
2
+ genaug/agent.py,sha256=A7UDEpMePNRiMUJk-McIiZutGswT8vu82yHYhQ7okb8,1221
3
+ genaug/client.py,sha256=DUIX9g18UCM9XIEU27pxFV6o04R-CAp7GghRAovGFlU,24612
4
+ genaug/identity.py,sha256=cNoRVLJbIAJ6JjtJj-90YPbg9OT0EQShg4MdjXmQ5SE,1059
5
+ genaug/tools.py,sha256=-qMKrw8wJFaPbKhiLKVPxrYx170Yvludj8LtebazZto,739
6
+ general_augment_sdk-0.1.0.dist-info/METADATA,sha256=k633KncJVPdeiFoCphslS_ijo16l-esU0tRZK0V714w,4605
7
+ general_augment_sdk-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
+ general_augment_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any