codex-lb 0.3.1__tar.gz → 0.5.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 (175) hide show
  1. {codex_lb-0.3.1 → codex_lb-0.5.0}/.all-contributorsrc +20 -0
  2. codex_lb-0.5.0/.github/release-please-manifest.json +3 -0
  3. {codex_lb-0.3.1 → codex_lb-0.5.0}/.github/workflows/ci.yml +20 -0
  4. {codex_lb-0.3.1 → codex_lb-0.5.0}/.gitignore +2 -1
  5. {codex_lb-0.3.1 → codex_lb-0.5.0}/AGENTS.md +15 -0
  6. {codex_lb-0.3.1 → codex_lb-0.5.0}/CHANGELOG.md +40 -0
  7. {codex_lb-0.3.1 → codex_lb-0.5.0}/PKG-INFO +42 -3
  8. {codex_lb-0.3.1 → codex_lb-0.5.0}/README.md +40 -2
  9. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/clients/proxy.py +33 -3
  10. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/config/settings.py +9 -8
  11. codex_lb-0.5.0/app/core/handlers/__init__.py +3 -0
  12. codex_lb-0.5.0/app/core/handlers/exceptions.py +39 -0
  13. codex_lb-0.5.0/app/core/middleware/__init__.py +9 -0
  14. codex_lb-0.5.0/app/core/middleware/api_errors.py +33 -0
  15. codex_lb-0.5.0/app/core/middleware/request_decompression.py +101 -0
  16. codex_lb-0.5.0/app/core/middleware/request_id.py +27 -0
  17. codex_lb-0.5.0/app/core/openai/chat_requests.py +172 -0
  18. codex_lb-0.5.0/app/core/openai/chat_responses.py +534 -0
  19. codex_lb-0.5.0/app/core/openai/message_coercion.py +60 -0
  20. codex_lb-0.5.0/app/core/openai/models_catalog.py +72 -0
  21. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/openai/requests.py +23 -5
  22. codex_lb-0.5.0/app/core/openai/v1_requests.py +92 -0
  23. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/db/models.py +3 -3
  24. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/db/session.py +25 -8
  25. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/dependencies.py +43 -16
  26. codex_lb-0.5.0/app/main.py +79 -0
  27. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/accounts/repository.py +25 -10
  28. codex_lb-0.5.0/app/modules/proxy/api.py +170 -0
  29. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/proxy/load_balancer.py +75 -58
  30. codex_lb-0.5.0/app/modules/proxy/repo_bundle.py +23 -0
  31. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/proxy/service.py +127 -102
  32. codex_lb-0.5.0/app/modules/request_logs/api.py +85 -0
  33. codex_lb-0.5.0/app/modules/request_logs/repository.py +218 -0
  34. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/request_logs/schemas.py +11 -2
  35. codex_lb-0.5.0/app/modules/request_logs/service.py +163 -0
  36. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/usage/service.py +65 -4
  37. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/usage/updater.py +58 -26
  38. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/static/index.css +378 -1
  39. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/static/index.html +183 -8
  40. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/static/index.js +308 -13
  41. {codex_lb-0.3.1 → codex_lb-0.5.0}/docker-compose.yml +3 -0
  42. codex_lb-0.5.0/docs/plans/2026-01-27-v1-chat-bridge-implementation.md +390 -0
  43. {codex_lb-0.3.1 → codex_lb-0.5.0}/pyproject.toml +2 -1
  44. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/integration/test_load_balancer_integration.py +22 -2
  45. codex_lb-0.5.0/tests/integration/test_proxy_chat_completions.py +82 -0
  46. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/integration/test_proxy_responses.py +100 -0
  47. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/integration/test_repositories.py +26 -2
  48. codex_lb-0.5.0/tests/integration/test_request_decompression.py +56 -0
  49. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/integration/test_request_logs_filters.py +127 -0
  50. codex_lb-0.5.0/tests/integration/test_v1_models.py +21 -0
  51. codex_lb-0.5.0/tests/test_request_logs_options_api.py +111 -0
  52. codex_lb-0.5.0/tests/unit/test_chat_request_mapping.py +148 -0
  53. codex_lb-0.5.0/tests/unit/test_chat_response_mapping.py +141 -0
  54. codex_lb-0.5.0/tests/unit/test_db_session.py +23 -0
  55. codex_lb-0.5.0/tests/unit/test_openai_requests.py +105 -0
  56. codex_lb-0.5.0/tests/unit/test_proxy_errors.py +96 -0
  57. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/unit/test_proxy_utils.py +29 -0
  58. codex_lb-0.5.0/tests/unit/test_request_decompression_middleware.py +45 -0
  59. codex_lb-0.5.0/tests/unit/test_request_logs_repository.py +35 -0
  60. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/unit/test_usage_client.py +3 -1
  61. codex_lb-0.5.0/tests/unit/test_usage_updater.py +343 -0
  62. {codex_lb-0.3.1 → codex_lb-0.5.0}/uv.lock +43 -1
  63. codex_lb-0.3.1/.github/release-please-manifest.json +0 -3
  64. codex_lb-0.3.1/app/main.py +0 -133
  65. codex_lb-0.3.1/app/modules/proxy/api.py +0 -76
  66. codex_lb-0.3.1/app/modules/request_logs/api.py +0 -31
  67. codex_lb-0.3.1/app/modules/request_logs/repository.py +0 -103
  68. codex_lb-0.3.1/app/modules/request_logs/service.py +0 -86
  69. codex_lb-0.3.1/tests/unit/test_usage_updater.py +0 -124
  70. {codex_lb-0.3.1 → codex_lb-0.5.0}/.dockerignore +0 -0
  71. {codex_lb-0.3.1 → codex_lb-0.5.0}/.env.example +0 -0
  72. {codex_lb-0.3.1 → codex_lb-0.5.0}/.github/release-please-config.json +0 -0
  73. {codex_lb-0.3.1 → codex_lb-0.5.0}/.github/workflows/release-please.yml +0 -0
  74. {codex_lb-0.3.1 → codex_lb-0.5.0}/.github/workflows/release.yml +0 -0
  75. {codex_lb-0.3.1 → codex_lb-0.5.0}/.pre-commit-config.yaml +0 -0
  76. {codex_lb-0.3.1 → codex_lb-0.5.0}/Dockerfile +0 -0
  77. {codex_lb-0.3.1 → codex_lb-0.5.0}/LICENSE +0 -0
  78. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/__init__.py +0 -0
  79. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/cli.py +0 -0
  80. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/__init__.py +0 -0
  81. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/auth/__init__.py +0 -0
  82. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/auth/models.py +0 -0
  83. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/auth/refresh.py +0 -0
  84. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/balancer/__init__.py +0 -0
  85. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/balancer/logic.py +0 -0
  86. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/balancer/types.py +0 -0
  87. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/clients/__init__.py +0 -0
  88. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/clients/http.py +0 -0
  89. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/clients/oauth.py +0 -0
  90. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/clients/usage.py +0 -0
  91. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/config/__init__.py +0 -0
  92. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/crypto.py +0 -0
  93. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/errors.py +0 -0
  94. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/openai/__init__.py +0 -0
  95. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/openai/models.py +0 -0
  96. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/openai/parsing.py +0 -0
  97. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/plan_types.py +0 -0
  98. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/types.py +0 -0
  99. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/usage/__init__.py +0 -0
  100. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/usage/logs.py +0 -0
  101. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/usage/models.py +0 -0
  102. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/usage/pricing.py +0 -0
  103. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/usage/quota.py +0 -0
  104. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/usage/types.py +0 -0
  105. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/utils/__init__.py +0 -0
  106. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/utils/request_id.py +0 -0
  107. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/utils/retry.py +0 -0
  108. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/utils/sse.py +0 -0
  109. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/core/utils/time.py +0 -0
  110. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/db/__init__.py +0 -0
  111. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/db/migrations/__init__.py +0 -0
  112. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/db/migrations/versions/__init__.py +0 -0
  113. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/db/migrations/versions/add_accounts_chatgpt_account_id.py +0 -0
  114. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/db/migrations/versions/add_accounts_reset_at.py +0 -0
  115. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/db/migrations/versions/add_dashboard_settings.py +0 -0
  116. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/db/migrations/versions/add_request_logs_reasoning_effort.py +0 -0
  117. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/db/migrations/versions/normalize_account_plan_types.py +0 -0
  118. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/__init__.py +0 -0
  119. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/accounts/__init__.py +0 -0
  120. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/accounts/api.py +0 -0
  121. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/accounts/auth_manager.py +0 -0
  122. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/accounts/schemas.py +0 -0
  123. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/accounts/service.py +0 -0
  124. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/health/__init__.py +0 -0
  125. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/health/api.py +0 -0
  126. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/health/schemas.py +0 -0
  127. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/oauth/__init__.py +0 -0
  128. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/oauth/api.py +0 -0
  129. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/oauth/schemas.py +0 -0
  130. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/oauth/service.py +0 -0
  131. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/oauth/templates/oauth_success.html +0 -0
  132. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/proxy/__init__.py +0 -0
  133. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/proxy/helpers.py +0 -0
  134. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/proxy/schemas.py +0 -0
  135. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/proxy/sticky_repository.py +0 -0
  136. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/proxy/types.py +0 -0
  137. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/request_logs/__init__.py +0 -0
  138. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/settings/__init__.py +0 -0
  139. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/settings/api.py +0 -0
  140. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/settings/repository.py +0 -0
  141. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/settings/schemas.py +0 -0
  142. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/settings/service.py +0 -0
  143. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/shared/__init__.py +0 -0
  144. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/shared/schemas.py +0 -0
  145. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/usage/__init__.py +0 -0
  146. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/usage/api.py +0 -0
  147. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/usage/repository.py +0 -0
  148. {codex_lb-0.3.1 → codex_lb-0.5.0}/app/modules/usage/schemas.py +0 -0
  149. {codex_lb-0.3.1 → codex_lb-0.5.0}/docs/screenshots/accounts.jpg +0 -0
  150. {codex_lb-0.3.1 → codex_lb-0.5.0}/docs/screenshots/dashboard.jpg +0 -0
  151. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/__init__.py +0 -0
  152. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/conftest.py +0 -0
  153. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/integration/test_accounts_api.py +0 -0
  154. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/integration/test_accounts_api_extended.py +0 -0
  155. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/integration/test_codex_usage_api.py +0 -0
  156. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/integration/test_db_models.py +0 -0
  157. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/integration/test_health_and_errors.py +0 -0
  158. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/integration/test_migrations.py +0 -0
  159. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/integration/test_oauth_flow.py +0 -0
  160. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/integration/test_proxy_api_extended.py +0 -0
  161. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/integration/test_proxy_compact.py +0 -0
  162. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/integration/test_proxy_sticky_sessions.py +0 -0
  163. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/integration/test_request_logs_api.py +0 -0
  164. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/integration/test_settings_api.py +0 -0
  165. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/integration/test_usage_api.py +0 -0
  166. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/integration/test_usage_summary.py +0 -0
  167. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/unit/test_auth.py +0 -0
  168. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/unit/test_auth_manager.py +0 -0
  169. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/unit/test_auth_refresh.py +0 -0
  170. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/unit/test_load_balancer.py +0 -0
  171. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/unit/test_oauth_client.py +0 -0
  172. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/unit/test_pricing.py +0 -0
  173. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/unit/test_retry.py +0 -0
  174. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/unit/test_sse.py +0 -0
  175. {codex_lb-0.3.1 → codex_lb-0.5.0}/tests/unit/test_usage.py +0 -0
