praxicraft-shared 0.6.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.
Files changed (135) hide show
  1. praxicraft_shared-0.6.0/PKG-INFO +82 -0
  2. praxicraft_shared-0.6.0/README.md +66 -0
  3. praxicraft_shared-0.6.0/pyproject.toml +46 -0
  4. praxicraft_shared-0.6.0/setup.cfg +4 -0
  5. praxicraft_shared-0.6.0/src/praxicraft_shared/__init__.py +3 -0
  6. praxicraft_shared-0.6.0/src/praxicraft_shared/ai_service.py +478 -0
  7. praxicraft_shared-0.6.0/src/praxicraft_shared/auth_client.py +388 -0
  8. praxicraft_shared-0.6.0/src/praxicraft_shared/billing_client.py +411 -0
  9. praxicraft_shared-0.6.0/src/praxicraft_shared/email/2fa-disabled.html +29 -0
  10. praxicraft_shared-0.6.0/src/praxicraft_shared/email/2fa-enabled.html +29 -0
  11. praxicraft_shared-0.6.0/src/praxicraft_shared/email/__init__.py +72 -0
  12. praxicraft_shared-0.6.0/src/praxicraft_shared/email/account-deletion-code.html +27 -0
  13. praxicraft_shared-0.6.0/src/praxicraft_shared/email/account-locked.html +37 -0
  14. praxicraft_shared-0.6.0/src/praxicraft_shared/email/account_locked.html +37 -0
  15. praxicraft_shared-0.6.0/src/praxicraft_shared/email/admin_invitation.html +32 -0
  16. praxicraft_shared-0.6.0/src/praxicraft_shared/email/admin_login_notify_internal.html +29 -0
  17. praxicraft_shared-0.6.0/src/praxicraft_shared/email/admin_staff_granted.html +27 -0
  18. praxicraft_shared-0.6.0/src/praxicraft_shared/email/anniversary.html +33 -0
  19. praxicraft_shared-0.6.0/src/praxicraft_shared/email/api_key_expiring_soon.html +23 -0
  20. praxicraft_shared-0.6.0/src/praxicraft_shared/email/api_key_reveal_code.html +26 -0
  21. praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_application_advanced.html +49 -0
  22. praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_application_received.html +36 -0
  23. praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_application_rejection.html +28 -0
  24. praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_completed_candidate.html +22 -0
  25. praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_custom_body.html +16 -0
  26. praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_interview_invite.html +48 -0
  27. praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_invitation.html +41 -0
  28. praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_needs_grading.html +25 -0
  29. praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_offer_reminder.html +36 -0
  30. praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_offer_sent.html +47 -0
  31. praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_offer_withdrawn.html +31 -0
  32. praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_org_welcome.html +31 -0
  33. praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_reminder.html +35 -0
  34. praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_results_ready.html +32 -0
  35. praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_stage_update.html +41 -0
  36. praxicraft_shared-0.6.0/src/praxicraft_shared/email/base.html +351 -0
  37. praxicraft_shared-0.6.0/src/praxicraft_shared/email/base.txt +4 -0
  38. praxicraft_shared-0.6.0/src/praxicraft_shared/email/beta_engineer_invitation.html +29 -0
  39. praxicraft_shared-0.6.0/src/praxicraft_shared/email/beta_organisation_invitation.html +32 -0
  40. praxicraft_shared-0.6.0/src/praxicraft_shared/email/billing_cancel_scheduled.html +32 -0
  41. praxicraft_shared-0.6.0/src/praxicraft_shared/email/billing_payment_failed.html +42 -0
  42. praxicraft_shared-0.6.0/src/praxicraft_shared/email/billing_payment_notify_internal.html +38 -0
  43. praxicraft_shared-0.6.0/src/praxicraft_shared/email/billing_payment_succeeded.html +45 -0
  44. praxicraft_shared-0.6.0/src/praxicraft_shared/email/billing_plan_changed.html +45 -0
  45. praxicraft_shared-0.6.0/src/praxicraft_shared/email/billing_refund_completed.html +29 -0
  46. praxicraft_shared-0.6.0/src/praxicraft_shared/email/billing_refund_initiated.html +33 -0
  47. praxicraft_shared-0.6.0/src/praxicraft_shared/email/billing_renewal_reminder.html +66 -0
  48. praxicraft_shared-0.6.0/src/praxicraft_shared/email/billing_subscription_ended.html +32 -0
  49. praxicraft_shared-0.6.0/src/praxicraft_shared/email/billing_subscription_resumed.html +28 -0
  50. praxicraft_shared-0.6.0/src/praxicraft_shared/email/billing_subscription_started.html +74 -0
  51. praxicraft_shared-0.6.0/src/praxicraft_shared/email/contract.html +28 -0
  52. praxicraft_shared-0.6.0/src/praxicraft_shared/email/dm_digest.html +30 -0
  53. praxicraft_shared-0.6.0/src/praxicraft_shared/email/email_verified.html +29 -0
  54. praxicraft_shared-0.6.0/src/praxicraft_shared/email/email_verify_code.html +26 -0
  55. praxicraft_shared-0.6.0/src/praxicraft_shared/email/employment_confirmation.html +27 -0
  56. praxicraft_shared-0.6.0/src/praxicraft_shared/email/feedback-request.html +38 -0
  57. praxicraft_shared-0.6.0/src/praxicraft_shared/email/feedback-thanks.html +35 -0
  58. praxicraft_shared-0.6.0/src/praxicraft_shared/email/inactivity.html +35 -0
  59. praxicraft_shared-0.6.0/src/praxicraft_shared/email/interview_cancellation.html +20 -0
  60. praxicraft_shared-0.6.0/src/praxicraft_shared/email/interview_cancellation.txt +7 -0
  61. praxicraft_shared-0.6.0/src/praxicraft_shared/email/interview_invitation.html +25 -0
  62. praxicraft_shared-0.6.0/src/praxicraft_shared/email/interview_invitation.txt +10 -0
  63. praxicraft_shared-0.6.0/src/praxicraft_shared/email/interview_no_show_recruiter.html +21 -0
  64. praxicraft_shared-0.6.0/src/praxicraft_shared/email/interview_no_show_recruiter.txt +5 -0
  65. praxicraft_shared-0.6.0/src/praxicraft_shared/email/interview_reminder.html +23 -0
  66. praxicraft_shared-0.6.0/src/praxicraft_shared/email/interview_reminder.txt +9 -0
  67. praxicraft_shared-0.6.0/src/praxicraft_shared/email/interview_report_ready.html +17 -0
  68. praxicraft_shared-0.6.0/src/praxicraft_shared/email/interview_report_ready.txt +7 -0
  69. praxicraft_shared-0.6.0/src/praxicraft_shared/email/interview_reschedule.html +23 -0
  70. praxicraft_shared-0.6.0/src/praxicraft_shared/email/interview_reschedule.txt +11 -0
  71. praxicraft_shared-0.6.0/src/praxicraft_shared/email/member_joined_notification.html +23 -0
  72. praxicraft_shared-0.6.0/src/praxicraft_shared/email/mentorship_declined.html +16 -0
  73. praxicraft_shared-0.6.0/src/praxicraft_shared/email/mentorship_expired.html +16 -0
  74. praxicraft_shared-0.6.0/src/praxicraft_shared/email/mentorship_matched.html +29 -0
  75. praxicraft_shared-0.6.0/src/praxicraft_shared/email/oauth_hint.html +15 -0
  76. praxicraft_shared-0.6.0/src/praxicraft_shared/email/onboarding_incomplete.html +34 -0
  77. praxicraft_shared-0.6.0/src/praxicraft_shared/email/org_deletion_verification.html +30 -0
  78. praxicraft_shared-0.6.0/src/praxicraft_shared/email/organisation_invitation.html +29 -0
  79. praxicraft_shared-0.6.0/src/praxicraft_shared/email/pair_invite.html +42 -0
  80. praxicraft_shared-0.6.0/src/praxicraft_shared/email/partials/_cta.html +10 -0
  81. praxicraft_shared-0.6.0/src/praxicraft_shared/email/partials/_detail_row.html +35 -0
  82. praxicraft_shared-0.6.0/src/praxicraft_shared/email/partials/_footer_banner.html +40 -0
  83. praxicraft_shared-0.6.0/src/praxicraft_shared/email/partials/_hero.html +48 -0
  84. praxicraft_shared-0.6.0/src/praxicraft_shared/email/partials/_settings_prefs_note.html +14 -0
  85. praxicraft_shared-0.6.0/src/praxicraft_shared/email/partials/_stat_tile.html +19 -0
  86. praxicraft_shared-0.6.0/src/praxicraft_shared/email/password-changed.html +19 -0
  87. praxicraft_shared-0.6.0/src/praxicraft_shared/email/password_changed.html +19 -0
  88. praxicraft_shared-0.6.0/src/praxicraft_shared/email/password_reset.html +18 -0
  89. praxicraft_shared-0.6.0/src/praxicraft_shared/email/payslip.html +16 -0
  90. praxicraft_shared-0.6.0/src/praxicraft_shared/email/promotion.html +33 -0
  91. praxicraft_shared-0.6.0/src/praxicraft_shared/email/reengagement.html +34 -0
  92. praxicraft_shared-0.6.0/src/praxicraft_shared/email/reset-password.html +18 -0
  93. praxicraft_shared-0.6.0/src/praxicraft_shared/email/security_alert.html +19 -0
  94. praxicraft_shared-0.6.0/src/praxicraft_shared/email/sprint_completed.html +31 -0
  95. praxicraft_shared-0.6.0/src/praxicraft_shared/email/step-up-code.html +26 -0
  96. praxicraft_shared-0.6.0/src/praxicraft_shared/email/support_inactivity_warning.html +29 -0
  97. praxicraft_shared-0.6.0/src/praxicraft_shared/email/support_reply.html +41 -0
  98. praxicraft_shared-0.6.0/src/praxicraft_shared/email/support_status_update.html +36 -0
  99. praxicraft_shared-0.6.0/src/praxicraft_shared/email/suspicious-login.html +36 -0
  100. praxicraft_shared-0.6.0/src/praxicraft_shared/email/suspicious_login.html +36 -0
  101. praxicraft_shared-0.6.0/src/praxicraft_shared/email/team_invite.html +25 -0
  102. praxicraft_shared-0.6.0/src/praxicraft_shared/email/team_join_request.html +25 -0
  103. praxicraft_shared-0.6.0/src/praxicraft_shared/email/track_launch.html +25 -0
  104. praxicraft_shared-0.6.0/src/praxicraft_shared/email/verify-email.html +26 -0
  105. praxicraft_shared-0.6.0/src/praxicraft_shared/email/waitlist-confirmation.html +23 -0
  106. praxicraft_shared-0.6.0/src/praxicraft_shared/email/waitlist-launch.html +39 -0
  107. praxicraft_shared-0.6.0/src/praxicraft_shared/email/webhook_endpoint_unreachable.html +25 -0
  108. praxicraft_shared-0.6.0/src/praxicraft_shared/email/weekly_digest.html +38 -0
  109. praxicraft_shared-0.6.0/src/praxicraft_shared/email/welcome.html +26 -0
  110. praxicraft_shared-0.6.0/src/praxicraft_shared/email/welcome_account.html +26 -0
  111. praxicraft_shared-0.6.0/src/praxicraft_shared/email/welcome_hr.html +54 -0
  112. praxicraft_shared-0.6.0/src/praxicraft_shared/email/welcome_self_paced.html +18 -0
  113. praxicraft_shared-0.6.0/src/praxicraft_shared/email/year_wrap.html +47 -0
  114. praxicraft_shared-0.6.0/src/praxicraft_shared/error_envelope.py +34 -0
  115. praxicraft_shared-0.6.0/src/praxicraft_shared/events/SCHEMA.md +55 -0
  116. praxicraft_shared-0.6.0/src/praxicraft_shared/events/__init__.py +99 -0
  117. praxicraft_shared-0.6.0/src/praxicraft_shared/hibp.py +73 -0
  118. praxicraft_shared-0.6.0/src/praxicraft_shared/jwt_utils.py +151 -0
  119. praxicraft_shared-0.6.0/src/praxicraft_shared/notifications_client.py +140 -0
  120. praxicraft_shared-0.6.0/src/praxicraft_shared/s3_client.py +173 -0
  121. praxicraft_shared-0.6.0/src/praxicraft_shared/secret_crypto.py +73 -0
  122. praxicraft_shared-0.6.0/src/praxicraft_shared/service_token.py +101 -0
  123. praxicraft_shared-0.6.0/src/praxicraft_shared/work_email.py +68 -0
  124. praxicraft_shared-0.6.0/src/praxicraft_shared.egg-info/PKG-INFO +82 -0
  125. praxicraft_shared-0.6.0/src/praxicraft_shared.egg-info/SOURCES.txt +133 -0
  126. praxicraft_shared-0.6.0/src/praxicraft_shared.egg-info/dependency_links.txt +1 -0
  127. praxicraft_shared-0.6.0/src/praxicraft_shared.egg-info/requires.txt +8 -0
  128. praxicraft_shared-0.6.0/src/praxicraft_shared.egg-info/top_level.txt +1 -0
  129. praxicraft_shared-0.6.0/tests/test_ai_service.py +215 -0
  130. praxicraft_shared-0.6.0/tests/test_billing_client.py +87 -0
  131. praxicraft_shared-0.6.0/tests/test_notifications_service_token.py +96 -0
  132. praxicraft_shared-0.6.0/tests/test_s1.py +106 -0
  133. praxicraft_shared-0.6.0/tests/test_s2_jwt_email.py +151 -0
  134. praxicraft_shared-0.6.0/tests/test_s3_auth_client.py +101 -0
  135. praxicraft_shared-0.6.0/tests/test_s3_client.py +82 -0
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: praxicraft-shared
3
+ Version: 0.6.0
4
+ Summary: Stateless cross-product Python utilities for Praxicraft backends
5
+ Author: Praxicraft
6
+ License: Proprietary
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: cryptography>=42.0.0
10
+ Requires-Dist: requests>=2.32.0
11
+ Requires-Dist: PyJWT[crypto]>=2.8.0
12
+ Requires-Dist: boto3>=1.34.0
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=8.0; extra == "dev"
15
+ Requires-Dist: ruff>=0.6; extra == "dev"
16
+
17
+ # praxicraft-shared
18
+
19
+ Stateless Python utilities shared by Praxicraft backends (Practice, auth, notifications, and future services). Products pin a **git tag**; no Django ORM, settings, or product business logic lives here.
20
+
21
+ **Planning & milestones:** [`Gamified-Application/docs/praxicraft-shared/README.md`](https://github.com/praxicraft-platform/praxicraft-practice/blob/staging/docs/praxicraft-shared/README.md)
22
+
23
+ ## Prerequisites
24
+
25
+ - Python 3.11+
26
+ - GitHub access to this private repo (install via HTTPS + token)
27
+
28
+ ## Install (in a product)
29
+
30
+ ```text
31
+ praxicraft-shared @ git+https://github.com/praxicraft-platform/praxicraft-shared@v0.6.0
32
+ ```
33
+
34
+ Builds need a token with `contents:read` (CI: `PRAXICRAFT_GH_TOKEN`).
35
+
36
+ Local editable (sibling checkout):
37
+
38
+ ```bash
39
+ pip install -e ~/Desktop/praxicraft-shared
40
+ ```
41
+
42
+ ## Local development
43
+
44
+ ```bash
45
+ cd ~/Desktop/praxicraft-shared
46
+ pip install -e ".[dev]"
47
+ ruff check src tests
48
+ pytest
49
+ ```
50
+
51
+ ## What’s in the package
52
+
53
+ | Module | Role |
54
+ | --- | --- |
55
+ | `jwt_utils` | Validate RS256 access JWTs (JWKS / PEM) |
56
+ | `auth_client` | HTTP client for `praxicraft-auth` (token exchange, me, …) |
57
+ | `billing_client` | HTTP client for `praxicraft-billing` (entitlements, checkout, cancel/resume) |
58
+ | `s3_client` | Injectable MinIO/S3 helpers (`make_s3_client`, put/get/presign, prefix download) |
59
+ | `notifications_client` | HTTP client for praxicraft-notifications internal send |
60
+ | `service_token` | Parse `product:token` / JSON service-token maps (no DRF) |
61
+ | `ai_service` | Thin client for `praxicraft-llm` (gateway + circuit-breaker fallback) |
62
+ | `error_envelope` | Standard success/error dict shapes |
63
+ | `events` | Event envelope helpers + `events/SCHEMA.md` |
64
+ | `email/` | Shared HTML/text email templates (rendered by notifications) |
65
+ | `secret_crypto` | Fernet encrypt/decrypt (inject key) |
66
+ | `work_email` | Email normalize / consumer-domain helpers |
67
+ | `hibp` | Have I Been Pwned password check |
68
+
69
+ Not shipped yet (planned): `org_client` (after auth A7).
70
+
71
+ ## Hard rules
72
+
73
+ 1. Stateless — no Django models, ORM, Celery, or package-owned Redis singletons
74
+ 2. No `django.conf.settings` — callers inject URLs and keys
75
+ 3. No product logic (XP, Assess scoring, Tutor mastery)
76
+ 4. Semver via git tags — breaking JWT/auth contracts = major bump
77
+
78
+ ## Related
79
+
80
+ - [praxicraft-auth](https://github.com/praxicraft-platform/praxicraft-auth)
81
+ - [praxicraft-notifications](https://github.com/praxicraft-platform/praxicraft-notifications)
82
+ - [praxicraft-llm](https://github.com/praxicraft-platform/praxicraft-llm)
@@ -0,0 +1,66 @@
1
+ # praxicraft-shared
2
+
3
+ Stateless Python utilities shared by Praxicraft backends (Practice, auth, notifications, and future services). Products pin a **git tag**; no Django ORM, settings, or product business logic lives here.
4
+
5
+ **Planning & milestones:** [`Gamified-Application/docs/praxicraft-shared/README.md`](https://github.com/praxicraft-platform/praxicraft-practice/blob/staging/docs/praxicraft-shared/README.md)
6
+
7
+ ## Prerequisites
8
+
9
+ - Python 3.11+
10
+ - GitHub access to this private repo (install via HTTPS + token)
11
+
12
+ ## Install (in a product)
13
+
14
+ ```text
15
+ praxicraft-shared @ git+https://github.com/praxicraft-platform/praxicraft-shared@v0.6.0
16
+ ```
17
+
18
+ Builds need a token with `contents:read` (CI: `PRAXICRAFT_GH_TOKEN`).
19
+
20
+ Local editable (sibling checkout):
21
+
22
+ ```bash
23
+ pip install -e ~/Desktop/praxicraft-shared
24
+ ```
25
+
26
+ ## Local development
27
+
28
+ ```bash
29
+ cd ~/Desktop/praxicraft-shared
30
+ pip install -e ".[dev]"
31
+ ruff check src tests
32
+ pytest
33
+ ```
34
+
35
+ ## What’s in the package
36
+
37
+ | Module | Role |
38
+ | --- | --- |
39
+ | `jwt_utils` | Validate RS256 access JWTs (JWKS / PEM) |
40
+ | `auth_client` | HTTP client for `praxicraft-auth` (token exchange, me, …) |
41
+ | `billing_client` | HTTP client for `praxicraft-billing` (entitlements, checkout, cancel/resume) |
42
+ | `s3_client` | Injectable MinIO/S3 helpers (`make_s3_client`, put/get/presign, prefix download) |
43
+ | `notifications_client` | HTTP client for praxicraft-notifications internal send |
44
+ | `service_token` | Parse `product:token` / JSON service-token maps (no DRF) |
45
+ | `ai_service` | Thin client for `praxicraft-llm` (gateway + circuit-breaker fallback) |
46
+ | `error_envelope` | Standard success/error dict shapes |
47
+ | `events` | Event envelope helpers + `events/SCHEMA.md` |
48
+ | `email/` | Shared HTML/text email templates (rendered by notifications) |
49
+ | `secret_crypto` | Fernet encrypt/decrypt (inject key) |
50
+ | `work_email` | Email normalize / consumer-domain helpers |
51
+ | `hibp` | Have I Been Pwned password check |
52
+
53
+ Not shipped yet (planned): `org_client` (after auth A7).
54
+
55
+ ## Hard rules
56
+
57
+ 1. Stateless — no Django models, ORM, Celery, or package-owned Redis singletons
58
+ 2. No `django.conf.settings` — callers inject URLs and keys
59
+ 3. No product logic (XP, Assess scoring, Tutor mastery)
60
+ 4. Semver via git tags — breaking JWT/auth contracts = major bump
61
+
62
+ ## Related
63
+
64
+ - [praxicraft-auth](https://github.com/praxicraft-platform/praxicraft-auth)
65
+ - [praxicraft-notifications](https://github.com/praxicraft-platform/praxicraft-notifications)
66
+ - [praxicraft-llm](https://github.com/praxicraft-platform/praxicraft-llm)
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "praxicraft-shared"
7
+ version = "0.6.0"
8
+ description = "Stateless cross-product Python utilities for Praxicraft backends"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "Proprietary" }
12
+ authors = [{ name = "Praxicraft" }]
13
+ dependencies = [
14
+ "cryptography>=42.0.0",
15
+ "requests>=2.32.0",
16
+ "PyJWT[crypto]>=2.8.0",
17
+ "boto3>=1.34.0",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ dev = [
22
+ "pytest>=8.0",
23
+ "ruff>=0.6",
24
+ ]
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
28
+
29
+ [tool.setuptools.package-data]
30
+ praxicraft_shared = [
31
+ "events/SCHEMA.md",
32
+ "email/*.html",
33
+ "email/*.txt",
34
+ "email/partials/*.html",
35
+ ]
36
+
37
+ [tool.pytest.ini_options]
38
+ testpaths = ["tests"]
39
+ pythonpath = ["src"]
40
+
41
+ [tool.ruff]
42
+ line-length = 100
43
+ target-version = "py311"
44
+
45
+ [tool.ruff.lint]
46
+ select = ["E", "F", "I", "UP"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Praxicraft shared utilities — stateless only. No Django/ORM."""
2
+
3
+ __version__ = "0.6.0"
@@ -0,0 +1,478 @@
1
+ """Thin LLM client for products (P5 / S-ai).
2
+
3
+ Gateway-first when ``llm_gateway_url`` is set (OpenAI-compatible LiteLLM).
4
+ On gateway failure, opens a circuit and optionally calls an injectable
5
+ ``direct_fallback`` so products keep working without a central SPOF.
6
+
7
+ Stateless: pass config; never import ``django.conf.settings``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ import time
15
+ from collections.abc import Callable, Iterator
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+ from urllib.parse import urljoin
19
+
20
+ import requests
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ DEFAULT_TIMEOUT = 120.0
25
+ CIRCUIT_FAILURE_THRESHOLD = 3
26
+ CIRCUIT_OPEN_SECONDS = 60.0
27
+
28
+ DirectSync = Callable[..., str]
29
+ DirectStream = Callable[..., Iterator[str]]
30
+ DirectAssistant = Callable[..., dict[str, Any]]
31
+
32
+
33
+ class AIDisabledError(Exception):
34
+ """AI features disabled or missing credentials."""
35
+
36
+
37
+ class AIProviderError(Exception):
38
+ """Gateway or provider call failed."""
39
+
40
+
41
+ @dataclass
42
+ class AIConfig:
43
+ """Injectable AI routing config (no Django settings)."""
44
+
45
+ enabled: bool = True
46
+ llm_gateway_url: str = ""
47
+ llm_service_key: str = ""
48
+ provider: str = "openai"
49
+ model: str = "gpt-4o-mini"
50
+ api_key: str = ""
51
+ product: str = "practice"
52
+ timeout: float = DEFAULT_TIMEOUT
53
+ # Optional Redis-like get/set for shared circuit state across workers.
54
+ # Signatures: get(key) -> str|None ; set(key, value, ex=seconds)
55
+ circuit_get: Callable[[str], str | None] | None = None
56
+ circuit_set: Callable[..., None] | None = None
57
+ circuit_key: str = "praxicraft:circuit:llm-gateway"
58
+
59
+
60
+ @dataclass
61
+ class _CircuitState:
62
+ failures: int = 0
63
+ opened_at: float = 0.0
64
+
65
+
66
+ _local_circuit = _CircuitState()
67
+
68
+
69
+ def _circuit_is_open(cfg: AIConfig) -> bool:
70
+ if cfg.circuit_get is not None:
71
+ raw = cfg.circuit_get(cfg.circuit_key)
72
+ if not raw:
73
+ return False
74
+ try:
75
+ opened_at = float(raw)
76
+ except ValueError:
77
+ return False
78
+ # Wall-clock: monotonic cannot be shared across workers via Redis.
79
+ return (time.time() - opened_at) < CIRCUIT_OPEN_SECONDS
80
+ if _local_circuit.opened_at <= 0:
81
+ return False
82
+ if (time.monotonic() - _local_circuit.opened_at) >= CIRCUIT_OPEN_SECONDS:
83
+ _local_circuit.failures = 0
84
+ _local_circuit.opened_at = 0.0
85
+ return False
86
+ return True
87
+
88
+
89
+ def _circuit_record_failure(cfg: AIConfig) -> None:
90
+ if cfg.circuit_get is not None and cfg.circuit_set is not None:
91
+ count_key = f"{cfg.circuit_key}:count"
92
+ raw = cfg.circuit_get(count_key) or "0"
93
+ try:
94
+ failures = int(raw) + 1
95
+ except ValueError:
96
+ failures = 1
97
+ cfg.circuit_set(count_key, str(failures), ex=int(CIRCUIT_OPEN_SECONDS))
98
+ if failures >= CIRCUIT_FAILURE_THRESHOLD:
99
+ cfg.circuit_set(
100
+ cfg.circuit_key,
101
+ str(time.time()),
102
+ ex=int(CIRCUIT_OPEN_SECONDS),
103
+ )
104
+ logger.warning("praxicraft-llm circuit opened (shared)")
105
+ return
106
+ _local_circuit.failures += 1
107
+ if _local_circuit.failures >= CIRCUIT_FAILURE_THRESHOLD:
108
+ _local_circuit.opened_at = time.monotonic()
109
+ logger.warning("praxicraft-llm circuit opened (local)")
110
+
111
+
112
+ def _circuit_record_success(cfg: AIConfig) -> None:
113
+ _local_circuit.failures = 0
114
+ _local_circuit.opened_at = 0.0
115
+ if cfg.circuit_set is not None:
116
+ try:
117
+ cfg.circuit_set(cfg.circuit_key, "", ex=1)
118
+ cfg.circuit_set(f"{cfg.circuit_key}:count", "0", ex=1)
119
+ except Exception:
120
+ pass
121
+
122
+
123
+ def _gateway_base(cfg: AIConfig) -> str:
124
+ return cfg.llm_gateway_url.rstrip("/")
125
+
126
+
127
+ def _gateway_headers(
128
+ cfg: AIConfig,
129
+ *,
130
+ product: str,
131
+ user_id: str,
132
+ task_type: str,
133
+ ) -> dict[str, str]:
134
+ headers = {
135
+ "Authorization": f"Bearer {cfg.llm_service_key}",
136
+ "Content-Type": "application/json",
137
+ "X-Praxicraft-Product": product or cfg.product or "practice",
138
+ "X-Praxicraft-Task-Type": task_type or "default",
139
+ }
140
+ if user_id:
141
+ headers["X-Praxicraft-User-ID"] = user_id
142
+ return headers
143
+
144
+
145
+ def _chat_url(cfg: AIConfig) -> str:
146
+ base = _gateway_base(cfg)
147
+ if base.endswith("/v1"):
148
+ return f"{base}/chat/completions"
149
+ return urljoin(base + "/", "v1/chat/completions")
150
+
151
+
152
+ def _should_use_gateway(cfg: AIConfig) -> bool:
153
+ return bool(cfg.llm_gateway_url.strip() and cfg.llm_service_key.strip())
154
+
155
+
156
+ def _gateway_misconfigured(cfg: AIConfig) -> bool:
157
+ """URL set without service key — fail closed (do not silently call providers)."""
158
+ return bool(cfg.llm_gateway_url.strip()) and not bool(cfg.llm_service_key.strip())
159
+
160
+
161
+ # HTTP statuses that open the circuit (outage). Client errors like 401/429 do not.
162
+ _CIRCUIT_STATUSES = frozenset({502, 503, 504})
163
+
164
+
165
+ def _gateway_chat(
166
+ cfg: AIConfig,
167
+ *,
168
+ messages: list[dict[str, Any]],
169
+ model: str,
170
+ max_tokens: int,
171
+ temperature: float | None,
172
+ product: str,
173
+ user_id: str,
174
+ task_type: str,
175
+ tools: list[dict] | None = None,
176
+ stream: bool = False,
177
+ ) -> Any:
178
+ body: dict[str, Any] = {
179
+ "model": model,
180
+ "messages": messages,
181
+ "max_tokens": max_tokens,
182
+ "stream": stream,
183
+ }
184
+ if temperature is not None:
185
+ body["temperature"] = temperature
186
+ if tools:
187
+ body["tools"] = tools
188
+
189
+ try:
190
+ resp = requests.post(
191
+ _chat_url(cfg),
192
+ headers=_gateway_headers(
193
+ cfg, product=product, user_id=user_id, task_type=task_type
194
+ ),
195
+ json=body,
196
+ timeout=cfg.timeout,
197
+ stream=stream,
198
+ )
199
+ except requests.RequestException as exc:
200
+ _circuit_record_failure(cfg)
201
+ raise AIProviderError(f"llm gateway transport error: {exc}") from exc
202
+
203
+ if resp.status_code in _CIRCUIT_STATUSES:
204
+ _circuit_record_failure(cfg)
205
+ raise AIProviderError(f"llm gateway unavailable ({resp.status_code})")
206
+ if resp.status_code >= 400:
207
+ # 401/429/budget: surface error; do not open circuit (would force direct burn)
208
+ raise AIProviderError(f"llm gateway error ({resp.status_code}): {resp.text[:300]}")
209
+
210
+ _circuit_record_success(cfg)
211
+ return resp
212
+
213
+
214
+ def get_ai_response(
215
+ cfg: AIConfig,
216
+ system_prompt: str,
217
+ user_prompt: str = "",
218
+ max_tokens: int = 1024,
219
+ messages: list | None = None,
220
+ model: str | None = None,
221
+ temperature: float | None = None,
222
+ cache_stable_system: str | None = None,
223
+ *,
224
+ product: str | None = None,
225
+ user_id: str = "",
226
+ task_type: str = "default",
227
+ direct_fallback: DirectSync | None = None,
228
+ ) -> str:
229
+ """Sync completion via gateway (preferred) or injectable direct fallback."""
230
+ if not cfg.enabled:
231
+ raise AIDisabledError("AI features are disabled.")
232
+
233
+ if _gateway_misconfigured(cfg):
234
+ raise AIDisabledError(
235
+ "LLM_GATEWAY_URL is set but PRAXICRAFT_LLM_SERVICE_KEY is missing."
236
+ )
237
+
238
+ resolved_model = (model or cfg.model).strip()
239
+ product_name = (product or cfg.product or "practice").strip()
240
+
241
+ if messages is None:
242
+ messages = [{"role": "user", "content": user_prompt}]
243
+
244
+ merged_system = (
245
+ f"{cache_stable_system}\n\n{system_prompt}" if cache_stable_system else system_prompt
246
+ )
247
+ openai_messages = [{"role": "system", "content": merged_system}, *messages]
248
+
249
+ use_gw = _should_use_gateway(cfg) and not _circuit_is_open(cfg)
250
+ if use_gw:
251
+ try:
252
+ resp = _gateway_chat(
253
+ cfg,
254
+ messages=openai_messages,
255
+ model=resolved_model,
256
+ max_tokens=max_tokens,
257
+ temperature=temperature,
258
+ product=product_name,
259
+ user_id=user_id,
260
+ task_type=task_type,
261
+ stream=False,
262
+ )
263
+ data = resp.json()
264
+ return str(data["choices"][0]["message"]["content"] or "")
265
+ except (AIProviderError, KeyError, ValueError, TypeError) as exc:
266
+ logger.warning("gateway get_ai_response failed: %s", exc)
267
+ # Budget/auth client errors: do not burn direct key unless circuit/outage
268
+ msg = str(exc)
269
+ if "llm gateway error (429)" in msg or "llm gateway error (401)" in msg:
270
+ raise AIProviderError(msg) from exc
271
+ if direct_fallback is None:
272
+ raise AIProviderError(str(exc)) from exc
273
+ # fall through to direct
274
+ except Exception as exc:
275
+ _circuit_record_failure(cfg)
276
+ logger.warning("gateway get_ai_response unexpected: %s", exc)
277
+ if direct_fallback is None:
278
+ raise AIProviderError(str(exc)) from exc
279
+
280
+ if _should_use_gateway(cfg) and _circuit_is_open(cfg) and direct_fallback is not None:
281
+ logger.warning("praxicraft-llm circuit open; using direct fallback")
282
+
283
+ if direct_fallback is not None:
284
+ return direct_fallback(
285
+ system_prompt=system_prompt,
286
+ user_prompt=user_prompt,
287
+ max_tokens=max_tokens,
288
+ messages=messages,
289
+ model=resolved_model,
290
+ temperature=temperature,
291
+ cache_stable_system=cache_stable_system,
292
+ )
293
+
294
+ if not cfg.api_key:
295
+ raise AIDisabledError("AI_API_KEY is not configured and no gateway/fallback available.")
296
+ raise AIProviderError("no gateway response and no direct_fallback configured")
297
+
298
+
299
+ def stream_ai_response(
300
+ cfg: AIConfig,
301
+ system_prompt: str,
302
+ user_prompt: str = "",
303
+ max_tokens: int = 1024,
304
+ messages: list | None = None,
305
+ model: str | None = None,
306
+ cache_stable_system: str | None = None,
307
+ *,
308
+ product: str | None = None,
309
+ user_id: str = "",
310
+ task_type: str = "default",
311
+ temperature: float | None = None,
312
+ direct_fallback: DirectStream | None = None,
313
+ ) -> Iterator[str]:
314
+ """Stream text deltas via gateway SSE or injectable direct fallback."""
315
+ if not cfg.enabled:
316
+ raise AIDisabledError("AI features are disabled.")
317
+
318
+ if _gateway_misconfigured(cfg):
319
+ raise AIDisabledError(
320
+ "LLM_GATEWAY_URL is set but PRAXICRAFT_LLM_SERVICE_KEY is missing."
321
+ )
322
+
323
+ resolved_model = (model or cfg.model).strip()
324
+ product_name = (product or cfg.product or "practice").strip()
325
+
326
+ if messages is None:
327
+ messages = [{"role": "user", "content": user_prompt}]
328
+
329
+ merged_system = (
330
+ f"{cache_stable_system}\n\n{system_prompt}" if cache_stable_system else system_prompt
331
+ )
332
+ openai_messages = [{"role": "system", "content": merged_system}, *messages]
333
+
334
+ use_gw = _should_use_gateway(cfg) and not _circuit_is_open(cfg)
335
+ if use_gw:
336
+ try:
337
+ resp = _gateway_chat(
338
+ cfg,
339
+ messages=openai_messages,
340
+ model=resolved_model,
341
+ max_tokens=max_tokens,
342
+ temperature=temperature,
343
+ product=product_name,
344
+ user_id=user_id,
345
+ task_type=task_type,
346
+ stream=True,
347
+ )
348
+ yielded = False
349
+ for line in resp.iter_lines(decode_unicode=True):
350
+ if not line:
351
+ continue
352
+ if line.startswith("data: "):
353
+ payload = line[6:].strip()
354
+ if payload == "[DONE]":
355
+ break
356
+ try:
357
+ chunk = json.loads(payload)
358
+ delta = chunk["choices"][0].get("delta") or {}
359
+ text = delta.get("content") or ""
360
+ if text:
361
+ yielded = True
362
+ yield text
363
+ except (KeyError, ValueError, TypeError, IndexError):
364
+ continue
365
+ if yielded:
366
+ return
367
+ raise AIProviderError("gateway stream returned no content")
368
+ except AIProviderError as exc:
369
+ logger.warning("gateway stream failed: %s", exc)
370
+ msg = str(exc)
371
+ if "llm gateway error (429)" in msg or "llm gateway error (401)" in msg:
372
+ raise
373
+ if direct_fallback is None:
374
+ raise
375
+ except Exception as exc:
376
+ _circuit_record_failure(cfg)
377
+ logger.warning("gateway stream unexpected: %s", exc)
378
+ if direct_fallback is None:
379
+ raise AIProviderError(str(exc)) from exc
380
+
381
+ if direct_fallback is not None:
382
+ yield from direct_fallback(
383
+ system_prompt=system_prompt,
384
+ user_prompt=user_prompt,
385
+ max_tokens=max_tokens,
386
+ messages=messages,
387
+ model=resolved_model,
388
+ cache_stable_system=cache_stable_system,
389
+ )
390
+ return
391
+
392
+ raise AIProviderError("no gateway stream and no direct_fallback configured")
393
+
394
+
395
+ def get_ai_assistant_turn(
396
+ cfg: AIConfig,
397
+ system_prompt: str,
398
+ messages: list[dict],
399
+ tools: list[dict] | None = None,
400
+ max_tokens: int = 1024,
401
+ model: str | None = None,
402
+ temperature: float | None = None,
403
+ *,
404
+ product: str | None = None,
405
+ user_id: str = "",
406
+ task_type: str = "assistant",
407
+ direct_fallback: DirectAssistant | None = None,
408
+ ) -> dict[str, Any]:
409
+ """Tool-capable chat turn via gateway or injectable fallback."""
410
+ if not cfg.enabled:
411
+ raise AIDisabledError("AI features are disabled.")
412
+
413
+ if _gateway_misconfigured(cfg):
414
+ raise AIDisabledError(
415
+ "LLM_GATEWAY_URL is set but PRAXICRAFT_LLM_SERVICE_KEY is missing."
416
+ )
417
+
418
+ resolved_model = (model or cfg.model).strip()
419
+ product_name = (product or cfg.product or "practice").strip()
420
+ openai_messages = [{"role": "system", "content": system_prompt}, *messages]
421
+
422
+ use_gw = _should_use_gateway(cfg) and not _circuit_is_open(cfg)
423
+ if use_gw:
424
+ try:
425
+ resp = _gateway_chat(
426
+ cfg,
427
+ messages=openai_messages,
428
+ model=resolved_model,
429
+ max_tokens=max_tokens,
430
+ temperature=temperature,
431
+ product=product_name,
432
+ user_id=user_id,
433
+ task_type=task_type,
434
+ tools=tools,
435
+ stream=False,
436
+ )
437
+ data = resp.json()
438
+ choice = data["choices"][0]
439
+ msg = choice.get("message") or {}
440
+ finish = choice.get("finish_reason") or "stop"
441
+ payload = {
442
+ "role": msg.get("role") or "assistant",
443
+ "content": msg.get("content") or "",
444
+ }
445
+ if msg.get("tool_calls"):
446
+ payload["tool_calls"] = msg["tool_calls"]
447
+ if finish == "stop":
448
+ finish = "tool_calls"
449
+ return {"message": payload, "finish_reason": finish}
450
+ except (AIProviderError, KeyError, ValueError, TypeError) as exc:
451
+ logger.warning("gateway assistant turn failed: %s", exc)
452
+ msg = str(exc)
453
+ if "llm gateway error (429)" in msg or "llm gateway error (401)" in msg:
454
+ raise AIProviderError(msg) from exc
455
+ if direct_fallback is None:
456
+ raise AIProviderError(str(exc)) from exc
457
+ except Exception as exc:
458
+ _circuit_record_failure(cfg)
459
+ if direct_fallback is None:
460
+ raise AIProviderError(str(exc)) from exc
461
+
462
+ if direct_fallback is not None:
463
+ return direct_fallback(
464
+ system_prompt=system_prompt,
465
+ messages=messages,
466
+ tools=tools,
467
+ max_tokens=max_tokens,
468
+ model=resolved_model,
469
+ temperature=temperature,
470
+ )
471
+
472
+ raise AIProviderError("no gateway assistant turn and no direct_fallback configured")
473
+
474
+
475
+ # Test helper — reset process-local circuit
476
+ def _reset_circuit_for_tests() -> None:
477
+ global _local_circuit
478
+ _local_circuit = _CircuitState()