codex-lb 0.1.5__tar.gz → 0.2.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.
- codex_lb-0.2.0/.github/release-please-manifest.json +3 -0
- codex_lb-0.2.0/.pre-commit-config.yaml +14 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/AGENTS.md +17 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/CHANGELOG.md +21 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/PKG-INFO +1 -1
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/__init__.py +1 -1
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/auth/__init__.py +2 -1
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/balancer/logic.py +12 -2
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/clients/proxy.py +2 -4
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/config/settings.py +2 -1
- codex_lb-0.2.0/app/core/plan_types.py +64 -0
- codex_lb-0.2.0/app/core/types.py +6 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/usage/__init__.py +3 -2
- codex_lb-0.2.0/app/core/usage/quota.py +58 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/utils/sse.py +6 -2
- codex_lb-0.2.0/app/db/migrations/__init__.py +80 -0
- codex_lb-0.2.0/app/db/migrations/versions/__init__.py +1 -0
- codex_lb-0.2.0/app/db/migrations/versions/normalize_account_plan_types.py +17 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/db/session.py +14 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/dependencies.py +0 -8
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/main.py +4 -4
- {codex_lb-0.1.5/app/modules/proxy → codex_lb-0.2.0/app/modules/accounts}/auth_manager.py +33 -4
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/accounts/repository.py +3 -3
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/accounts/service.py +10 -7
- codex_lb-0.2.0/app/modules/health/api.py +12 -0
- codex_lb-0.2.0/app/modules/health/schemas.py +9 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/oauth/service.py +5 -1
- codex_lb-0.2.0/app/modules/proxy/helpers.py +285 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/proxy/load_balancer.py +12 -36
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/proxy/service.py +37 -307
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/request_logs/service.py +5 -3
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/usage/service.py +7 -6
- codex_lb-0.1.5/app/modules/proxy/usage_updater.py → codex_lb-0.2.0/app/modules/usage/updater.py +1 -1
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/static/index.js +23 -7
- {codex_lb-0.1.5 → codex_lb-0.2.0}/pyproject.toml +5 -1
- {codex_lb-0.1.5 → codex_lb-0.2.0}/tests/conftest.py +3 -0
- codex_lb-0.2.0/tests/integration/test_migrations.py +56 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/tests/integration/test_proxy_api_extended.py +2 -2
- {codex_lb-0.1.5 → codex_lb-0.2.0}/tests/integration/test_proxy_compact.py +1 -1
- codex_lb-0.2.0/tests/unit/test_auth_manager.py +84 -0
- codex_lb-0.2.0/tests/unit/test_load_balancer.py +129 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/tests/unit/test_usage_client.py +21 -12
- {codex_lb-0.1.5 → codex_lb-0.2.0}/uv.lock +28 -1
- codex_lb-0.1.5/.github/release-please-manifest.json +0 -3
- codex_lb-0.1.5/.pre-commit-config.yaml +0 -7
- codex_lb-0.1.5/app/core/types.py +0 -4
- codex_lb-0.1.5/app/modules/health/api.py +0 -10
- codex_lb-0.1.5/tests/unit/test_load_balancer.py +0 -71
- {codex_lb-0.1.5 → codex_lb-0.2.0}/.dockerignore +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/.env.example +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/.github/release-please-config.json +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/.github/workflows/ci.yml +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/.github/workflows/release-please.yml +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/.github/workflows/release.yml +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/.gitignore +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/Dockerfile +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/LICENSE +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/README.md +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/cli.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/__init__.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/auth/models.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/auth/refresh.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/balancer/__init__.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/balancer/types.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/clients/__init__.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/clients/http.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/clients/oauth.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/clients/usage.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/config/__init__.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/crypto.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/errors.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/openai/__init__.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/openai/models.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/openai/parsing.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/openai/requests.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/usage/logs.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/usage/models.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/usage/pricing.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/usage/types.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/utils/__init__.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/utils/request_id.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/utils/retry.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/core/utils/time.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/db/__init__.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/db/models.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/__init__.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/accounts/__init__.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/accounts/api.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/accounts/schemas.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/health/__init__.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/oauth/__init__.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/oauth/api.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/oauth/schemas.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/oauth/templates/oauth_success.html +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/proxy/__init__.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/proxy/api.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/proxy/schemas.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/proxy/types.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/request_logs/__init__.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/request_logs/api.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/request_logs/repository.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/request_logs/schemas.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/shared/__init__.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/shared/schemas.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/usage/__init__.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/usage/api.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/usage/repository.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/modules/usage/schemas.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/static/7.css +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/static/index.css +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/app/static/index.html +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/docker-compose.yml +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/docs/screenshots/accounts.jpeg +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/docs/screenshots/dashboard.jpeg +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/tests/__init__.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/tests/integration/test_accounts_api.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/tests/integration/test_accounts_api_extended.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/tests/integration/test_codex_usage_api.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/tests/integration/test_db_models.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/tests/integration/test_health_and_errors.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/tests/integration/test_load_balancer_integration.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/tests/integration/test_oauth_flow.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/tests/integration/test_proxy_responses.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/tests/integration/test_repositories.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/tests/integration/test_request_logs_api.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/tests/integration/test_request_logs_filters.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/tests/integration/test_usage_api.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/tests/integration/test_usage_summary.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/tests/unit/test_auth.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/tests/unit/test_auth_refresh.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/tests/unit/test_oauth_client.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/tests/unit/test_pricing.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/tests/unit/test_proxy_utils.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/tests/unit/test_retry.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/tests/unit/test_sse.py +0 -0
- {codex_lb-0.1.5 → codex_lb-0.2.0}/tests/unit/test_usage.py +0 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
repos:
|
|
2
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
3
|
+
rev: v0.14.10
|
|
4
|
+
hooks:
|
|
5
|
+
- id: ruff
|
|
6
|
+
args: ["--fix"]
|
|
7
|
+
- id: ruff-format
|
|
8
|
+
- repo: local
|
|
9
|
+
hooks:
|
|
10
|
+
- id: ty
|
|
11
|
+
name: ty
|
|
12
|
+
entry: .venv/bin/python -m ty check
|
|
13
|
+
language: system
|
|
14
|
+
pass_filenames: false
|
|
@@ -33,3 +33,20 @@
|
|
|
33
33
|
- Services should be instantiated inside context providers and receive repositories via constructor injection.
|
|
34
34
|
- Background tasks or standalone scripts must create and manage their own session; do not reuse request contexts.
|
|
35
35
|
- When adding a new module, define `api.py` endpoints that depend on a module-specific context provider.
|
|
36
|
+
|
|
37
|
+
## Git Workflow & Contribution
|
|
38
|
+
1. **Important**: Create branches, commits, or PRs **only upon explicit user request**. Implicit actions are not allowed.
|
|
39
|
+
2. **Branch Naming**: Use prefixes like `feature/`, `fix/`, `chore/` (e.g., `feature/add-login`).
|
|
40
|
+
3. **Commit Messages**: Follow [Conventional Commits](https://www.conventionalcommits.org/).
|
|
41
|
+
- Format: `<type>(<scope>): <description>`
|
|
42
|
+
- Types: `feat`, `fix`, `docs`, `refactor`, `chore`, `test`
|
|
43
|
+
- Example: `feat(api): add auth endpoint`
|
|
44
|
+
4. **Workflow**:
|
|
45
|
+
```bash
|
|
46
|
+
git checkout -b feature/add-login
|
|
47
|
+
git commit -m "feat(api): add auth endpoint"
|
|
48
|
+
# Only on explicit request:
|
|
49
|
+
git push -u origin feature/add-login
|
|
50
|
+
gh pr create --title "feat(api): add auth" --body "..."
|
|
51
|
+
```
|
|
52
|
+
5. **Best Practices**: Commit often in small units. Do not commit directly to `main`. Always check `git diff` before pushing.
|
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.0](https://github.com/Soju06/codex-lb/compare/v0.1.5...v0.2.0) (2026-01-19)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add ty type checking and pre-commit hook
|
|
9
|
+
* add health response schema and typed context cleanup
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
* normalize stored plan types (pro/team/business/enterprise/edu) so accounts no longer show as unknown
|
|
15
|
+
* prevent rate-limit status when usage is below 100% by using cooldown/backoff and primary-window quota checks
|
|
16
|
+
* surface per-account quota reset times by applying primary/secondary reset windows with fallbacks
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Refactor
|
|
20
|
+
|
|
21
|
+
* move auth/usage helpers into module boundaries and extract proxy helpers
|
|
22
|
+
* tighten typing across services and tests
|
|
23
|
+
|
|
3
24
|
## [0.1.5](https://github.com/Soju06/codex-lb/compare/v0.1.4...v0.1.5) (2026-01-14)
|
|
4
25
|
|
|
5
26
|
|
|
@@ -82,10 +82,11 @@ def extract_id_token_claims(id_token: str) -> IdTokenClaims:
|
|
|
82
82
|
def claims_from_auth(auth: AuthFile) -> AccountClaims:
|
|
83
83
|
claims = extract_id_token_claims(auth.tokens.id_token)
|
|
84
84
|
auth_claims = claims.auth or OpenAIAuthClaims()
|
|
85
|
+
plan_type = auth_claims.chatgpt_plan_type or claims.chatgpt_plan_type
|
|
85
86
|
return AccountClaims(
|
|
86
87
|
account_id=auth.tokens.account_id or auth_claims.chatgpt_account_id or claims.chatgpt_account_id,
|
|
87
88
|
email=claims.email,
|
|
88
|
-
plan_type=
|
|
89
|
+
plan_type=plan_type,
|
|
89
90
|
)
|
|
90
91
|
|
|
91
92
|
|
|
@@ -23,6 +23,7 @@ class AccountState:
|
|
|
23
23
|
status: AccountStatus
|
|
24
24
|
used_percent: float | None = None
|
|
25
25
|
reset_at: float | None = None
|
|
26
|
+
cooldown_until: float | None = None
|
|
26
27
|
last_error_at: float | None = None
|
|
27
28
|
last_selected_at: float | None = None
|
|
28
29
|
error_count: int = 0
|
|
@@ -59,6 +60,12 @@ def select_account(states: Iterable[AccountState], now: float | None = None) ->
|
|
|
59
60
|
state.reset_at = None
|
|
60
61
|
else:
|
|
61
62
|
continue
|
|
63
|
+
if state.cooldown_until and current >= state.cooldown_until:
|
|
64
|
+
state.cooldown_until = None
|
|
65
|
+
state.last_error_at = None
|
|
66
|
+
state.error_count = 0
|
|
67
|
+
if state.cooldown_until and current < state.cooldown_until:
|
|
68
|
+
continue
|
|
62
69
|
if state.error_count >= 3:
|
|
63
70
|
backoff = min(300, 30 * (2 ** (state.error_count - 3)))
|
|
64
71
|
if state.last_error_at and current - state.last_error_at < backoff:
|
|
@@ -82,6 +89,10 @@ def select_account(states: Iterable[AccountState], now: float | None = None) ->
|
|
|
82
89
|
if reset_candidates:
|
|
83
90
|
wait_seconds = max(0, min(reset_candidates) - int(current))
|
|
84
91
|
return SelectionResult(None, f"Rate limit exceeded. Try again in {wait_seconds:.0f}s")
|
|
92
|
+
cooldowns = [s.cooldown_until for s in all_states if s.cooldown_until and s.cooldown_until > current]
|
|
93
|
+
if cooldowns:
|
|
94
|
+
wait_seconds = max(0.0, min(cooldowns) - current)
|
|
95
|
+
return SelectionResult(None, f"Rate limit exceeded. Try again in {wait_seconds:.0f}s")
|
|
85
96
|
return SelectionResult(None, "No available accounts")
|
|
86
97
|
|
|
87
98
|
def _sort_key(state: AccountState) -> tuple[float, float, str]:
|
|
@@ -94,14 +105,13 @@ def select_account(states: Iterable[AccountState], now: float | None = None) ->
|
|
|
94
105
|
|
|
95
106
|
|
|
96
107
|
def handle_rate_limit(state: AccountState, error: UpstreamError) -> None:
|
|
97
|
-
state.status = AccountStatus.RATE_LIMITED
|
|
98
108
|
state.error_count += 1
|
|
99
109
|
state.last_error_at = time.time()
|
|
100
110
|
message = error.get("message")
|
|
101
111
|
delay = parse_retry_after(message) if message else None
|
|
102
112
|
if delay is None:
|
|
103
113
|
delay = backoff_seconds(state.error_count)
|
|
104
|
-
state.
|
|
114
|
+
state.cooldown_until = time.time() + delay
|
|
105
115
|
|
|
106
116
|
|
|
107
117
|
def handle_quota_exceeded(state: AccountState, error: UpstreamError) -> None:
|
|
@@ -18,7 +18,6 @@ IGNORE_INBOUND_HEADERS = {"authorization", "chatgpt-account-id", "content-length
|
|
|
18
18
|
|
|
19
19
|
_ERROR_TYPE_CODE_MAP = {
|
|
20
20
|
"rate_limit_exceeded": "rate_limit_exceeded",
|
|
21
|
-
"usage_limit_reached": "rate_limit_exceeded",
|
|
22
21
|
"usage_not_included": "usage_not_included",
|
|
23
22
|
"insufficient_quota": "insufficient_quota",
|
|
24
23
|
"quota_exceeded": "quota_exceeded",
|
|
@@ -64,12 +63,11 @@ def _normalize_error_code(code: str | None, error_type: str | None) -> str:
|
|
|
64
63
|
if code:
|
|
65
64
|
normalized_code = code.lower()
|
|
66
65
|
mapped = _ERROR_TYPE_CODE_MAP.get(normalized_code)
|
|
67
|
-
return mapped or
|
|
66
|
+
return mapped or normalized_code
|
|
68
67
|
normalized_type = error_type.lower() if error_type else None
|
|
69
68
|
if normalized_type:
|
|
70
69
|
mapped = _ERROR_TYPE_CODE_MAP.get(normalized_type)
|
|
71
|
-
|
|
72
|
-
return mapped
|
|
70
|
+
return mapped or normalized_type
|
|
73
71
|
return "upstream_error"
|
|
74
72
|
|
|
75
73
|
|
|
@@ -39,6 +39,7 @@ class Settings(BaseSettings):
|
|
|
39
39
|
usage_refresh_enabled: bool = True
|
|
40
40
|
usage_refresh_interval_seconds: int = 60
|
|
41
41
|
encryption_key_file: Path = DEFAULT_ENCRYPTION_KEY_FILE
|
|
42
|
+
database_migrations_fail_fast: bool = True
|
|
42
43
|
|
|
43
44
|
@field_validator("database_url")
|
|
44
45
|
@classmethod
|
|
@@ -61,7 +62,7 @@ class Settings(BaseSettings):
|
|
|
61
62
|
return value.expanduser()
|
|
62
63
|
if isinstance(value, str):
|
|
63
64
|
return Path(value).expanduser()
|
|
64
|
-
|
|
65
|
+
raise TypeError("encryption_key_file must be a path")
|
|
65
66
|
|
|
66
67
|
|
|
67
68
|
@lru_cache(maxsize=1)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Final
|
|
4
|
+
|
|
5
|
+
ACCOUNT_PLAN_TYPES: Final[set[str]] = {
|
|
6
|
+
"free",
|
|
7
|
+
"plus",
|
|
8
|
+
"pro",
|
|
9
|
+
"team",
|
|
10
|
+
"business",
|
|
11
|
+
"enterprise",
|
|
12
|
+
"edu",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
RATE_LIMIT_PLAN_TYPES: Final[set[str]] = {
|
|
16
|
+
*ACCOUNT_PLAN_TYPES,
|
|
17
|
+
"guest",
|
|
18
|
+
"go",
|
|
19
|
+
"free_workspace",
|
|
20
|
+
"education",
|
|
21
|
+
"quorum",
|
|
22
|
+
"k12",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _clean_plan_type(value: str | None) -> str | None:
|
|
27
|
+
if value is None:
|
|
28
|
+
return None
|
|
29
|
+
cleaned = value.strip()
|
|
30
|
+
return cleaned or None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def normalize_account_plan_type(value: str | None) -> str | None:
|
|
34
|
+
cleaned = _clean_plan_type(value)
|
|
35
|
+
if not cleaned:
|
|
36
|
+
return None
|
|
37
|
+
normalized = cleaned.lower()
|
|
38
|
+
return normalized if normalized in ACCOUNT_PLAN_TYPES else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def canonicalize_account_plan_type(value: str | None) -> str | None:
|
|
42
|
+
cleaned = _clean_plan_type(value)
|
|
43
|
+
if not cleaned:
|
|
44
|
+
return None
|
|
45
|
+
normalized = cleaned.lower()
|
|
46
|
+
if normalized in ACCOUNT_PLAN_TYPES:
|
|
47
|
+
return normalized
|
|
48
|
+
return cleaned
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def coerce_account_plan_type(value: str | None, default: str) -> str:
|
|
52
|
+
cleaned = _clean_plan_type(value)
|
|
53
|
+
if cleaned is None:
|
|
54
|
+
return default
|
|
55
|
+
canonical = canonicalize_account_plan_type(cleaned)
|
|
56
|
+
return canonical if canonical is not None else default
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def normalize_rate_limit_plan_type(value: str | None) -> str | None:
|
|
60
|
+
cleaned = _clean_plan_type(value)
|
|
61
|
+
if not cleaned:
|
|
62
|
+
return None
|
|
63
|
+
normalized = cleaned.lower()
|
|
64
|
+
return normalized if normalized in RATE_LIMIT_PLAN_TYPES else None
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import Iterable, Mapping
|
|
4
4
|
|
|
5
|
+
from app.core.plan_types import normalize_account_plan_type
|
|
5
6
|
from app.core.usage.types import (
|
|
6
7
|
UsageCostSummary,
|
|
7
8
|
UsageHistoryPayload,
|
|
@@ -134,9 +135,9 @@ def summarize_usage_window(
|
|
|
134
135
|
|
|
135
136
|
|
|
136
137
|
def capacity_for_plan(plan_type: str | None, window: str) -> float | None:
|
|
137
|
-
|
|
138
|
+
normalized = normalize_account_plan_type(plan_type)
|
|
139
|
+
if not normalized:
|
|
138
140
|
return None
|
|
139
|
-
normalized = plan_type.lower()
|
|
140
141
|
window_key = _normalize_window_key(window)
|
|
141
142
|
if window_key == "primary":
|
|
142
143
|
return PLAN_CAPACITY_CREDITS_PRIMARY.get(normalized)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from app.core import usage as usage_core
|
|
6
|
+
from app.db.models import AccountStatus
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def apply_usage_quota(
|
|
10
|
+
*,
|
|
11
|
+
status: AccountStatus,
|
|
12
|
+
primary_used: float | None,
|
|
13
|
+
primary_reset: int | None,
|
|
14
|
+
primary_window_minutes: int | None,
|
|
15
|
+
runtime_reset: float | None,
|
|
16
|
+
secondary_used: float | None,
|
|
17
|
+
secondary_reset: int | None,
|
|
18
|
+
) -> tuple[AccountStatus, float | None, float | None]:
|
|
19
|
+
used_percent = primary_used
|
|
20
|
+
reset_at = runtime_reset
|
|
21
|
+
|
|
22
|
+
if status in (AccountStatus.DEACTIVATED, AccountStatus.PAUSED):
|
|
23
|
+
return status, used_percent, reset_at
|
|
24
|
+
|
|
25
|
+
if secondary_used is not None:
|
|
26
|
+
if secondary_used >= 100.0:
|
|
27
|
+
status = AccountStatus.QUOTA_EXCEEDED
|
|
28
|
+
used_percent = 100.0
|
|
29
|
+
if secondary_reset is not None:
|
|
30
|
+
reset_at = secondary_reset
|
|
31
|
+
return status, used_percent, reset_at
|
|
32
|
+
if status == AccountStatus.QUOTA_EXCEEDED:
|
|
33
|
+
status = AccountStatus.ACTIVE
|
|
34
|
+
reset_at = None
|
|
35
|
+
elif status == AccountStatus.QUOTA_EXCEEDED and secondary_reset is not None:
|
|
36
|
+
reset_at = secondary_reset
|
|
37
|
+
|
|
38
|
+
if primary_used is not None:
|
|
39
|
+
if primary_used >= 100.0:
|
|
40
|
+
status = AccountStatus.RATE_LIMITED
|
|
41
|
+
used_percent = 100.0
|
|
42
|
+
if primary_reset is not None:
|
|
43
|
+
reset_at = primary_reset
|
|
44
|
+
else:
|
|
45
|
+
reset_at = _fallback_primary_reset(primary_window_minutes) or reset_at
|
|
46
|
+
return status, used_percent, reset_at
|
|
47
|
+
if status == AccountStatus.RATE_LIMITED:
|
|
48
|
+
status = AccountStatus.ACTIVE
|
|
49
|
+
reset_at = None
|
|
50
|
+
|
|
51
|
+
return status, used_percent, reset_at
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _fallback_primary_reset(primary_window_minutes: int | None) -> float | None:
|
|
55
|
+
window_minutes = primary_window_minutes or usage_core.default_window_minutes("primary")
|
|
56
|
+
if not window_minutes:
|
|
57
|
+
return None
|
|
58
|
+
return time.time() + float(window_minutes) * 60.0
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
from collections.abc import Mapping
|
|
4
5
|
|
|
5
|
-
from app.core.
|
|
6
|
+
from app.core.errors import ResponseFailedEvent
|
|
7
|
+
from app.core.types import JsonValue
|
|
6
8
|
|
|
9
|
+
type JsonPayload = Mapping[str, JsonValue] | ResponseFailedEvent
|
|
7
10
|
|
|
8
|
-
|
|
11
|
+
|
|
12
|
+
def format_sse_event(payload: JsonPayload) -> str:
|
|
9
13
|
data = json.dumps(payload, ensure_ascii=True, separators=(",", ":"))
|
|
10
14
|
event_type = payload.get("type")
|
|
11
15
|
if isinstance(event_type, str) and event_type:
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Awaitable, Callable, Final
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import text
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
+
|
|
11
|
+
from app.db.migrations.versions import normalize_account_plan_types
|
|
12
|
+
|
|
13
|
+
_CREATE_MIGRATIONS_TABLE = """
|
|
14
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
15
|
+
name TEXT PRIMARY KEY,
|
|
16
|
+
applied_at TEXT NOT NULL
|
|
17
|
+
)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
_INSERT_MIGRATION = """
|
|
21
|
+
INSERT INTO schema_migrations (name, applied_at)
|
|
22
|
+
VALUES (:name, :applied_at)
|
|
23
|
+
ON CONFLICT(name) DO NOTHING
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Migration:
|
|
29
|
+
name: str
|
|
30
|
+
run: Callable[[AsyncSession], Awaitable[None]]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
MIGRATIONS: Final[tuple[Migration, ...]] = (
|
|
34
|
+
Migration("001_normalize_account_plan_types", normalize_account_plan_types.run),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def run_migrations(session: AsyncSession) -> int:
|
|
39
|
+
await _ensure_schema_migrations(session)
|
|
40
|
+
applied_count = 0
|
|
41
|
+
for migration in MIGRATIONS:
|
|
42
|
+
applied_now = await _apply_migration(session, migration)
|
|
43
|
+
if applied_now:
|
|
44
|
+
applied_count += 1
|
|
45
|
+
return applied_count
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def _apply_migration(session: AsyncSession, migration: Migration) -> bool:
|
|
49
|
+
async with _migration_transaction(session):
|
|
50
|
+
result = await session.execute(
|
|
51
|
+
text(_INSERT_MIGRATION),
|
|
52
|
+
{
|
|
53
|
+
"name": migration.name,
|
|
54
|
+
"applied_at": _utcnow_iso(),
|
|
55
|
+
},
|
|
56
|
+
)
|
|
57
|
+
rowcount = getattr(result, "rowcount", 0) or 0
|
|
58
|
+
if not rowcount:
|
|
59
|
+
return False
|
|
60
|
+
await migration.run(session)
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def _ensure_schema_migrations(session: AsyncSession) -> None:
|
|
65
|
+
async with _migration_transaction(session):
|
|
66
|
+
await session.execute(text(_CREATE_MIGRATIONS_TABLE))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@asynccontextmanager
|
|
70
|
+
async def _migration_transaction(session: AsyncSession):
|
|
71
|
+
if session.in_transaction():
|
|
72
|
+
async with session.begin_nested():
|
|
73
|
+
yield
|
|
74
|
+
else:
|
|
75
|
+
async with session.begin():
|
|
76
|
+
yield
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _utcnow_iso() -> str:
|
|
80
|
+
return datetime.now(timezone.utc).isoformat()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import select
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
5
|
+
|
|
6
|
+
from app.core.auth import DEFAULT_PLAN
|
|
7
|
+
from app.core.plan_types import coerce_account_plan_type
|
|
8
|
+
from app.db.models import Account
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def run(session: AsyncSession) -> None:
|
|
12
|
+
result = await session.execute(select(Account))
|
|
13
|
+
accounts = list(result.scalars().all())
|
|
14
|
+
for account in accounts:
|
|
15
|
+
coerced = coerce_account_plan_type(account.plan_type, DEFAULT_PLAN)
|
|
16
|
+
if account.plan_type != coerced:
|
|
17
|
+
account.plan_type = coerced
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import logging
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from typing import AsyncIterator
|
|
6
7
|
|
|
7
8
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
8
9
|
|
|
9
10
|
from app.core.config.settings import get_settings
|
|
11
|
+
from app.db.migrations import run_migrations
|
|
10
12
|
|
|
11
13
|
DATABASE_URL = get_settings().database_url
|
|
12
14
|
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
13
17
|
engine = create_async_engine(DATABASE_URL, echo=False)
|
|
14
18
|
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
|
15
19
|
|
|
@@ -60,3 +64,13 @@ async def init_db() -> None:
|
|
|
60
64
|
|
|
61
65
|
async with engine.begin() as conn:
|
|
62
66
|
await conn.run_sync(Base.metadata.create_all)
|
|
67
|
+
|
|
68
|
+
async with SessionLocal() as session:
|
|
69
|
+
try:
|
|
70
|
+
updated = await run_migrations(session)
|
|
71
|
+
if updated:
|
|
72
|
+
logger.info("Applied database migrations count=%s", updated)
|
|
73
|
+
except Exception:
|
|
74
|
+
logger.exception("Failed to apply database migrations")
|
|
75
|
+
if get_settings().database_migrations_fail_fast:
|
|
76
|
+
raise
|
|
@@ -22,8 +22,6 @@ from app.modules.usage.service import UsageService
|
|
|
22
22
|
class AccountsContext:
|
|
23
23
|
session: AsyncSession
|
|
24
24
|
repository: AccountsRepository
|
|
25
|
-
usage_repository: UsageRepository
|
|
26
|
-
request_logs_repository: RequestLogsRepository
|
|
27
25
|
service: AccountsService
|
|
28
26
|
|
|
29
27
|
|
|
@@ -31,8 +29,6 @@ class AccountsContext:
|
|
|
31
29
|
class UsageContext:
|
|
32
30
|
session: AsyncSession
|
|
33
31
|
usage_repository: UsageRepository
|
|
34
|
-
request_logs_repository: RequestLogsRepository
|
|
35
|
-
accounts_repository: AccountsRepository
|
|
36
32
|
service: UsageService
|
|
37
33
|
|
|
38
34
|
|
|
@@ -63,8 +59,6 @@ def get_accounts_context(
|
|
|
63
59
|
return AccountsContext(
|
|
64
60
|
session=session,
|
|
65
61
|
repository=repository,
|
|
66
|
-
usage_repository=usage_repository,
|
|
67
|
-
request_logs_repository=request_logs_repository,
|
|
68
62
|
service=service,
|
|
69
63
|
)
|
|
70
64
|
|
|
@@ -79,8 +73,6 @@ def get_usage_context(
|
|
|
79
73
|
return UsageContext(
|
|
80
74
|
session=session,
|
|
81
75
|
usage_repository=usage_repository,
|
|
82
|
-
request_logs_repository=request_logs_repository,
|
|
83
|
-
accounts_repository=accounts_repository,
|
|
84
76
|
service=service,
|
|
85
77
|
)
|
|
86
78
|
|
|
@@ -11,7 +11,7 @@ from fastapi.exception_handlers import (
|
|
|
11
11
|
request_validation_exception_handler,
|
|
12
12
|
)
|
|
13
13
|
from fastapi.exceptions import RequestValidationError
|
|
14
|
-
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
|
14
|
+
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
|
|
15
15
|
from fastapi.staticfiles import StaticFiles
|
|
16
16
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
17
17
|
|
|
@@ -57,7 +57,7 @@ def create_app() -> FastAPI:
|
|
|
57
57
|
return response
|
|
58
58
|
|
|
59
59
|
@app.middleware("http")
|
|
60
|
-
async def api_unhandled_error_middleware(request: Request, call_next) ->
|
|
60
|
+
async def api_unhandled_error_middleware(request: Request, call_next) -> Response:
|
|
61
61
|
try:
|
|
62
62
|
return await call_next(request)
|
|
63
63
|
except Exception:
|
|
@@ -76,7 +76,7 @@ def create_app() -> FastAPI:
|
|
|
76
76
|
async def _validation_error_handler(
|
|
77
77
|
request: Request,
|
|
78
78
|
exc: RequestValidationError,
|
|
79
|
-
) ->
|
|
79
|
+
) -> Response:
|
|
80
80
|
if request.url.path.startswith("/api/"):
|
|
81
81
|
return JSONResponse(
|
|
82
82
|
status_code=422,
|
|
@@ -88,7 +88,7 @@ def create_app() -> FastAPI:
|
|
|
88
88
|
async def _http_error_handler(
|
|
89
89
|
request: Request,
|
|
90
90
|
exc: StarletteHTTPException,
|
|
91
|
-
) ->
|
|
91
|
+
) -> Response:
|
|
92
92
|
if request.url.path.startswith("/api/"):
|
|
93
93
|
detail = exc.detail if isinstance(exc.detail, str) else "Request failed"
|
|
94
94
|
return JSONResponse(
|
|
@@ -1,15 +1,39 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from app.core.auth import DEFAULT_PLAN
|
|
3
7
|
from app.core.auth.refresh import RefreshError, refresh_access_token, should_refresh
|
|
4
8
|
from app.core.balancer import PERMANENT_FAILURE_CODES
|
|
5
9
|
from app.core.crypto import TokenEncryptor
|
|
10
|
+
from app.core.plan_types import coerce_account_plan_type
|
|
6
11
|
from app.core.utils.time import utcnow
|
|
7
12
|
from app.db.models import Account, AccountStatus
|
|
8
|
-
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AccountsRepositoryPort(Protocol):
|
|
16
|
+
async def update_status(
|
|
17
|
+
self,
|
|
18
|
+
account_id: str,
|
|
19
|
+
status: AccountStatus,
|
|
20
|
+
deactivation_reason: str | None = None,
|
|
21
|
+
) -> bool: ...
|
|
22
|
+
|
|
23
|
+
async def update_tokens(
|
|
24
|
+
self,
|
|
25
|
+
account_id: str,
|
|
26
|
+
access_token_encrypted: bytes,
|
|
27
|
+
refresh_token_encrypted: bytes,
|
|
28
|
+
id_token_encrypted: bytes,
|
|
29
|
+
last_refresh: datetime,
|
|
30
|
+
plan_type: str | None = None,
|
|
31
|
+
email: str | None = None,
|
|
32
|
+
) -> bool: ...
|
|
9
33
|
|
|
10
34
|
|
|
11
35
|
class AuthManager:
|
|
12
|
-
def __init__(self, repo:
|
|
36
|
+
def __init__(self, repo: AccountsRepositoryPort) -> None:
|
|
13
37
|
self._repo = repo
|
|
14
38
|
self._encryptor = TokenEncryptor()
|
|
15
39
|
|
|
@@ -34,8 +58,13 @@ class AuthManager:
|
|
|
34
58
|
account.refresh_token_encrypted = self._encryptor.encrypt(result.refresh_token)
|
|
35
59
|
account.id_token_encrypted = self._encryptor.encrypt(result.id_token)
|
|
36
60
|
account.last_refresh = utcnow()
|
|
37
|
-
if result.plan_type:
|
|
38
|
-
account.plan_type =
|
|
61
|
+
if result.plan_type is not None:
|
|
62
|
+
account.plan_type = coerce_account_plan_type(
|
|
63
|
+
result.plan_type,
|
|
64
|
+
account.plan_type or DEFAULT_PLAN,
|
|
65
|
+
)
|
|
66
|
+
elif not account.plan_type:
|
|
67
|
+
account.plan_type = DEFAULT_PLAN
|
|
39
68
|
if result.email:
|
|
40
69
|
account.email = result.email
|
|
41
70
|
|
|
@@ -48,12 +48,12 @@ class AccountsRepository:
|
|
|
48
48
|
.values(status=status, deactivation_reason=deactivation_reason)
|
|
49
49
|
)
|
|
50
50
|
await self._session.commit()
|
|
51
|
-
return bool(result
|
|
51
|
+
return bool(getattr(result, "rowcount", 0) or 0)
|
|
52
52
|
|
|
53
53
|
async def delete(self, account_id: str) -> bool:
|
|
54
54
|
result = await self._session.execute(delete(Account).where(Account.id == account_id))
|
|
55
55
|
await self._session.commit()
|
|
56
|
-
return bool(result
|
|
56
|
+
return bool(getattr(result, "rowcount", 0) or 0)
|
|
57
57
|
|
|
58
58
|
async def update_tokens(
|
|
59
59
|
self,
|
|
@@ -77,4 +77,4 @@ class AccountsRepository:
|
|
|
77
77
|
values["email"] = email
|
|
78
78
|
result = await self._session.execute(update(Account).where(Account.id == account_id).values(**values))
|
|
79
79
|
await self._session.commit()
|
|
80
|
-
return bool(result
|
|
80
|
+
return bool(getattr(result, "rowcount", 0) or 0)
|