@@ -44,6 +44,26 @@
44
44
  "maintenance",
45
45
  "design"
46
46
  ]
47
+ },
48
+ {
49
+ "login": "hhsw2015",
50
+ "name": "Jill Kok, San Mou",
51
+ "avatar_url": "https://avatars.githubusercontent.com/u/103614420?v=4",
52
+ "profile": "https://github.com/hhsw2015",
53
+ "contributions": [
54
+ "code",
55
+ "test",
56
+ "maintenance"
57
+ ]
58
+ },
59
+ {
60
+ "login": "pcy06",
61
+ "name": "PARK CHANYOUNG",
62
+ "avatar_url": "https://avatars.githubusercontent.com/u/44970486?v=4",
63
+ "profile": "https://github.com/pcy06",
64
+ "contributions": [
65
+ "doc"
66
+ ]
47
67
  }
48
68
  ],
49
69
  "contributorsPerLine": 7,
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.5.0"
3
+ }
@@ -30,6 +30,26 @@ jobs:
30
30
  - name: Ruff format (check)
31
31
  run: uvx ruff format --check .
32
32
 
33
+ typecheck:
34
+ name: Type check (ty)
35
+ runs-on: ubuntu-24.04
36
+
37
+ steps:
38
+ - name: Checkout repository
39
+ uses: actions/checkout@v4
40
+
41
+ - name: Set up uv
42
+ uses: astral-sh/setup-uv@v5
43
+ with:
44
+ python-version: "3.13"
45
+ enable-cache: true
46
+
47
+ - name: Install dependencies
48
+ run: uv sync --dev --frozen
49
+
50
+ - name: Ty check
51
+ run: uv run ty check
52
+
33
53
  test:
