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.
- praxicraft_shared-0.6.0/PKG-INFO +82 -0
- praxicraft_shared-0.6.0/README.md +66 -0
- praxicraft_shared-0.6.0/pyproject.toml +46 -0
- praxicraft_shared-0.6.0/setup.cfg +4 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/__init__.py +3 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/ai_service.py +478 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/auth_client.py +388 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/billing_client.py +411 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/2fa-disabled.html +29 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/2fa-enabled.html +29 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/__init__.py +72 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/account-deletion-code.html +27 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/account-locked.html +37 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/account_locked.html +37 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/admin_invitation.html +32 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/admin_login_notify_internal.html +29 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/admin_staff_granted.html +27 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/anniversary.html +33 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/api_key_expiring_soon.html +23 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/api_key_reveal_code.html +26 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_application_advanced.html +49 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_application_received.html +36 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_application_rejection.html +28 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_completed_candidate.html +22 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_custom_body.html +16 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_interview_invite.html +48 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_invitation.html +41 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_needs_grading.html +25 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_offer_reminder.html +36 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_offer_sent.html +47 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_offer_withdrawn.html +31 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_org_welcome.html +31 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_reminder.html +35 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_results_ready.html +32 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/assess_stage_update.html +41 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/base.html +351 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/base.txt +4 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/beta_engineer_invitation.html +29 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/beta_organisation_invitation.html +32 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/billing_cancel_scheduled.html +32 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/billing_payment_failed.html +42 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/billing_payment_notify_internal.html +38 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/billing_payment_succeeded.html +45 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/billing_plan_changed.html +45 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/billing_refund_completed.html +29 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/billing_refund_initiated.html +33 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/billing_renewal_reminder.html +66 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/billing_subscription_ended.html +32 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/billing_subscription_resumed.html +28 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/billing_subscription_started.html +74 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/contract.html +28 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/dm_digest.html +30 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/email_verified.html +29 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/email_verify_code.html +26 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/employment_confirmation.html +27 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/feedback-request.html +38 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/feedback-thanks.html +35 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/inactivity.html +35 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/interview_cancellation.html +20 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/interview_cancellation.txt +7 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/interview_invitation.html +25 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/interview_invitation.txt +10 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/interview_no_show_recruiter.html +21 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/interview_no_show_recruiter.txt +5 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/interview_reminder.html +23 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/interview_reminder.txt +9 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/interview_report_ready.html +17 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/interview_report_ready.txt +7 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/interview_reschedule.html +23 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/interview_reschedule.txt +11 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/member_joined_notification.html +23 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/mentorship_declined.html +16 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/mentorship_expired.html +16 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/mentorship_matched.html +29 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/oauth_hint.html +15 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/onboarding_incomplete.html +34 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/org_deletion_verification.html +30 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/organisation_invitation.html +29 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/pair_invite.html +42 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/partials/_cta.html +10 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/partials/_detail_row.html +35 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/partials/_footer_banner.html +40 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/partials/_hero.html +48 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/partials/_settings_prefs_note.html +14 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/partials/_stat_tile.html +19 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/password-changed.html +19 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/password_changed.html +19 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/password_reset.html +18 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/payslip.html +16 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/promotion.html +33 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/reengagement.html +34 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/reset-password.html +18 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/security_alert.html +19 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/sprint_completed.html +31 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/step-up-code.html +26 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/support_inactivity_warning.html +29 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/support_reply.html +41 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/support_status_update.html +36 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/suspicious-login.html +36 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/suspicious_login.html +36 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/team_invite.html +25 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/team_join_request.html +25 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/track_launch.html +25 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/verify-email.html +26 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/waitlist-confirmation.html +23 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/waitlist-launch.html +39 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/webhook_endpoint_unreachable.html +25 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/weekly_digest.html +38 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/welcome.html +26 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/welcome_account.html +26 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/welcome_hr.html +54 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/welcome_self_paced.html +18 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/email/year_wrap.html +47 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/error_envelope.py +34 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/events/SCHEMA.md +55 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/events/__init__.py +99 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/hibp.py +73 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/jwt_utils.py +151 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/notifications_client.py +140 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/s3_client.py +173 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/secret_crypto.py +73 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/service_token.py +101 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared/work_email.py +68 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared.egg-info/PKG-INFO +82 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared.egg-info/SOURCES.txt +133 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared.egg-info/dependency_links.txt +1 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared.egg-info/requires.txt +8 -0
- praxicraft_shared-0.6.0/src/praxicraft_shared.egg-info/top_level.txt +1 -0
- praxicraft_shared-0.6.0/tests/test_ai_service.py +215 -0
- praxicraft_shared-0.6.0/tests/test_billing_client.py +87 -0
- praxicraft_shared-0.6.0/tests/test_notifications_service_token.py +96 -0
- praxicraft_shared-0.6.0/tests/test_s1.py +106 -0
- praxicraft_shared-0.6.0/tests/test_s2_jwt_email.py +151 -0
- praxicraft_shared-0.6.0/tests/test_s3_auth_client.py +101 -0
- 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,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()
|