34
54
  name: Tests (pytest)
35
55
  runs-on: ubuntu-24.04
@@ -33,6 +33,7 @@ node_modules/
33
33
 
34
34
  # Local
35
35
  .local/
36
+ .worktrees/
36
37
 
37
38
  app/static/components
38
- .codex-lb/
39
+ .codex-lb/
@@ -1,16 +1,26 @@
1
1
  # AGENTS
2
2
 
3
3
  ## Environment
4
+
4
5
  - Python: .venv/bin/python (uv, CPython 3.13.3)
5
6
 
6
7
  ## Code Conventions (Typing & Data Contracts)
8
+
7
9
  - Prefer strict typing end-to-end. Avoid `dict`, `Mapping[str, object]`, and `object` in app/service/repository layers when the shape is known.
8
10
  - Use explicit dataclasses or Pydantic models for internal payloads; convert to response schemas at the edge.
9
11
  - ORM models should be passed through services instead of generic containers; avoid `getattr`/`[]` access on ORM results.
10
12
  - Expose time values in dashboard APIs as ISO 8601 strings (`datetime` in schemas), not epoch numbers.
11
13
  - If a test depends on a contract change (field name/type), update the test to match the new typed schema.
12
14
 
15
+ ## Code Conventions (Anti-Patterns to Avoid)
16
+
17
+ - **No Speculative Fallbacks**: Do not use multiple keys for the same configuration (e.g., `os.getenv("A") or os.getenv("B")`). Pick one canonical name and stick to it.
18
+ - **Single Source of Truth**: Do not create redundant fields in data models (JSON/DB) that represent the same state. Calculate derived values dynamically.
19
+ - **Fail Fast**: Do not clutter code with excessive `None` checks or fallback defaults for critical configurations. Raise explicit errors for missing or invalid configuration.
20
+ - **Refactor over Duplicate**: Do not duplicate logic to avoid touching existing code. Refactor the existing code to support the new requirement.
21
+
13
22
  ## Code Conventions (Structure & Responsibilities)
23
+
14
24
  - Keep domain boundaries clear: `core/` for reusable logic, `modules/*` for API-facing features, `db/` for persistence, `static/` for dashboard assets.
15
25
  - Follow module layout conventions in `app/modules/<feature>/`: `api.py` (routes), `service.py` (business logic), `repository.py` (DB access), `schemas.py` (Pydantic I/O models).
16
26
  - Prefer small, focused files; split when a file grows beyond a single responsibility or mixes layers.
@@ -20,6 +30,7 @@
20
30
  - Validate inputs early and fail fast with clear errors; never silently coerce invalid types.
21
31
 
22
32
  ## Code Conventions (Testing / TC)
33
+
23
34
  - Add or update tests whenever contracts change (field names/types, response formats, default values).
24
35
  - Keep unit tests under `tests/unit` and integration tests under `tests/integration` using existing markers.
25
36
  - Tests should assert public behavior (API responses, service outputs) rather than internal implementation details.
@@ -27,6 +38,7 @@
27
38
  - Prefer deterministic inputs (fixed timestamps, explicit payloads) to avoid flaky tests.
28
39
 
29
40
  ## Code Conventions (DI & Context)
41
+
30
42
  - Use FastAPI `Depends` providers in `app/dependencies.py` to construct per-request contexts (`*Context` dataclasses).
31
43
  - Contexts should hold only the session, repositories, and service for a single module; avoid cross-module service coupling.
32
44
  - Repositories must be constructed with the request-scoped `AsyncSession` from `get_session`; no global sessions.
@@ -35,6 +47,7 @@
35
47
  - When adding a new module, define `api.py` endpoints that depend on a module-specific context provider.
36
48
 
37
49
  ## Git Workflow & Contribution
50
+
38
51
  1. **Important**: Create branches, commits, or PRs **only upon explicit user request**. Implicit actions are not allowed.
39
52
  2. **Branch Naming**: Use prefixes like `feature/`, `fix/`, `chore/` (e.g., `feature/add-login`).
40
53
  3. **Commit Messages**: Follow [Conventional Commits](https://www.conventionalcommits.org/).
@@ -42,6 +55,7 @@
42
55
  - Types: `feat`, `fix`, `docs`, `refactor`, `chore`, `test`
43
56
  - Example: `feat(api): add auth endpoint`
44
57
  4. **Workflow**:
58
+
45
59
  ```bash
46
60
  git checkout -b feature/add-login
47
61
  git commit -m "feat(api): add auth endpoint"
@@ -49,4 +63,5 @@
49
63
  git push -u origin feature/add-login
50
64
  gh pr create --title "feat(api): add auth" --body "..."
51
65
  ```
66
+
52
67
  5. **Best Practices**: Commit often in small units. Do not commit directly to `main`. Always check `git diff` before pushing.
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.0](https://github.com/Soju06/codex-lb/compare/v0.4.0...v0.5.0) (2026-01-29)
4
+
5
+
6
+ ### Features
7
+
8
+ * **db:** add configurable pool settings ([#44](https://github.com/Soju06/codex-lb/issues/44)) ([e2e553d](https://github.com/Soju06/codex-lb/commit/e2e553debfac1ab51c691a883b16812db6acdd9e))
9
+ * **proxy:** add v1 chat and models endpoints ([#39](https://github.com/Soju06/codex-lb/issues/39)) ([c242304](https://github.com/Soju06/codex-lb/commit/c242304304583821afebb9e2c0b2803012d4a7aa))
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * **accounts:** update upsert for duplicate email ([#35](https://github.com/Soju06/codex-lb/issues/35)) ([5f68773](https://github.com/Soju06/codex-lb/commit/5f6877342d81abca82e800dbf0b21458e78cb1d9))
15
+ * **core:** support zstd request decompression and modularize middleware ([#42](https://github.com/Soju06/codex-lb/issues/42)) ([d0eebb7](https://github.com/Soju06/codex-lb/commit/d0eebb7b9c8c16b1a1293279db42633ba75b1867))
16
+ * **proxy:** use short-lived sessions for streaming ([#38](https://github.com/Soju06/codex-lb/issues/38)) ([cb48757](https://github.com/Soju06/codex-lb/commit/cb48757bfbf66d3fb2598523d66c6b5bda44a55d))
17
+ * **usage:** coalesce refresh requests ([#36](https://github.com/Soju06/codex-lb/issues/36)) ([04d8fab](https://github.com/Soju06/codex-lb/commit/04d8fab891236e4d4b6bb46c5219730acbabd822))
18
+
19
+
20
+ ### Documentation
21
+
22
+ * add hhsw2015 as a contributor for maintenance ([#43](https://github.com/Soju06/codex-lb/issues/43)) ([1651968](https://github.com/Soju06/codex-lb/commit/1651968e2c8605190fe8647c755f2ab97a7db3d3))
23
+
24
+ ## [0.4.0](https://github.com/Soju06/codex-lb/compare/v0.3.1...v0.4.0) (2026-01-26)
25
+
26
+
27
+ ### Features
28
+
29
+ * **proxy:** add v1 responses compatibility for OpenCode ([#28](https://github.com/Soju06/codex-lb/issues/28)) ([04d58d2](https://github.com/Soju06/codex-lb/commit/04d58d2430e4ba88f28e9e811f08b628e9a4674c))
30
+
31
+
32
+ ### Bug Fixes
33
+
34
+ * **dashboard:** remove rounding in avgPerHour calculation ([#29](https://github.com/Soju06/codex-lb/issues/29)) ([b432939](https://github.com/Soju06/codex-lb/commit/b432939d6ea832d917658dfdbcb935f88f9e08a6)), closes [#26](https://github.com/Soju06/codex-lb/issues/26)
35
+
36
+
37
+ ### Documentation
38
+
39
+ * add hhsw2015 as a contributor for code, and test ([#31](https://github.com/Soju06/codex-lb/issues/31)) ([a1f0e79](https://github.com/Soju06/codex-lb/commit/a1f0e796e45862e520953f60716d2b5eaab3a0d9))
40
+ * add opencode setup guide ([#32](https://github.com/Soju06/codex-lb/issues/32)) ([9330619](https://github.com/Soju06/codex-lb/commit/93306198902e558e6bce89719d7cd6b1e797ddc5))
41
+ * add pcy06 as a contributor for doc ([#34](https://github.com/Soju06/codex-lb/issues/34)) ([506b7b1](https://github.com/Soju06/codex-lb/commit/506b7b160b11b558533fafb39793870ceefd9131))
42
+
3
43
  ## [0.3.1](https://github.com/Soju06/codex-lb/compare/v0.3.0...v0.3.1) (2026-01-22)
4
44
 
5
45
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-lb
3
- Version: 0.3.1
3
+ Version: 0.5.0
4
4
  Summary: Codex load balancer and proxy for ChatGPT accounts with usage dashboard
5
5
  Author-email: Soju06 <qlskssk@gmail.com>
6
6
  Maintainer-email: Soju06 <qlskssk@gmail.com>
@@ -49,12 +49,25 @@ Requires-Dist: pydantic>=2.12.5
49
49
  Requires-Dist: python-dotenv>=1.2.1
50
50
  Requires-Dist: python-multipart>=0.0.21
51
51
  Requires-Dist: sqlalchemy>=2.0.45
52
+ Requires-Dist: zstandard>=0.25.0
52
53
  Description-Content-Type: text/markdown
53
54
 
55
+ <!--
56
+ About
57
+ Codex/ChatGPT account load balancer & proxy with usage tracking, dashboard, and OpenCode-compatible endpoints
58
+
59
+ Topics
60
+ python oauth sqlalchemy dashboard load-balancer openai rate-limit api-proxy codex fastapi usage-tracking chatgpt opencode
61
+
62
+ Resources
63
+ -->
64
+
54
65
  # codex-lb
55
66
 
56
67
  Load balancer for ChatGPT accounts. Pool multiple accounts, track usage, view everything in a dashboard.
57
68
 
69
+ ## Screenshots
70
+
58
71
  ### Main Dashboard View
59
72
 
60
73
  ![main dashboard view](docs/screenshots/dashboard.jpg)
@@ -82,8 +95,6 @@ uvx codex-lb
82
95
 
83
96
  Open [localhost:2455](http://localhost:2455) → Add account → Done.
84
97
 
85
-
86
-
87
98
  ## Codex CLI & Extension Setup
88
99
 
89
100
  Add to `~/.codex/config.toml`:
@@ -101,6 +112,32 @@ chatgpt_base_url = "http://127.0.0.1:2455"
101
112
  requires_openai_auth = true # Required: enables model selection in Codex IDE extension
102
113
  ```
103
114
 
115
+ ## OpenCode Setup
116
+
117
+ Run:
118
+
119
+ ```bash
120
+ opencode auth login
121
+ ```
122
+
123
+ Then select `OpenAI` -> `Manually enter API Key` and enter any value.
124
+
125
+ Add the following to `~/.config/opencode/opencode.json`:
126
+
127
+ ```jsonc
128
+ {
129
+ ...
130
+ "provider": {
131
+ "openai": {
132
+ "options": {
133
+ "baseURL": "http://127.0.0.1:2455/v1"
134
+ }
135
+ },
136
+ ...
137
+ }
138
+ }
139
+ ```
140
+
104
141
  ## Data
105
142
 
106
143
  All data stored in `~/.codex-lb/`:
@@ -122,6 +159,8 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
122
159
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/Soju06"><img src="https://avatars.githubusercontent.com/u/34199905?v=4?s=100" width="100px;" alt="Soju06"/><br /><sub><b>Soju06</b></sub></a><br /><a href="https://github.com/Soju06/codex-lb/commits?author=Soju06" title="Code">💻</a> <a href="https://github.com/Soju06/codex-lb/commits?author=Soju06" title="Tests">⚠️</a> <a href="#maintenance-Soju06" title="Maintenance">🚧</a> <a href="#infra-Soju06" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
123
160
  <td align="center" valign="top" width="14.28%"><a href="http://jonas.kamsker.at/"><img src="https://avatars.githubusercontent.com/u/11245306?v=4?s=100" width="100px;" alt="Jonas Kamsker"/><br /><sub><b>Jonas Kamsker</b></sub></a><br /><a href="https://github.com/Soju06/codex-lb/commits?author=JKamsker" title="Code">💻</a> <a href="https://github.com/Soju06/codex-lb/issues?q=author%3AJKamsker" title="Bug reports">🐛</a> <a href="#maintenance-JKamsker" title="Maintenance">🚧</a></td>
124
161
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/Quack6765"><img src="https://avatars.githubusercontent.com/u/5446230?v=4?s=100" width="100px;" alt="Quack"/><br /><sub><b>Quack</b></sub></a><br /><a href="https://github.com/Soju06/codex-lb/commits?author=Quack6765" title="Code">💻</a> <a href="https://github.com/Soju06/codex-lb/issues?q=author%3AQuack6765" title="Bug reports">🐛</a> <a href="#maintenance-Quack6765" title="Maintenance">🚧</a> <a href="#design-Quack6765" title="Design">🎨</a></td>
162
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/hhsw2015"><img src="https://avatars.githubusercontent.com/u/103614420?v=4?s=100" width="100px;" alt="Jill Kok, San Mou"/><br /><sub><b>Jill Kok, San Mou</b></sub></a><br /><a href="https://github.com/Soju06/codex-lb/commits?author=hhsw2015" title="Code">💻</a> <a href="https://github.com/Soju06/codex-lb/commits?author=hhsw2015" title="Tests">⚠️</a> <a href="#maintenance-hhsw2015" title="Maintenance">🚧</a></td>
163
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/pcy06"><img src="https://avatars.githubusercontent.com/u/44970486?v=4?s=100" width="100px;" alt="PARK CHANYOUNG"/><br /><sub><b>PARK CHANYOUNG</b></sub></a><br /><a href="https://github.com/Soju06/codex-lb/commits?author=pcy06" title="Documentation">📖</a></td>
125
164
  </tr>
126
165
  </tbody>
127
166
  </table>
@@ -1,7 +1,19 @@
1
+ <!--
2
+ About
3
+ Codex/ChatGPT account load balancer & proxy with usage tracking, dashboard, and OpenCode-compatible endpoints
4
+
5
+ Topics
6
+ python oauth sqlalchemy dashboard load-balancer openai rate-limit api-proxy codex fastapi usage-tracking chatgpt opencode
7
+
8
+ Resources
9
+ -->
10
+
1
11
  # codex-lb
2
12
 
3
13
  Load balancer for ChatGPT accounts. Pool multiple accounts, track usage, view everything in a dashboard.
4
14
 
15
+ ## Screenshots
16
+
5
17
  ### Main Dashboard View
6
18
 
7
19
  ![main dashboard view](docs/screenshots/dashboard.jpg)
@@ -29,8 +41,6 @@ uvx codex-lb
29
41
 
30
42
  Open [localhost:2455](http://localhost:2455) → Add account → Done.
31
43
 
32
-
33
-
34
44
  ## Codex CLI & Extension Setup
35
45
 
36
46
  Add to `~/.codex/config.toml`:
@@ -48,6 +58,32 @@ chatgpt_base_url = "http://127.0.0.1:2455"
48
58
  requires_openai_auth = true # Required: enables model selection in Codex IDE extension
49
59
  ```
50
60
 
61
+ ## OpenCode Setup
62
+
63
+ Run:
64
+
65
+ ```bash
66
+ opencode auth login
67
+ ```
68
+
69
+ Then select `OpenAI` -> `Manually enter API Key` and enter any value.
70
+
71
+ Add the following to `~/.config/opencode/opencode.json`:
72
+
73
+ ```jsonc
74
+ {
75
+ ...
76
+ "provider": {
77
+ "openai": {
78
+ "options": {
79
+ "baseURL": "http://127.0.0.1:2455/v1"
80
+ }
81
+ },
82
+ ...
83
+ }
84
+ }
85
+ ```
86
+
51
87
  ## Data
52
88
 
53
89
  All data stored in `~/.codex-lb/`:
@@ -69,6 +105,8 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
69
105
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/Soju06"><img src="https://avatars.githubusercontent.com/u/34199905?v=4?s=100" width="100px;" alt="Soju06"/><br /><sub><b>Soju06</b></sub></a><br /><a href="https://github.com/Soju06/codex-lb/commits?author=Soju06" title="Code">💻</a> <a href="https://github.com/Soju06/codex-lb/commits?author=Soju06" title="Tests">⚠️</a> <a href="#maintenance-Soju06" title="Maintenance">🚧</a> <a href="#infra-Soju06" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
70
106
  <td align="center" valign="top" width="14.28%"><a href="http://jonas.kamsker.at/"><img src="https://avatars.githubusercontent.com/u/11245306?v=4?s=100" width="100px;" alt="Jonas Kamsker"/><br /><sub><b>Jonas Kamsker</b></sub></a><br /><a href="https://github.com/Soju06/codex-lb/commits?author=JKamsker" title="Code">💻</a> <a href="https://github.com/Soju06/codex-lb/issues?q=author%3AJKamsker" title="Bug reports">🐛</a> <a href="#maintenance-JKamsker" title="Maintenance">🚧</a></td>
71
107
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/Quack6765"><img src="https://avatars.githubusercontent.com/u/5446230?v=4?s=100" width="100px;" alt="Quack"/><br /><sub><b>Quack</b></sub></a><br /><a href="https://github.com/Soju06/codex-lb/commits?author=Quack6765" title="Code">💻</a> <a href="https://github.com/Soju06/codex-lb/issues?q=author%3AQuack6765" title="Bug reports">🐛</a> <a href="#maintenance-Quack6765" title="Maintenance">🚧</a> <a href="#design-Quack6765" title="Design">🎨</a></td>
108
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/hhsw2015"><img src="https://avatars.githubusercontent.com/u/103614420?v=4?s=100" width="100px;" alt="Jill Kok, San Mou"/><br /><sub><b>Jill Kok, San Mou</b></sub></a><br /><a href="https://github.com/Soju06/codex-lb/commits?author=hhsw2015" title="Code">💻</a> <a href="https://github.com/Soju06/codex-lb/commits?author=hhsw2015" title="Tests">⚠️</a> <a href="#maintenance-hhsw2015" title="Maintenance">🚧</a></td>
109
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/pcy06"><img src="https://avatars.githubusercontent.com/u/44970486?v=4?s=100" width="100px;" alt="PARK CHANYOUNG"/><br /><sub><b>PARK CHANYOUNG</b></sub></a><br /><a href="https://github.com/Soju06/codex-lb/commits?author=pcy06" title="Documentation">📖</a></td>
72
110
  </tr>
73
111
  </tbody>
74
112
  </table>
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
- from typing import AsyncIterator, Mapping
4
+ from typing import AsyncIterator, Mapping, Protocol, TypeAlias
5
5
 
6
6
  import aiohttp
7
7
 
@@ -28,6 +28,18 @@ class StreamIdleTimeoutError(Exception):
28
28
  pass
29
29
 
30
30
 
31
+ class ErrorResponseProtocol(Protocol):
32
+ status: int
33
+ reason: str | None
34
+
35
+ async def json(self, *, content_type: str | None = None) -> object: ...
36
+
37
+ async def text(self, *, encoding: str | None = None, errors: str = "strict") -> str: ...
38
+
39
+
40
+ ErrorResponse: TypeAlias = aiohttp.ClientResponse | ErrorResponseProtocol
41
+
42
+
31
43
  class ProxyResponseError(Exception):
32
44
  def __init__(self, status_code: int, payload: OpenAIErrorEnvelope) -> None:
33
45
  super().__init__(f"Proxy response error ({status_code})")
@@ -88,8 +100,10 @@ async def _iter_sse_lines(
88
100
  yield line
89
101
 
90
102
 
91
- async def _error_event_from_response(resp: aiohttp.ClientResponse) -> ResponseFailedEvent:
103
+ async def _error_event_from_response(resp: ErrorResponse) -> ResponseFailedEvent:
92
104
  fallback_message = f"Upstream error: HTTP {resp.status}"
105
+ if resp.reason:
106
+ fallback_message += f" {resp.reason}"
93
107
  try:
94
108
  data = await resp.json(content_type=None)
95
109
  except Exception:
@@ -112,11 +126,16 @@ async def _error_event_from_response(resp: aiohttp.ClientResponse) -> ResponseFa
112
126
  if key in payload:
113
127
  event["response"]["error"][key] = payload[key]
114
128
  return event
129
+ message = _extract_upstream_message(data)
130
+ if message:
131
+ return response_failed_event("upstream_error", message, response_id=get_request_id())
115
132
  return response_failed_event("upstream_error", fallback_message, response_id=get_request_id())
116
133
 
117
134
 
118
- async def _error_payload_from_response(resp: aiohttp.ClientResponse) -> OpenAIErrorEnvelope:
135
+ async def _error_payload_from_response(resp: ErrorResponse) -> OpenAIErrorEnvelope:
119
136
  fallback_message = f"Upstream error: HTTP {resp.status}"
137
+ if resp.reason:
138
+ fallback_message += f" {resp.reason}"
120
139
  try:
121
140
  data = await resp.json(content_type=None)
122
141
  except Exception:
@@ -128,9 +147,20 @@ async def _error_payload_from_response(resp: aiohttp.ClientResponse) -> OpenAIEr
128
147
  error = parse_error_payload(data)
129
148
  if error:
130
149
  return {"error": error.model_dump(exclude_none=True)}
150
+ message = _extract_upstream_message(data)
151
+ if message:
152
+ return openai_error("upstream_error", message)
131
153
  return openai_error("upstream_error", fallback_message)
132
154
 
133
155
 
156
+ def _extract_upstream_message(data: dict) -> str | None:
157
+ for key in ("message", "detail", "error"):
158
+ value = data.get(key)
159
+ if isinstance(value, str) and value.strip():
160
+ return value
161
+ return None
162
+
163
+
134
164
  async def stream_responses(
135
165
  payload: ResponsesRequest,
136
166
  headers: Mapping[str, str],
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from functools import lru_cache
4
4
  from pathlib import Path
5
5
 
6
- from pydantic import field_validator
6
+ from pydantic import Field, field_validator
7
7
  from pydantic_settings import BaseSettings, SettingsConfigDict
8
8
 
9
9
  BASE_DIR = Path(__file__).resolve().parents[3]
@@ -22,6 +22,9 @@ class Settings(BaseSettings):
22
22
  )
23
23
 
24
24
  database_url: str = f"sqlite+aiosqlite:///{DEFAULT_DB_PATH}"
25
+ database_pool_size: int = Field(default=15, gt=0)
26
+ database_max_overflow: int = Field(default=10, ge=0)
27
+ database_pool_timeout_seconds: float = Field(default=30.0, gt=0)
25
28
  upstream_base_url: str = "https://chatgpt.com/backend-api"
26
29
  upstream_connect_timeout_seconds: float = 30.0
27
30
  stream_idle_timeout_seconds: float = 300.0
@@ -42,24 +45,22 @@ class Settings(BaseSettings):
42
45
  database_migrations_fail_fast: bool = True
43
46
  log_proxy_request_shape: bool = False
44
47
  log_proxy_request_shape_raw_cache_key: bool = False
48
+ log_proxy_request_payload: bool = False
49
+ max_decompressed_body_bytes: int = Field(default=32 * 1024 * 1024, gt=0)
45
50
 
46
51
  @field_validator("database_url")
47
52
  @classmethod
48
- def _normalize_database_url(cls, value: str) -> str:
49
- if not isinstance(value, str):
50
- return value
51
-
53
+ def _expand_database_url(cls, value: str) -> str:
52
54
  for prefix in ("sqlite+aiosqlite:///", "sqlite:///"):
53
55
  if value.startswith(prefix):
54
56
  path = value[len(prefix) :]
55
57
  if path.startswith("~"):
56
- expanded = str(Path(path).expanduser())
57
- return f"{prefix}{expanded}"
58
+ return f"{prefix}{Path(path).expanduser()}"
58
59
  return value
59
60
 
60
61
  @field_validator("encryption_key_file", mode="before")
61
62
  @classmethod
62
- def _normalize_encryption_key_file(cls, value: object) -> Path:
63
+ def _expand_encryption_key_file(cls, value: str | Path) -> Path:
63
64
  if isinstance(value, Path):
64
65
  return value.expanduser()
65
66
  if isinstance(value, str):
@@ -0,0 +1,3 @@
1
+ from app.core.handlers.exceptions import add_exception_handlers
2
+
3
+ __all__ = ["add_exception_handlers"]
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import FastAPI, Request
4
+ from fastapi.exception_handlers import (
5
+ http_exception_handler,
6
+ request_validation_exception_handler,
7
+ )
8
+ from fastapi.exceptions import RequestValidationError
9
+ from fastapi.responses import JSONResponse, Response
10
+ from starlette.exceptions import HTTPException as StarletteHTTPException
11
+
12
+ from app.core.errors import dashboard_error
13
+
14
+
15
+ def add_exception_handlers(app: FastAPI) -> None:
16
+ @app.exception_handler(RequestValidationError)
17
+ async def validation_error_handler(
18
+ request: Request,
19
+ exc: RequestValidationError,
20
+ ) -> Response:
21
+ if request.url.path.startswith("/api/"):
22
+ return JSONResponse(
23
+ status_code=422,
24
+ content=dashboard_error("validation_error", "Invalid request payload"),
25
+ )
26
+ return await request_validation_exception_handler(request, exc)
27
+
28
+ @app.exception_handler(StarletteHTTPException)
29
+ async def http_error_handler(
30
+ request: Request,
31
+ exc: StarletteHTTPException,
32
+ ) -> Response:
33
+ if request.url.path.startswith("/api/"):
34
+ detail = exc.detail if isinstance(exc.detail, str) else "Request failed"
35
+ return JSONResponse(
36
+ status_code=exc.status_code,
37
+ content=dashboard_error(f"http_{exc.status_code}", detail),
38
+ )
39
+ return await http_exception_handler(request, exc)
@@ -0,0 +1,9 @@
1
+ from app.core.middleware.api_errors import add_api_unhandled_error_middleware
2
+ from app.core.middleware.request_decompression import add_request_decompression_middleware
3
+ from app.core.middleware.request_id import add_request_id_middleware
4
+
5
+ __all__ = [
6
+ "add_api_unhandled_error_middleware",
7
+ "add_request_decompression_middleware",
8
+ "add_request_id_middleware",
9
+ ]
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import Awaitable, Callable
5
+
6
+ from fastapi import FastAPI, Request
7
+ from fastapi.responses import JSONResponse, Response
8
+
9
+ from app.core.errors import dashboard_error
10
+ from app.core.utils.request_id import get_request_id
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def add_api_unhandled_error_middleware(app: FastAPI) -> None:
16
+ @app.middleware("http")
17
+ async def api_unhandled_error_middleware(
18
+ request: Request,
19
+ call_next: Callable[[Request], Awaitable[Response]],
20
+ ) -> Response:
21
+ try:
22
+ return await call_next(request)
23
+ except Exception:
24
+ if request.url.path.startswith("/api/"):
25
+ logger.exception(
26
+ "Unhandled API error request_id=%s",
27
+ get_request_id(),
28
+ )
29
+ return JSONResponse(
30
+ status_code=500,
31
+ content=dashboard_error("internal_error", "Unexpected error"),
32
+ )
33
+ raise
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ from collections.abc import Awaitable, Callable
5
+ from typing import Protocol
6
+
7
+ import zstandard as zstd
8
+ from fastapi import FastAPI, Request
9
+ from fastapi.responses import JSONResponse, Response
10
+
11
+ from app.core.config.settings import get_settings
12
+ from app.core.errors import dashboard_error
13
+
14
+
15
+ class _DecompressedBodyTooLarge(Exception):
16
+ def __init__(self, max_size: int) -> None:
17
+ super().__init__(f"Decompressed body exceeded {max_size} bytes")
18
+ self.max_size = max_size
19
+
20
+
21
+ class _Readable(Protocol):
22
+ def read(self, size: int = ...) -> bytes: ...
23
+
24
+
25
+ def _read_limited(reader: _Readable, max_size: int) -> bytes:
26
+ buffer = bytearray()
27
+ total = 0
28
+ chunk_size = 64 * 1024
29
+ while True:
30
+ chunk = reader.read(chunk_size)
31
+ if not chunk:
32
+ break
33
+ total += len(chunk)
34
+ if total > max_size:
35
+ raise _DecompressedBodyTooLarge(max_size)
36
+ buffer.extend(chunk)
37
+ return bytes(buffer)
38
+
39
+
40
+ def _replace_request_body(request: Request, body: bytes) -> None:
41
+ request._body = body
42
+ headers: list[tuple[bytes, bytes]] = []
43
+ for key, value in request.scope.get("headers", []):
44
+ if key.lower() in (b"content-encoding", b"content-length"):
45
+ continue
46
+ headers.append((key, value))
47
+ headers.append((b"content-length", str(len(body)).encode("ascii")))
48
+ request.scope["headers"] = headers
49
+ # Ensure subsequent request.headers reflects the updated scope headers.
50
+ request._headers = None
51
+
52
+
53
+ def add_request_decompression_middleware(app: FastAPI) -> None:
54
+ @app.middleware("http")
55
+ async def request_decompression_middleware(
56
+ request: Request,
57
+ call_next: Callable[[Request], Awaitable[Response]],
58
+ ) -> Response:
59
+ content_encoding = request.headers.get("content-encoding")
60
+ if not content_encoding:
61
+ return await call_next(request)
62
+ encodings = [enc.strip().lower() for enc in content_encoding.split(",") if enc.strip()]
63
+ if encodings != ["zstd"]:
64
+ return await call_next(request)
65
+ body = await request.body()
66
+ settings = get_settings()
67
+ max_size = settings.max_decompressed_body_bytes
68
+ try:
69
+ decompressed = zstd.ZstdDecompressor().decompress(body, max_output_size=max_size)
70
+ if len(decompressed) > max_size:
71
+ raise _DecompressedBodyTooLarge(max_size)
72
+ except _DecompressedBodyTooLarge:
73
+ return JSONResponse(
74
+ status_code=413,
75
+ content=dashboard_error(
76
+ "payload_too_large",
77
+ "Request body exceeds the maximum allowed size",
78
+ ),
79
+ )
80
+ except Exception:
81
+ try:
82
+ with zstd.ZstdDecompressor().stream_reader(io.BytesIO(body)) as reader:
83
+ decompressed = _read_limited(reader, max_size)
84
+ except _DecompressedBodyTooLarge:
85
+ return JSONResponse(
86
+ status_code=413,
87
+ content=dashboard_error(
88
+ "payload_too_large",
89
+ "Request body exceeds the maximum allowed size",
90
+ ),
91
+ )
92
+ except Exception:
93
+ return JSONResponse(
94
+ status_code=400,
95
+ content=dashboard_error(
96
+ "invalid_request",
97
+ "Request body is zstd-compressed but could not be decompressed",
98
+ ),
99
+ )
100
+ _replace_request_body(request, decompressed)
101
+ return await call_next(request)