refactor: split http services and codex domains

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-13 07:17:03 +08:00
co-authored by Codex
parent 01b8548180
commit e5b09ef8ce
176 changed files with 15363 additions and 12985 deletions
-6
View File
@@ -1,6 +0,0 @@
CPRS_HOST=127.0.0.1
CPRS_PORT=8080
CPRS_DATABASE_URL=sqlite://data/codex-proxy-rs.sqlite
CPRS_MASTER_KEY_FILE=data/master.key
CPRS_API_KEY_PEPPER_FILE=data/api-key-pepper.key
RUST_LOG=codex_proxy_rs=info,tower_http=info
+2 -5
View File
@@ -1,11 +1,8 @@
/target/
/data/
/logs/
/.env
/.env.*
!/.env.example
/config/local.yaml
/config/local.yml
/local.yaml
/local.yml
/.worktrees/
/worktrees/
*.sqlite
Generated
-1
View File
@@ -376,7 +376,6 @@ dependencies = [
"base64",
"chrono",
"config",
"dotenvy",
"futures",
"hmac",
"insta",
-1
View File
@@ -17,7 +17,6 @@ axum = "0.8.9"
base64 = "0.22.1"
chrono = { version = "0.4.45", features = ["serde", "clock"] }
config = "0.15.23"
dotenvy = "0.15.7"
futures = "0.3.32"
hmac = "0.13.0"
rand = "0.10.1"
+1 -2
View File
@@ -14,10 +14,9 @@ RUN apt-get update \
WORKDIR /app
COPY --from=builder /app/target/release/codex-proxy-rs /usr/local/bin/codex-proxy-rs
COPY config ./config
COPY config.yaml ./config.yaml
COPY migrations ./migrations
ENV RUST_LOG=codex_proxy_rs=info,tower_http=info
EXPOSE 8080
CMD ["codex-proxy-rs"]
+3
View File
@@ -29,6 +29,9 @@ Rust rewrite of Codex Proxy focused only on ChatGPT/Codex accounts and the Codex
## Development
Runtime config is loaded from `config.yaml` in the project root, then optional
`local.yaml` / `local.yml`.
```bash
cargo fmt --check
cargo clippy --all-targets --all-features --locked -- -D warnings
+1 -1
View File
@@ -1,5 +1,5 @@
server:
host: 127.0.0.1
host: 0.0.0.0
port: 8080
api:
base_url: https://chatgpt.com/backend-api
-7
View File
@@ -3,13 +3,6 @@ services:
build: .
ports:
- "8080:8080"
environment:
CPRS_HOST: 0.0.0.0
CPRS_PORT: 8080
CPRS_DATABASE_URL: sqlite://data/codex-proxy-rs.sqlite
CPRS_MASTER_KEY_FILE: data/master.key
CPRS_API_KEY_PEPPER_FILE: data/api-key-pepper.key
RUST_LOG: codex_proxy_rs=info,tower_http=info
volumes:
- ./data:/app/data
- ./logs:/app/logs
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -6,7 +6,7 @@ Update this file before each feature commit.
| --- | --- | --- | --- | --- |
| Scaffold | Completed | `fc47967` | `cargo fmt --check`; `cargo test`; `cargo clippy --all-targets --all-features --locked -- -D warnings` | Rust crate, lint policy, pinned TLS-sensitive dependencies, `.gitignore`, and minimal entry points are in place. |
| API contract docs | Completed | `fc47967` | `cargo fmt --check`; `cargo test`; `cargo clippy --all-targets --all-features --locked -- -D warnings` | `/v1/*` OpenAI-compatible body, `/admin/*` frontend envelope, body codes, camelCase, and request ID policy are documented. |
| Configuration | Completed | `9ed8579` + `e0680bf` + `cfec8ba` | `cargo test default_config_keeps_only_codex_backend`; `cargo test config_loader_merges_default_local_and_cprs_env_overrides`; full `fmt/test/clippy` before commit | Defines Codex-only config, default/local/env loading, default YAML, `Arc<AppServices>` state shell, `gpt-5.5` default model, and in-scope model/auth/quota/usage/logging settings fields without proxy/Ollama/provider/OpenAI-key knobs. |
| Configuration | Completed | `9ed8579` + `e0680bf` + `cfec8ba` | `cargo test default_config_keeps_only_codex_backend`; `cargo test config_loader_merges_config_and_local_yaml`; full `fmt/test/clippy` before commit | Defines Codex-only config, root `config.yaml` plus optional local YAML loading, `Arc<AppServices>` state shell, `gpt-5.5` default model, and in-scope model/auth/quota/usage/logging settings fields without proxy/Ollama/provider/OpenAI-key knobs. |
| SQLite storage | Completed | `ffec8b5` + `b0c10f2` | `cargo test sqlite_schema_creates_accounts_and_event_tables`; `cargo test connect_sqlite_creates_parent_directory_for_file_database`; full `fmt/test/clippy` before commit | Creates SQLite WAL connector, parent directory bootstrap, sqlx-managed schema for accounts/API keys/sessions/cookies/fingerprints/events, and event/account indexes. |
| Admin auth and client API keys | Partial | `24d5799` + `4e0d1e5` + `e9fdea0` + `8af350d` + `960bbb2` | `cargo test client_api_key_has_proxy_prefix_and_verifies_against_hash`; `cargo test admin_password_hash_is_not_a_client_api_key`; `cargo test --test admin_login_route_test`; `cargo test --test admin_api_keys_route_test`; `cargo fmt --check`; `cargo test`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; full `fmt/test/clippy` before `8af350d`; full `fmt/test/clippy` before `960bbb2`; current local key import/export targeted `cargo test --test admin_api_keys_route_test` | Admin passwords use Argon2id, client API keys use `cpr_` prefix plus HMAC-SHA256 with server-side pepper; `/admin/login` issues HttpOnly sessions and rejects client API keys as admin credentials. `/admin/api-keys` can create/list local client keys, status-enable/status-disable local client keys, label local client keys, delete local client keys, batch-delete local client keys, export local client key metadata, import metadata by rotating to new local `cpr_` keys, and `/v1/*` verifies only enabled keys against SQLite instead of accepting any `cpr_` string. These routes are Rust-local client auth utilities, not TypeScript third-party provider API-key parity. Provider API key pools and provider key import/export remain out of scope; local `cpr_` key plaintext and key hashes are not exportable after HMAC storage, and import returns newly generated plaintext values only once. |
| Secret encryption | Completed | `105aa58` + `228febd` | `cargo test secret_box_encrypts_and_decrypts_without_storing_plaintext`; `cargo test secret_box_load_or_create_should_reuse_existing_master_key_file`; full `fmt/test/clippy` before commit | AES-256-GCM `SecretBox` stores upstream secrets as `v1:<nonce>:<ciphertext>` without plaintext and can load/create the runtime master key file used by repositories. |
@@ -23,4 +23,4 @@ Update this file before each feature commit.
| Upstream lifecycle | Partial | `88cad87` + `225fbff` + `c2cf7a6` + `636fa79` + `ee0ed90` + `fdb9374` + `5bf012c` + `9ca5c7d` + `b9c30ba` + `350ffbe` + `3ecd5b6` + `3960a35` + `bfe561d` + `6d18d2c` | `cargo test responses_route_rejects_non_codex_provider_models`; `cargo test v1_requires_client_api_key_not_admin_cookie`; `cargo test --test codex_sse_usage_test`; `cargo test --test codex_client_test`; `cargo test --test oauth_refresh_test`; `cargo test --test v1_upstream_route_test`; `cargo test --test account_pool_startup_test`; `cargo test --test codex_websocket_test`; `cargo test --test admin_accounts_route_test`; Task 5 targeted `cargo test --test v1_upstream_route_test`, `cargo test --test chat_completions_route_test`, `cargo test --test account_pool_scheduling_test`, and WebSocket route tests; HTTP SSE error mapping targeted `cargo test --test v1_upstream_route_test v1_responses_non_stream_should_return_upstream_error` and `cargo test --test v1_upstream_route_test v1_responses_stream_should_passthrough`; full `cargo fmt --check && cargo test && cargo clippy --all-targets --all-features --locked -- -D warnings` before `b9c30ba` and `350ffbe` | `/v1` enforces client API key shape, rejects non-Codex provider models through the model catalog, returns OpenAI-compatible errors, includes reusable SSE/usage helpers, sends Codex Desktop HTTP SSE requests through imported accounts, sends WebSocket `response.create` requests when `previous_response_id` requires upstream history, keeps `previous_response_id` requests account-affine on classified WebSocket handshake failures for streaming and non-streaming clients, replays/captures encrypted Cookies, captures successful WebSocket rate-limit headers, forwards Codex request context headers, fetches Codex usage/quota through imported account tokens for admin health/quota operations, normalizes `service_tier: fast` to upstream `priority`, collects `response.completed`, reconstructs missing assistant output from `response.output_item.done` or text deltas, synchronizes `output_text` for non-streaming clients, maps non-streaming HTTP SSE `error` and `response.failed` terminal frames to OpenAI-compatible `upstream_error` bodies, passes through Responses streaming SSE by default while marking `error` and `response.failed` terminal frames as failed lifecycle logs, adapts Chat Completions responses/chunks, refreshes and retries once after upstream 401 when a refresh token is stored, parses Retry-After plus Codex reset metadata from upstream errors, records 429 request attempts, classifies upstream 429/402/403 into account cooldown/quota/banned/Cloudflare state changes, retries non-streaming `/v1/responses`, HTTP SSE `/v1/responses` setup, non-history WebSocket setup, and `/v1/chat/completions` with another imported account when available, records token usage, writes v1 lifecycle event logs, records refresh failure classification for upstream 401, and restores the account pool from SQLite on startup. Still missing durable 429 quota cooldown persistence because the current account schema has no dedicated cooldown column, and live Chat streaming transform. |
| Codex WebSocket boundary | Completed | `cc0516d` + `b9c30ba` + `350ffbe` + `6d18d2c` | `cargo test --test codex_websocket_test`; `cargo test --test v1_upstream_route_test v1_responses_should_use_websocket_for_previous_response_id_streaming`; WebSocket route fallback tests; full `cargo fmt --check && cargo test && cargo clippy --all-targets --all-features --locked -- -D warnings` before `b9c30ba` and `350ffbe` | Adds `src/codex/websocket.rs` with tested transport classification for HTTP SSE versus WebSocket-only requests. `previous_response_id` and explicit WebSocket requests use typed transport selection, local-only `use_websocket` is not serialized into upstream JSON, WebSocket `response.create` carries Codex headers and parity fields, incoming WebSocket messages are emitted as SSE-compatible events, handshake HTTP failures surface as upstream errors before request body send, successful handshake headers capture turn state, cookies, and rate-limit headers, non-history WebSocket setup can fallback to another imported account, and `previous_response_id` WebSocket failures remain on the original account. |
| Fingerprint updates | Partial | `dd26d31` + `e32220a` | `cargo test update_manifest_updates_app_version_and_build_number`; `cargo test fingerprint_updater_fetches_manifest_and_persists_history`; full `fmt/test/clippy` before commit | Parses a JSON update manifest and persists app version/build number history to SQLite. Still missing original appcast XML polling, update-state persistence, runtime header fingerprint selection from the latest stored fingerprint, and optional local Codex.app Chromium extraction. |
| Runtime docs and packaging | Completed | `33b63f3` | `cargo fmt --check`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; `cargo test`; `cargo run`; `curl -s -i http://127.0.0.1:8080/health` | README scope, `.env.example`, Dockerfile, compose file, and health smoke test are complete. |
| Runtime docs and packaging | Completed | `33b63f3` | `cargo fmt --check`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; `cargo test`; `cargo run`; `curl -s -i http://127.0.0.1:8080/health` | README scope, root runtime config, Dockerfile, compose file, and health smoke test are complete. |
@@ -6,7 +6,7 @@
**Architecture:** Create a new Rust service at `/home/zyy/桌面/Codes/codex-proxy-rs`. The only upstream is `https://chatgpt.com/backend-api`; remove OpenAI official API key passthrough, Anthropic, Gemini, custom providers, Ollama, Electron, and per-account proxy assignment. Treat Codex Desktop impersonation as a first-class subsystem: locked `reqwest` + `rustls`, exact headers, account-scoped Cookie replay, and fingerprint auto-update. Keep `src/main.rs` as the binary/bootstrap layer and keep reusable logic in `src/lib.rs` modules with typed `thiserror` errors.
**Tech Stack:** Rust 2021, `tokio`, `axum`, `tower-http`, `sqlx` + SQLite WAL, `config`, `dotenvy`, `serde`, `serde_json`, `serde_yaml`, `tracing`, `tracing-subscriber`, rotating file logging, `argon2`, `rand`, `uuid`, `chrono`, `thiserror`, `anyhow` only in `main.rs` and tests, `base64`, `aes-gcm`, `hmac`, `sha2`, `secrecy`, `zeroize`, `wiremock`, `insta`, `tempfile`. Use the latest stable crate versions at implementation time except dependencies intentionally pinned for Codex Desktop TLS fingerprint parity: `reqwest = 0.12.28` and `rustls = 0.23.36` until a fingerprint review proves a newer pair is equivalent.
**Tech Stack:** Rust 2021, `tokio`, `axum`, `tower-http`, `sqlx` + SQLite WAL, `config`, `serde`, `serde_json`, `serde_yaml`, `tracing`, `tracing-subscriber`, rotating file logging, `argon2`, `rand`, `uuid`, `chrono`, `thiserror`, `anyhow` only in `main.rs` and tests, `base64`, `aes-gcm`, `hmac`, `sha2`, `secrecy`, `zeroize`, `wiremock`, `insta`, `tempfile`. Use the latest stable crate versions at implementation time except dependencies intentionally pinned for Codex Desktop TLS fingerprint parity: `reqwest = 0.12.28` and `rustls = 0.23.36` until a fingerprint review proves a newer pair is equivalent.
**Execution Mode:** Subagent-Driven. Execute one task at a time with implementation, spec review, and code-quality review before moving to the next task. After each completed task, update this plan's checkbox state and `docs/implementation-status.md` before committing.
@@ -50,7 +50,7 @@ These adjustments come from the `rust-best-practices` review and should be treat
- Hash admin passwords with Argon2id. Hash high-entropy client API keys with HMAC-SHA256 using a server-side pepper, not with the API key display name.
- Prefer concrete service structs over early `dyn Trait` abstraction. Use generics for test seams where useful; introduce trait objects only at boundaries that actually need runtime polymorphism.
- Keep shared runtime state in an `Arc<AppServices>` container that holds cheap-clone handles (`SqlitePool`, `reqwest::Client`, repositories, secret box, config). Avoid mutable globals and avoid `std::sync::Mutex` across async `.await`; use SQLite, channels, or `tokio::sync` primitives.
- Use the `config` crate with `config/default.yaml`, optional `config/local.yaml`, and `CPRS_*` environment overrides. Keep `.env` support via `dotenvy` for local development only.
- Use the `config` crate with root `config.yaml` plus optional root `local.yaml` or `local.yml`. Do not use environment-variable overrides for runtime config.
- Prefer borrowed parameters in public APIs (`&str`, `&[T]`, references to request structs) unless ownership transfer is part of the domain model.
- Keep tests descriptive and targeted. Unit tests live beside modules for private behavior; integration tests under `tests/` cover route and storage behavior. Use `insta` only for structured payload snapshots that are worth reviewing.
- Add sparse Chinese comments only where the reason is not obvious from names or types. Good targets are protocol/security/business invariants: TLS fingerprint pinning, Codex Desktop header parity, account-scoped Cookie replay, admin-session versus client-API-key boundaries, and token refresh invariants. Do not comment every line, do not restate obvious code, and do not leave untracked TODO comments.
@@ -193,7 +193,7 @@ codex-proxy-rs/
Cargo.toml
rust-toolchain.toml
README.md
config/default.yaml
config.yaml
docs/api.md
docs/dependency-policy.md
docs/implementation-status.md
@@ -305,7 +305,7 @@ Expected: `git status --short` prints no tracked files yet.
Run these commands so non-fingerprint dependencies resolve to the latest stable versions available at implementation time:
```bash
cargo add anyhow aes-gcm argon2 async-stream base64 bytes dotenvy futures-util hmac http http-body-util rand serde_json serde_yaml sha2 thiserror tracing tracing-appender uuid zeroize
cargo add anyhow aes-gcm argon2 async-stream base64 bytes futures-util hmac http http-body-util rand serde_json serde_yaml sha2 thiserror tracing tracing-appender uuid zeroize
cargo add axum --features macros,ws
cargo add chrono --features serde
cargo add config
@@ -340,7 +340,6 @@ base64 = "0.22"
bytes = "1"
chrono = { version = "0.4", features = ["serde"] }
config = "0.15"
dotenvy = "0.15"
futures-util = "0.3"
hmac = "0.12"
http = "1"
@@ -715,7 +714,7 @@ git commit -m "docs: define api contracts and status codes"
### Task 2: Configuration and Application State
**Files:**
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/config/default.yaml`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/config.yaml`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/config.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/state.rs`
- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs`
@@ -875,7 +874,7 @@ impl AppState {
}
```
Create `/home/zyy/桌面/Codes/codex-proxy-rs/config/default.yaml`:
Create `/home/zyy/桌面/Codes/codex-proxy-rs/config.yaml`:
```yaml
server:
@@ -1739,8 +1738,8 @@ git commit -m "feat: add paginated event logs"
### Task 6: Codex Fingerprint, Header Generation, and TLS Client
**Files:**
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/fingerprint/mod.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/fingerprint/model.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/fingerprint/mod.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/fingerprint/model.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/mod.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/headers.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/client.rs`
@@ -1753,8 +1752,8 @@ Create `/home/zyy/桌面/Codes/codex-proxy-rs/tests/codex_headers_test.rs`:
```rust
use codex_proxy_rs::{
codex::headers::build_codex_headers,
fingerprint::model::Fingerprint,
codex::transport::headers::build_codex_headers,
codex::fingerprint::model::Fingerprint,
};
#[test]
@@ -1791,21 +1790,21 @@ pub mod codex;
pub mod config;
pub mod crypto;
pub mod error;
pub mod fingerprint;
pub mod logs;
pub mod pagination;
pub mod state;
pub mod storage;
```
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/fingerprint/mod.rs`:
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/fingerprint/mod.rs`:
```rust
pub mod model;
pub mod updater;
```
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/fingerprint/model.rs`:
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/fingerprint/model.rs`:
```rust
use serde::{Deserialize, Serialize};
@@ -1860,7 +1859,7 @@ Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/headers.rs`:
```rust
use std::collections::BTreeMap;
use crate::fingerprint::model::Fingerprint;
use crate::codex::fingerprint::model::Fingerprint;
pub fn build_codex_headers(
fp: &Fingerprint,
@@ -1907,7 +1906,7 @@ pub fn build_reqwest_client(force_http11: bool) -> Result<Client, reqwest::Error
Create empty module files:
```bash
touch src/fingerprint/updater.rs src/codex/sse.rs src/codex/types.rs src/codex/usage.rs src/codex/websocket.rs
touch src/codex/fingerprint/updater.rs src/codex/sse.rs src/codex/types.rs src/codex/usage.rs src/codex/websocket.rs
```
- [x] **Step 4: Verify headers**
@@ -1924,7 +1923,7 @@ Expected: test passes; `cargo tree` shows `reqwest v0.12.28` and `rustls v0.23.3
- [x] **Step 5: Commit TLS and headers foundation**
```bash
git add Cargo.toml src/codex src/fingerprint src/lib.rs tests/codex_headers_test.rs
git add Cargo.toml src/codex src/codex/fingerprint src/lib.rs tests/codex_headers_test.rs
git commit -m "feat: add codex tls client and headers"
```
@@ -1933,9 +1932,9 @@ git commit -m "feat: add codex tls client and headers"
### Task 7: Account Cookie Persistence
**Files:**
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/cookies/mod.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/cookies/jar.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/cookies/repository.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/cookies/mod.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/cookies/jar.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/cookies/repository.rs`
- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs`
- Test: `/home/zyy/桌面/Codes/codex-proxy-rs/tests/cookie_store_test.rs`
@@ -1944,7 +1943,7 @@ git commit -m "feat: add codex tls client and headers"
Create `/home/zyy/桌面/Codes/codex-proxy-rs/tests/cookie_store_test.rs`:
```rust
use codex_proxy_rs::cookies::jar::CookieJar;
use codex_proxy_rs::codex::cookies::jar::CookieJar;
#[test]
fn cookie_jar_captures_and_replays_account_scoped_cookies() {
@@ -1978,21 +1977,21 @@ pub mod config;
pub mod cookies;
pub mod crypto;
pub mod error;
pub mod fingerprint;
pub mod logs;
pub mod pagination;
pub mod state;
pub mod storage;
```
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/cookies/mod.rs`:
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/cookies/mod.rs`:
```rust
pub mod jar;
pub mod repository;
```
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/cookies/jar.rs`:
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/cookies/jar.rs`:
```rust
use std::collections::HashMap;
@@ -2041,7 +2040,7 @@ impl CookieJar {
}
```
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/cookies/repository.rs`:
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/cookies/repository.rs`:
```rust
#[derive(Debug, Clone)]
@@ -2061,7 +2060,7 @@ Expected: pass.
- [x] **Step 5: Commit cookies**
```bash
git add src/cookies src/lib.rs tests/cookie_store_test.rs
git add src/codex/cookies src/lib.rs tests/cookie_store_test.rs
git commit -m "feat: add account scoped cookie jar"
```
@@ -2070,14 +2069,14 @@ git commit -m "feat: add account scoped cookie jar"
### Task 8: Account Model, Pool, and Refresh Scheduler Compatibility
**Files:**
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/accounts/mod.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/accounts/model.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/accounts/pool.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/accounts/repository.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/accounts/lifecycle.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/auth/refresh.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/auth/token.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/auth/oauth.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/accounts/mod.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/accounts/model.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/accounts/pool.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/accounts/repository.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/accounts/lifecycle.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/oauth/refresh.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/oauth/token.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/oauth/oauth.rs`
- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs`
- Test: `/home/zyy/桌面/Codes/codex-proxy-rs/tests/refresh_scheduler_test.rs`
@@ -2086,7 +2085,7 @@ git commit -m "feat: add account scoped cookie jar"
Create `/home/zyy/桌面/Codes/codex-proxy-rs/tests/refresh_scheduler_test.rs`:
```rust
use codex_proxy_rs::accounts::{
use codex_proxy_rs::codex::accounts::{
model::{Account, AccountStatus},
pool::AccountPool,
};
@@ -2120,21 +2119,21 @@ Expected: compile failure because accounts module does not exist.
Modify `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs`:
```rust
pub mod accounts;
pub mod auth;
pub mod codex;
pub mod config;
pub mod cookies;
pub mod crypto;
pub mod error;
pub mod fingerprint;
pub mod logs;
pub mod pagination;
pub mod state;
pub mod storage;
```
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/accounts/mod.rs`:
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/accounts/mod.rs`:
```rust
pub mod lifecycle;
@@ -2143,7 +2142,7 @@ pub mod pool;
pub mod repository;
```
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/accounts/model.rs`:
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/accounts/model.rs`:
```rust
use chrono::Utc;
@@ -2193,12 +2192,12 @@ impl Account {
}
```
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/accounts/pool.rs`:
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/accounts/pool.rs`:
```rust
use std::collections::BTreeMap;
use crate::accounts::model::{Account, AccountStatus};
use crate::codex::accounts::model::{Account, AccountStatus};
#[derive(Debug, Default)]
pub struct AccountPool {
@@ -2220,14 +2219,14 @@ impl AccountPool {
}
```
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/accounts/repository.rs`:
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/accounts/repository.rs`:
```rust
#[derive(Debug, Clone)]
pub struct AccountRepository;
```
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/accounts/lifecycle.rs`:
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/accounts/lifecycle.rs`:
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -2241,7 +2240,7 @@ pub enum AccountLifecycleEvent {
}
```
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/auth/refresh.rs`:
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/oauth/refresh.rs`:
```rust
#[derive(Debug, Clone, Copy)]
@@ -2251,7 +2250,7 @@ pub struct RefreshPolicy {
}
```
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/auth/token.rs`:
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/oauth/token.rs`:
```rust
#[derive(Debug, Clone)]
@@ -2261,7 +2260,7 @@ pub struct TokenPair {
}
```
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/auth/oauth.rs`:
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/oauth/oauth.rs`:
```rust
#[derive(Debug, Clone)]
@@ -2295,7 +2294,7 @@ Expected: pass.
- [x] **Step 5: Commit account foundation**
```bash
git add src/accounts src/auth src/lib.rs tests/refresh_scheduler_test.rs
git add src/codex/accounts src/auth src/lib.rs tests/refresh_scheduler_test.rs
git commit -m "feat: add account pool foundation"
```
@@ -2304,10 +2303,10 @@ git commit -m "feat: add account pool foundation"
### Task 9: Translation Layer for OpenAI Chat and Codex Responses
**Files:**
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/translation/mod.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/translation/schema.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/translation/openai_to_codex.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/translation/codex_to_openai.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/protocol/mod.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/protocol/schema.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/protocol/openai_to_codex.rs`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/protocol/codex_to_openai.rs`
- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/types.rs`
- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs`
- Test: `/home/zyy/桌面/Codes/codex-proxy-rs/tests/routes_chat_test.rs`
@@ -2317,7 +2316,7 @@ git commit -m "feat: add account pool foundation"
Create `/home/zyy/桌面/Codes/codex-proxy-rs/tests/routes_chat_test.rs`:
```rust
use codex_proxy_rs::translation::openai_to_codex::{translate_chat_to_codex, ChatCompletionRequest, ChatMessage};
use codex_proxy_rs::codex::protocol::openai_to_codex::{translate_chat_to_codex, ChatCompletionRequest, ChatMessage};
#[test]
fn chat_completion_translates_to_codex_response_request() {
@@ -2352,22 +2351,22 @@ Expected: compile failure because translation module does not exist.
Modify `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs`:
```rust
pub mod accounts;
pub mod auth;
pub mod codex;
pub mod config;
pub mod cookies;
pub mod crypto;
pub mod error;
pub mod fingerprint;
pub mod logs;
pub mod pagination;
pub mod state;
pub mod storage;
pub mod translation;
```
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/translation/mod.rs`:
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/protocol/mod.rs`:
```rust
pub mod codex_to_openai;
@@ -2395,13 +2394,13 @@ pub struct CodexResponsesRequest {
}
```
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/translation/openai_to_codex.rs`:
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/protocol/openai_to_codex.rs`:
```rust
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::{codex::types::CodexResponsesRequest, error::{AppError, AppResult}};
use crate::{codex::transport::types::CodexResponsesRequest, error::{AppError, AppResult}};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatCompletionRequest {
@@ -2437,7 +2436,7 @@ pub fn translate_chat_to_codex(req: ChatCompletionRequest) -> AppResult<CodexRes
}
```
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/translation/codex_to_openai.rs`:
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/protocol/codex_to_openai.rs`:
```rust
use serde_json::{json, Value};
@@ -2454,7 +2453,7 @@ pub fn openai_error(message: &str, code: &str) -> Value {
}
```
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/translation/schema.rs`:
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/protocol/schema.rs`:
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -2478,7 +2477,7 @@ Expected: pass.
- [x] **Step 5: Commit translation layer**
```bash
git add src/translation src/codex/types.rs src/lib.rs tests/routes_chat_test.rs
git add src/codex/protocol src/codex/types.rs src/lib.rs tests/routes_chat_test.rs
git commit -m "feat: add openai to codex translation"
```
@@ -2550,7 +2549,7 @@ Expected: compile failure because app/http modules do not exist.
Modify `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs`:
```rust
pub mod accounts;
pub mod app;
pub mod auth;
pub mod codex;
@@ -2558,13 +2557,13 @@ pub mod config;
pub mod cookies;
pub mod crypto;
pub mod error;
pub mod fingerprint;
pub mod http;
pub mod logs;
pub mod pagination;
pub mod state;
pub mod storage;
pub mod translation;
```
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/app.rs`:
@@ -2609,7 +2608,7 @@ Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/http/v1.rs`:
```rust
use axum::{http::StatusCode, response::IntoResponse, Json};
use crate::translation::codex_to_openai::openai_error;
use crate::codex::protocol::codex_to_openai::openai_error;
pub async fn responses() -> impl IntoResponse {
(
@@ -2723,7 +2722,7 @@ Modify `/home/zyy/桌面/Codes/codex-proxy-rs/src/http/v1.rs`:
use axum::{http::{HeaderMap, StatusCode}, response::IntoResponse, Json};
use serde::Deserialize;
use crate::translation::codex_to_openai::openai_error;
use crate::codex::protocol::codex_to_openai::openai_error;
#[derive(Deserialize)]
struct ResponsesBody {
@@ -2774,8 +2773,8 @@ git commit -m "feat: enforce codex-only upstream routing"
### Task 12: Fingerprint Auto-Update
**Files:**
- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/fingerprint/updater.rs`
- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/fingerprint/model.rs`
- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/fingerprint/updater.rs`
- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/fingerprint/model.rs`
- Test: `/home/zyy/桌面/Codes/codex-proxy-rs/tests/codex_headers_test.rs`
- [x] **Step 1: Add failing updater parse test**
@@ -2783,7 +2782,7 @@ git commit -m "feat: enforce codex-only upstream routing"
Append to `/home/zyy/桌面/Codes/codex-proxy-rs/tests/codex_headers_test.rs`:
```rust
use codex_proxy_rs::fingerprint::updater::parse_update_manifest;
use codex_proxy_rs::codex::fingerprint::updater::parse_update_manifest;
#[test]
fn update_manifest_updates_app_version_and_build_number() {
@@ -2806,7 +2805,7 @@ Expected: compile failure because `parse_update_manifest` does not exist.
- [x] **Step 3: Implement updater parser**
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/fingerprint/updater.rs`:
Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/fingerprint/updater.rs`:
```rust
use serde::Deserialize;
@@ -2852,7 +2851,7 @@ Expected: pass.
- [x] **Step 5: Commit fingerprint updater parser**
```bash
git add src/fingerprint/updater.rs tests/codex_headers_test.rs
git add src/codex/fingerprint/updater.rs tests/codex_headers_test.rs
git commit -m "feat: parse codex desktop fingerprint updates"
```
@@ -2862,7 +2861,6 @@ git commit -m "feat: parse codex desktop fingerprint updates"
**Files:**
- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/README.md`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/.env.example`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/Dockerfile`
- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/docker-compose.yml`
@@ -2901,18 +2899,12 @@ Rust rewrite of Codex Proxy focused only on ChatGPT/Codex accounts and the Codex
- Codex account tokens are internal upstream credentials.
```
- [x] **Step 2: Add environment example**
- [x] **Step 2: Document local YAML overrides**
Create `/home/zyy/桌面/Codes/codex-proxy-rs/.env.example`:
Document that runtime config loads from root `config.yaml`, then optional root
`local.yaml` or `local.yml`. Local overrides stay untracked and replace the old
environment-variable override path.
```dotenv
CPRS_HOST=127.0.0.1
CPRS_PORT=8080
CPRS_DATABASE_URL=sqlite://data/codex-proxy-rs.sqlite
CPRS_MASTER_KEY_FILE=data/master.key
CPRS_API_KEY_PEPPER_FILE=data/api-key-pepper.key
RUST_LOG=codex_proxy_rs=info,tower_http=info
```
- [x] **Step 3: Verify full test suite and lint**
@@ -2944,7 +2936,7 @@ Expected response:
- [x] **Step 5: Commit docs and runtime files**
```bash
git add README.md .env.example Dockerfile docker-compose.yml
git add README.md config.yaml Dockerfile docker-compose.yml
git commit -m "docs: document codex-proxy-rs scope"
```
@@ -2979,7 +2971,7 @@ The Rust project has no legacy compatibility requirement, but the in-scope behav
### Keep and Rebuild Natively
- [x] **HTTP auth boundary:** restore a real `src/http/auth.rs` module instead of scattered header checks. It validates `Bearer cpr_...` client API keys for `/v1/*`, validates admin session cookies for `/admin/*`, rejects cross-use in both directions, and returns the documented OpenAI/admin response families.
- [x] **Account SQLite repository:** implement `src/accounts/repository.rs` for encrypted access/refresh tokens, account metadata, quota JSON, usage counters, labels, status transitions, pagination, batch-safe mutations, metadata-only auth status reads, and account-wide delete for logout. Do not add TypeScript JSON migration or legacy import shims.
- [x] **Account SQLite repository:** implement `src/codex/accounts/repository.rs` for encrypted access/refresh tokens, account metadata, quota JSON, usage counters, labels, status transitions, pagination, batch-safe mutations, metadata-only auth status reads, and account-wide delete for logout. Do not add TypeScript JSON migration or legacy import shims.
- [ ] **Admin account routes:** rebuild `/auth/status`, `/auth/accounts`, import/export, batch delete/status, health-check, per-account refresh, label, delete, reset usage, quota, Cookie CRUD, and quota warnings as `/admin/*` endpoints with admin envelopes and cursor pagination where a list is returned. Encrypted import, manual token add, CLI import, paginated sanitized list, native/Sub2API export, Sub2API import marking, label/status/delete, batch delete/status, Cookie CRUD, health/probe, refresh, quota, reset usage, admin auth status, and admin logout are complete. Quota warnings remain.
- [x] **Login routes:** implement OAuth PKCE login-start/code-relay/callback, device-login/device-poll, CLI auth import, manual access-token import, and logout as account-login flows. CLI auth import, validated manual token import, device login/poll, OAuth PKCE login-start/code-relay/callback, admin-session-gated auth status, and logout are complete. Keep this separate from admin password login and client API key creation.
- [x] **Client API key management:** implement admin CRUD for local client API keys: list/create/delete/batch-delete/label/status/export/import. Create/list/status/delete/batch-delete/label/export/import and `/v1/*` SQLite verification are complete. Remove provider/model binding semantics from the TypeScript version; Rust client API keys authorize only local `/v1/*` access.
@@ -3038,7 +3030,7 @@ This pass re-read the TypeScript routes and core modules under `/home/zyy/Codes/
- Non-fingerprint dependencies are resolved from current stable crate releases at implementation time and `Cargo.lock` is committed.
- `reqwest` and `rustls` remain explicitly pinned until a separate TLS fingerprint review approves newer versions.
- Library modules use `thiserror` error enums. `anyhow` is limited to `src/main.rs`, tests, and setup utilities.
- Runtime config loads from `config/default.yaml`, optional local config, and `CPRS_*` environment variables.
- Runtime config loads from root `config.yaml`, then optional root `local.yaml` or `local.yml`.
- In-memory upstream secrets use `secrecy` types, and persisted access tokens, refresh tokens, Cookies, master key material, and API key pepper material are encrypted or stored outside plaintext database fields.
- `/v1/*` and `/admin/*` response envelopes and status codes match `docs/api.md` and `docs/status-codes.md`.
- `/v1/*` never uses the admin/frontend `code/message/data/requestId` envelope; it keeps OpenAI-compatible response and error bodies.
@@ -43,7 +43,7 @@ Immediate priority after this audit:
### Task 1: Chat Completions Translation
**Files:**
- Modify: `src/translation/openai_to_codex.rs`
- Modify: `src/codex/protocol/openai_to_codex.rs`
- Modify: `src/codex/types.rs`
- Test: `tests/routes_chat_test.rs`
@@ -57,7 +57,7 @@ Immediate priority after this audit:
**Files:**
- Modify: `src/app.rs`
- Modify: `src/http/v1.rs`
- Modify: `src/translation/codex_to_openai.rs`
- Modify: `src/codex/protocol/codex_to_openai.rs`
- Test: `tests/chat_completions_route_test.rs`
- [x] Write failing route tests proving `/v1/chat/completions` sends translated Codex payloads and returns OpenAI chat JSON/SSE.
@@ -98,8 +98,8 @@ Immediate priority after this audit:
**Files:**
- Modify: `src/http/v1.rs`
- Modify: `src/accounts/pool.rs`
- Modify: `src/accounts/repository.rs`
- Modify: `src/codex/accounts/pool.rs`
- Modify: `src/codex/accounts/repository.rs`
- Test: `tests/v1_upstream_route_test.rs`
- Test: `tests/account_pool_scheduling_test.rs`
@@ -123,8 +123,8 @@ Immediate priority after this audit:
### Task 6: Model Catalog Refresh
**Files:**
- Modify: `src/models/catalog.rs`
- Create: `src/models/repository.rs`
- Modify: `src/codex/models/catalog.rs`
- Create: `src/codex/models/repository.rs`
- Modify: `src/http/v1.rs`
- Test: `tests/model_catalog_test.rs`
@@ -140,8 +140,8 @@ Immediate priority after this audit:
**Files:**
- Modify: `src/http/admin.rs`
- Modify: `src/accounts/repository.rs`
- Modify: `src/cookies/repository.rs`
- Modify: `src/codex/accounts/repository.rs`
- Modify: `src/codex/cookies/repository.rs`
- Test: `tests/admin_accounts_route_test.rs`
- Test: `tests/admin_api_keys_route_test.rs`
@@ -0,0 +1,141 @@
# Utils Module Migration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Move cross-domain helpers out of crate root into `src/utils/` without changing runtime behavior.
**Architecture:** Keep the project as one Rust crate and tighten internal module boundaries. `utils` will hold reusable primitives that do not depend on HTTP, services, or domain modules; callers update to the new explicit paths.
**Tech Stack:** Rust, Cargo, Axum integration tests, `thiserror`, `serde_json`, `base64`, `aes-gcm`, `secrecy`.
---
### Task 1: Document The Utils Boundary
**Files:**
- Modify: `docs/architecture-reorganization.md`
- [x] **Step 1: Replace the target source layout with the accepted single-crate layout**
The architecture document should show `app/`, `config/`, `utils/`, `http/`, `service/`, and the existing domain modules.
- [x] **Step 2: Add boundary rules**
The document should state that `utils` may contain `pagination`, `json`, and `crypto`, and that it must not depend on `http`, `service`, `accounts`, `auth`, or `codex`.
### Task 2: Create `src/utils`
**Files:**
- Create: `src/utils/mod.rs`
- Create: `src/utils/pagination.rs`
- Create: `src/utils/crypto.rs`
- Create: `src/utils/json.rs`
- Modify: `src/lib.rs`
- Delete: `src/pagination.rs`
- Delete: `src/crypto.rs`
- [ ] **Step 1: Add the utils module root**
```rust
pub mod crypto;
pub mod json;
pub mod pagination;
```
- [ ] **Step 2: Move pagination helpers**
Move `Page<T>`, `encode_cursor`, `decode_cursor`, and `clamp_limit` into `src/utils/pagination.rs` unchanged.
- [ ] **Step 3: Move crypto helpers**
Move `CryptoError`, `CryptoResult<T>`, and `SecretBox` into `src/utils/crypto.rs` unchanged.
- [ ] **Step 4: Add JSON string helpers**
```rust
use serde_json::Value;
pub fn first_string(value: &Value, paths: &[&[&str]]) -> Option<String> {
paths.iter().find_map(|path| string_at(value, path))
}
pub fn string_at(value: &Value, path: &[&str]) -> Option<String> {
let mut current = value;
for segment in path {
current = current.get(*segment)?;
}
current
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
```
- [ ] **Step 5: Expose `utils` from the library**
```rust
pub mod utils;
```
Remove the old top-level `pub mod crypto;` and `pub mod pagination;` declarations after all imports are updated.
### Task 3: Update Callers
**Files:**
- Modify: `src/codex/accounts/repository.rs`
- Modify: `src/auth/api_key_repository.rs`
- Modify: `src/codex/cookies/repository.rs`
- Modify: `src/http/admin/*.rs`
- Modify: `src/service/*.rs`
- Modify: `src/main.rs`
- Modify: `src/state.rs`
- Modify: `tests/*.rs`
- Modify: `tests/common/*.rs`
- [ ] **Step 1: Update source imports**
Use `crate::utils::crypto::{CryptoError, SecretBox}` and
`crate::utils::pagination::{clamp_limit, decode_cursor, encode_cursor, Page}`.
- [ ] **Step 2: Update integration test imports**
Use `codex_proxy_rs::utils::crypto::SecretBox` and
`codex_proxy_rs::utils::pagination::Page`.
- [ ] **Step 3: Remove duplicate JSON helper functions**
Import `crate::utils::json::first_string` in `src/service/api_key_service.rs` and
`src/http/admin/accounts.rs`, then delete the local `first_string` and `string_at`
definitions.
### Task 4: Verify
**Files:**
- No source edits after this task unless verification fails.
- [ ] **Step 1: Format**
Run:
```bash
cargo fmt
```
- [ ] **Step 2: Run focused tests**
Run:
```bash
cargo test --test crypto_test --test account_repository_test --test admin_accounts_import_export_test --test admin_api_keys_route_test
```
- [ ] **Step 3: Run full gate**
Run:
```bash
cargo fmt --check && cargo test && cargo clippy --all-targets --all-features --locked -- -D warnings
```
Expected: all commands exit successfully.
@@ -22,14 +22,14 @@ Current progress: Tasks 1-4 are implemented for the OpenAI GPT/Codex scope. Comm
## Components
- `src/translation/openai_to_codex.rs`: OpenAI Chat request schema and Chat-to-Codex translation, including messages, tools/functions, multimodal input, response formats, reasoning effort, and service tier.
- `src/translation/codex_to_openai.rs`: Codex SSE to OpenAI Chat JSON/SSE conversion, including text, reasoning, tool calls, usage, and errors.
- `src/codex/protocol/openai_to_codex.rs`: OpenAI Chat request schema and Chat-to-Codex translation, including messages, tools/functions, multimodal input, response formats, reasoning effort, and service tier.
- `src/codex/protocol/codex_to_openai.rs`: Codex SSE to OpenAI Chat JSON/SSE conversion, including text, reasoning, tool calls, usage, and errors.
- `src/codex/types.rs`: Expanded Codex request/response types for Responses and WebSocket.
- `src/codex/websocket.rs`: WebSocket transport selection and `response.create` conversion into SSE-compatible events for upstream-history requests such as `previous_response_id`.
- `src/codex/client.rs`: HTTP SSE and WebSocket Codex transport, context headers, cookies, and rate-limit header capture.
- `src/http/v1.rs`: Separate `/v1/responses` and `/v1/chat/completions` handlers sharing upstream lifecycle helpers.
- `src/accounts/pool.rs` and repositories: fallback selection, quota/rate-limit updates, durable usage release, and account state mutation hooks.
- `src/models/catalog.rs`: static catalog plus persisted backend snapshots, plan allowlist derivation, and admin refresh trigger.
- `src/codex/accounts/pool.rs` and repositories: fallback selection, quota/rate-limit updates, durable usage release, and account state mutation hooks.
- `src/codex/models/catalog.rs`: static catalog plus persisted backend snapshots, plan allowlist derivation, and admin refresh trigger.
- `src/http/admin.rs`: scoped account/API-key/account-health/quota/cookie/admin model operations needed to operate Codex-backed GPT compatibility, including Sub2API account import normalization without proxy-pool support.
## Data Flow
+49
View File
@@ -0,0 +1,49 @@
use thiserror::Error;
use crate::{
auth::{api_key::ApiKeyHasher, error::AuthError},
codex::accounts::repository::AccountRepositoryError,
codex::oauth::OpenAiOAuthRefresher,
config::AppConfig,
storage::db::connect_sqlite,
utils::crypto::{CryptoError, SecretBox},
};
use super::state::AppState;
#[derive(Debug, Error)]
pub enum BootstrapError {
#[error("secret storage error: {0}")]
Crypto(#[from] CryptoError),
#[error("api key hasher error: {0}")]
Auth(#[from] AuthError),
#[error("http client error: {0}")]
HttpClient(#[from] reqwest::Error),
#[error("database error: {0}")]
Database(#[from] sqlx::Error),
#[error("account repository error: {0}")]
AccountRepository(#[from] AccountRepositoryError),
}
pub type BootstrapResult<T> = Result<T, BootstrapError>;
pub async fn build_state(config: AppConfig) -> BootstrapResult<(AppState, usize)> {
let secret_box = SecretBox::load_or_create(&config.security.master_key_file)?;
let api_key_hasher = ApiKeyHasher::load_or_create(&config.security.api_key_pepper_file)?;
let oauth_client = OpenAiOAuthRefresher::codex_default(
reqwest::Client::builder()
.use_rustls_tls()
.no_proxy()
.build()?,
);
let pool = connect_sqlite(&config.database.url).await?;
let state = AppState::with_pool_secret_api_key_hasher_and_oauth_client(
config,
pool,
secret_box,
api_key_hasher,
oauth_client,
);
let restored_accounts = state.reload_account_pool_from_repository().await?;
Ok((state, restored_accounts))
}
+5
View File
@@ -0,0 +1,5 @@
pub mod bootstrap;
pub mod router;
pub mod state;
pub use router::build_router;
+16
View File
@@ -0,0 +1,16 @@
use axum::{middleware::from_fn, routing::get, Router};
use tower_http::trace::TraceLayer;
use crate::http::{admin, health::health, middleware::attach_request_id, v1};
use super::state::AppState;
pub fn build_router(state: AppState) -> Router {
Router::new()
.route("/health", get(health))
.merge(v1::router())
.merge(admin::router())
.with_state(state)
.layer(TraceLayer::new_for_http())
.layer(from_fn(attach_request_id))
}
+356
View File
@@ -0,0 +1,356 @@
use std::sync::Arc;
use sqlx::SqlitePool;
use tokio::sync::Mutex;
use crate::auth::{api_key::ApiKeyHasher, api_key_repository::ClientApiKeyRepository};
use crate::codex::accounts::{
pool::{AccountPool, AccountPoolOptions, RotationStrategy},
repository::{AccountRepository, AccountRepositoryResult, AccountUsageRepository},
service::AccountService,
};
use crate::codex::cookies::repository::CookieRepository;
use crate::codex::models::repository::ModelSnapshotRepository;
use crate::codex::models::service::ModelService;
use crate::codex::oauth::{OAuthClient, PkceSessionStore, TokenRefresher};
use crate::codex::upstream::CodexUpstreamService;
use crate::config::AppConfig;
use crate::logs::repository::EventLogRepository;
use crate::service::{
admin_auth::AdminAuthService, api_key::ApiKeyService, chat::ChatService, log::LogService,
responses::ResponsesService, usage::UsageService,
};
use crate::utils::crypto::SecretBox;
#[derive(Clone)]
pub struct AppState {
pub services: Arc<AppServices>,
}
pub struct AppServices {
pub config: AppConfig,
pub admin_auth: AdminAuthService,
pub accounts: AccountService,
pub api_keys: ApiKeyService,
pub logs: LogService,
pub usage: UsageService,
pub chat: ChatService,
pub responses: ResponsesService,
pub models: ModelService,
}
#[derive(Default)]
struct AppStateDependencies {
pool: Option<SqlitePool>,
secret_box: Option<SecretBox>,
api_key_hasher: Option<ApiKeyHasher>,
token_refresher: Option<Arc<dyn TokenRefresher>>,
oauth_client: Option<Arc<dyn OAuthClient>>,
}
impl AppState {
pub fn new(config: AppConfig) -> Self {
Self::from_dependencies(config, AppStateDependencies::default())
}
pub fn with_pool(config: AppConfig, pool: SqlitePool) -> Self {
Self::from_dependencies(
config,
AppStateDependencies {
pool: Some(pool),
..AppStateDependencies::default()
},
)
}
pub fn with_pool_and_secret_box(
config: AppConfig,
pool: SqlitePool,
secret_box: SecretBox,
) -> Self {
Self::from_dependencies(
config,
AppStateDependencies {
pool: Some(pool),
secret_box: Some(secret_box),
..AppStateDependencies::default()
},
)
}
pub fn with_pool_secret_and_api_key_hasher(
config: AppConfig,
pool: SqlitePool,
secret_box: SecretBox,
api_key_hasher: ApiKeyHasher,
) -> Self {
Self::from_dependencies(
config,
AppStateDependencies {
pool: Some(pool),
secret_box: Some(secret_box),
api_key_hasher: Some(api_key_hasher),
..AppStateDependencies::default()
},
)
}
pub fn with_pool_secret_api_key_hasher_and_token_refresher<C>(
config: AppConfig,
pool: SqlitePool,
secret_box: SecretBox,
api_key_hasher: ApiKeyHasher,
token_refresher: C,
) -> Self
where
C: TokenRefresher,
{
let token_refresher: Arc<dyn TokenRefresher> = Arc::new(token_refresher);
Self::from_dependencies(
config,
AppStateDependencies {
pool: Some(pool),
secret_box: Some(secret_box),
api_key_hasher: Some(api_key_hasher),
token_refresher: Some(token_refresher),
..AppStateDependencies::default()
},
)
}
pub fn with_pool_secret_api_key_hasher_and_oauth_client<C>(
config: AppConfig,
pool: SqlitePool,
secret_box: SecretBox,
api_key_hasher: ApiKeyHasher,
oauth_client: C,
) -> Self
where
C: OAuthClient + TokenRefresher,
{
let oauth_client = Arc::new(oauth_client);
let token_refresher: Arc<dyn TokenRefresher> = oauth_client.clone();
let oauth_client: Arc<dyn OAuthClient> = oauth_client;
Self::from_dependencies(
config,
AppStateDependencies {
pool: Some(pool),
secret_box: Some(secret_box),
api_key_hasher: Some(api_key_hasher),
token_refresher: Some(token_refresher),
oauth_client: Some(oauth_client),
},
)
}
pub fn config(&self) -> &AppConfig {
&self.services.config
}
pub async fn reload_account_pool_from_repository(&self) -> AccountRepositoryResult<usize> {
self.services
.accounts
.reload_runtime_accounts_from_repository()
.await
}
fn from_dependencies(config: AppConfig, dependencies: AppStateDependencies) -> Self {
let AppStateDependencies {
pool,
secret_box,
api_key_hasher,
token_refresher,
oauth_client,
} = dependencies;
let pool_ref = pool.as_ref();
let secret_box_ref = secret_box.as_ref();
let account_pool = account_pool_from_config(&config);
let oauth_sessions = Arc::new(Mutex::new(PkceSessionStore::default()));
let api_keys = api_key_service(pool_ref, api_key_hasher.as_ref());
let logs = log_service(pool_ref);
let usage = usage_service(pool_ref);
let accounts = account_service(
&config,
account_repository(pool_ref, secret_box_ref),
pool_ref,
secret_box_ref,
token_refresher.clone(),
account_pool.clone(),
);
let admin_auth = admin_auth_service(
&config,
pool_ref,
secret_box_ref,
account_pool.clone(),
oauth_client,
oauth_sessions,
accounts.clone(),
);
let V1Services {
chat,
responses,
models,
} = v1_services(
&config,
pool_ref,
secret_box_ref,
token_refresher,
account_pool,
);
Self {
services: Arc::new(AppServices {
config,
admin_auth,
accounts,
api_keys,
logs,
usage,
chat,
responses,
models,
}),
}
}
}
fn account_pool_from_config(config: &AppConfig) -> Arc<Mutex<AccountPool>> {
Arc::new(Mutex::new(AccountPool::with_options(AccountPoolOptions {
max_concurrent_per_account: config.auth.max_concurrent_per_account,
stale_slot_ttl: chrono::Duration::minutes(5),
rotation_strategy: match config.auth.rotation_strategy.as_str() {
"round_robin" => RotationStrategy::RoundRobin,
"sticky" => RotationStrategy::Sticky,
_ => RotationStrategy::LeastUsed,
},
skip_quota_limited: config.quota.skip_exhausted,
tier_priority: config.auth.tier_priority.clone(),
model_plan_allowlist: std::collections::BTreeMap::new(),
})))
}
struct V1Services {
chat: ChatService,
responses: ResponsesService,
models: ModelService,
}
fn v1_services(
config: &AppConfig,
pool: Option<&SqlitePool>,
secret_box: Option<&SecretBox>,
token_refresher: Option<Arc<dyn TokenRefresher>>,
account_pool: Arc<Mutex<AccountPool>>,
) -> V1Services {
let upstream = codex_upstream_service(
config,
pool,
secret_box,
token_refresher,
account_pool.clone(),
);
let models = model_service(config, pool, secret_box, account_pool);
let model_config = config.model.clone();
V1Services {
chat: ChatService::new(model_config.clone(), models.clone(), upstream.clone()),
responses: ResponsesService::new(model_config, models.clone(), upstream),
models,
}
}
fn model_service(
config: &AppConfig,
pool: Option<&SqlitePool>,
secret_box: Option<&SecretBox>,
account_pool: Arc<Mutex<AccountPool>>,
) -> ModelService {
ModelService::new(
config.clone(),
pool.cloned().map(ModelSnapshotRepository::new),
account_repository(pool, secret_box),
account_pool,
)
}
fn admin_auth_service(
config: &AppConfig,
pool: Option<&SqlitePool>,
secret_box: Option<&SecretBox>,
account_pool: Arc<Mutex<AccountPool>>,
oauth_client: Option<Arc<dyn OAuthClient>>,
oauth_sessions: Arc<Mutex<PkceSessionStore>>,
accounts: AccountService,
) -> AdminAuthService {
AdminAuthService::new(
config.clone(),
pool.cloned(),
account_repository(pool, secret_box),
account_pool,
oauth_client,
oauth_sessions,
accounts,
)
}
fn account_service(
config: &AppConfig,
repository: Option<AccountRepository>,
pool: Option<&SqlitePool>,
secret_box: Option<&SecretBox>,
token_refresher: Option<Arc<dyn TokenRefresher>>,
account_pool: Arc<Mutex<AccountPool>>,
) -> AccountService {
AccountService::new(
config.clone(),
repository,
pool.cloned().map(AccountUsageRepository::new),
cookie_repository(pool, secret_box),
token_refresher,
account_pool,
)
}
fn codex_upstream_service(
config: &AppConfig,
pool: Option<&SqlitePool>,
secret_box: Option<&SecretBox>,
token_refresher: Option<Arc<dyn TokenRefresher>>,
account_pool: Arc<Mutex<AccountPool>>,
) -> CodexUpstreamService {
CodexUpstreamService::new(
config.clone(),
account_pool,
account_repository(pool, secret_box),
cookie_repository(pool, secret_box),
pool.cloned().map(EventLogRepository::new),
token_refresher,
)
}
fn account_repository(
pool: Option<&SqlitePool>,
secret_box: Option<&SecretBox>,
) -> Option<AccountRepository> {
Some(AccountRepository::new(pool?.clone(), secret_box?.clone()))
}
fn cookie_repository(
pool: Option<&SqlitePool>,
secret_box: Option<&SecretBox>,
) -> Option<CookieRepository> {
Some(CookieRepository::new(pool?.clone(), secret_box?.clone()))
}
fn api_key_service(pool: Option<&SqlitePool>, hasher: Option<&ApiKeyHasher>) -> ApiKeyService {
ApiKeyService::new(
pool.cloned().map(ClientApiKeyRepository::new),
hasher.cloned(),
)
}
fn log_service(pool: Option<&SqlitePool>) -> LogService {
LogService::new(pool.cloned().map(EventLogRepository::new))
}
fn usage_service(pool: Option<&SqlitePool>) -> UsageService {
UsageService::new(pool.cloned().map(AccountUsageRepository::new))
}
+1 -1
View File
@@ -5,7 +5,7 @@ use uuid::Uuid;
use crate::{
auth::api_key::{ApiKeyHasher, GeneratedClientApiKey},
pagination::{decode_cursor, encode_cursor, Page},
utils::pagination::{decode_cursor, encode_cursor, Page},
};
#[derive(Debug, Error)]
-4
View File
@@ -1,8 +1,4 @@
pub mod admin_session;
pub mod api_key;
pub mod api_key_repository;
pub mod cli_import;
pub mod error;
pub mod oauth;
pub mod refresh;
pub mod token;
@@ -2,3 +2,4 @@ pub mod lifecycle;
pub mod model;
pub mod pool;
pub mod repository;
pub mod service;
@@ -2,7 +2,7 @@ use std::collections::{BTreeMap, VecDeque};
use chrono::{DateTime, Duration, Utc};
use crate::accounts::model::{Account, AccountStatus};
use crate::codex::accounts::model::{Account, AccountStatus};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RotationStrategy {
@@ -4,9 +4,9 @@ use sqlx::{Row, SqlitePool};
use thiserror::Error;
use crate::{
accounts::model::{Account, AccountStatus},
crypto::{CryptoError, SecretBox},
pagination::{decode_cursor, encode_cursor, Page},
codex::accounts::model::{Account, AccountStatus},
utils::crypto::{CryptoError, SecretBox},
utils::pagination::{decode_cursor, encode_cursor, Page},
};
#[derive(Debug, Error)]
+51
View File
@@ -0,0 +1,51 @@
use reqwest::Url;
use super::{AccountService, AccountServiceError};
impl AccountService {
pub async fn get_cookies(
&self,
account_id: &str,
) -> Result<Option<String>, AccountServiceError> {
self.ensure_account_exists(account_id).await?;
self.cookie_repository()?
.cookie_header(account_id, "chatgpt.com")
.await
.map_err(|_| AccountServiceError::LoadCookies)
}
pub async fn set_cookies(
&self,
account_id: &str,
cookie_header: &str,
) -> Result<Option<String>, AccountServiceError> {
self.ensure_account_exists(account_id).await?;
let cookie_repo = self.cookie_repository()?;
match cookie_repo
.set_cookie_header(account_id, cookie_header)
.await
{
Ok(0) => Err(AccountServiceError::NoValidCookies),
Ok(_) => cookie_repo
.cookie_header(account_id, "chatgpt.com")
.await
.map_err(|_| AccountServiceError::LoadCookies),
Err(_) => Err(AccountServiceError::StoreCookies),
}
}
pub async fn delete_cookies(&self, account_id: &str) -> Result<(), AccountServiceError> {
self.ensure_account_exists(account_id).await?;
self.cookie_repository()?
.delete_account_cookies(account_id)
.await
.map(|_| ())
.map_err(|_| AccountServiceError::DeleteCookies)
}
}
pub(super) fn request_domain(base_url: &str) -> Option<String> {
Url::parse(base_url)
.ok()
.and_then(|url| url.host_str().map(ToString::to_string))
}
+308
View File
@@ -0,0 +1,308 @@
use std::time::Duration as StdDuration;
use chrono::Utc;
use futures::{stream, StreamExt};
use reqwest::StatusCode;
use secrecy::ExposeSecret;
use serde_json::Value;
use tokio::time::sleep;
use crate::{
codex::accounts::{
model::AccountStatus,
repository::{AccountRepository, StoredAccount},
},
codex::fingerprint::model::Fingerprint,
codex::transport::client::{
build_reqwest_client, CodexBackendClient, CodexClientError, CodexRequestContext,
},
};
use super::{
cookies::request_domain, quota::quota_from_usage, AccountProbeOutcome, AccountProbeResult,
AccountService, HealthCheckError,
};
impl AccountService {
pub async fn health_check_accounts(
&self,
ids: Option<Vec<String>>,
concurrency: usize,
stagger_ms: u64,
request_id: &str,
) -> Result<Vec<AccountProbeResult>, HealthCheckError> {
let repo = self
.repository
.as_ref()
.ok_or(HealthCheckError::RepositoryUnavailable)?;
let accounts = repo
.list_all()
.await
.map(|accounts| filter_health_check_accounts(accounts, ids.as_deref()))
.map_err(|_| HealthCheckError::List)?;
let results = stream::iter(accounts.into_iter().enumerate())
.map(|(index, account)| {
let service = self.clone();
let repo = repo.clone();
let request_id = request_id.to_string();
async move {
if stagger_ms > 0 && index > 0 {
let multiplier = index.min(concurrency);
sleep(StdDuration::from_millis(
stagger_ms.saturating_mul(multiplier as u64),
))
.await;
}
service
.probe_account_with_codex_backend(&repo, account, &request_id)
.await
}
})
.buffer_unordered(concurrency)
.collect::<Vec<_>>()
.await;
Ok(results)
}
async fn probe_account_with_codex_backend(
&self,
repo: &AccountRepository,
account: StoredAccount,
request_id: &str,
) -> AccountProbeResult {
if account.status == AccountStatus::Disabled {
return skipped_probe_result(&account, "manually disabled");
}
let started_at = std::time::Instant::now();
let previous_status = account.status;
match fetch_account_usage(self, &account, request_id).await {
Ok(raw) => {
let quota = quota_from_usage(&raw);
let _ = repo
.update_quota_json(&account.id, &quota.to_string())
.await;
if account.status != AccountStatus::Active {
let _ = repo.set_status(&account.id, AccountStatus::Active).await;
self.account_pool
.lock()
.await
.set_status(&account.id, AccountStatus::Active);
}
AccountProbeResult {
id: account.id,
email: account.email,
previous_status,
outcome: AccountProbeOutcome::Alive,
status: Some(AccountStatus::Active),
error: None,
duration_ms: Some(started_at.elapsed().as_millis()),
}
}
Err(error) => {
let status = apply_codex_account_error(self, repo, &account, &error).await;
AccountProbeResult {
id: account.id,
email: account.email,
previous_status,
outcome: AccountProbeOutcome::Dead,
status,
error: Some(public_codex_error(&error)),
duration_ms: Some(started_at.elapsed().as_millis()),
}
}
}
}
}
pub(super) async fn fetch_account_usage(
service: &AccountService,
account: &StoredAccount,
request_id: &str,
) -> Result<Value, CodexClientError> {
let cookie_header = account_cookie_header(service, &account.id).await;
let client = CodexBackendClient::new(
build_reqwest_client(service.config.tls.force_http11)?,
service.config.api.base_url.clone(),
Fingerprint::default_codex_desktop(),
);
client
.fetch_usage(CodexRequestContext {
access_token: account.access_token.expose_secret(),
account_id: account.account_id.as_deref(),
request_id,
turn_state: None,
turn_metadata: None,
beta_features: None,
include_timing_metrics: None,
version: None,
codex_window_id: None,
parent_thread_id: None,
cookie_header: cookie_header.as_deref(),
})
.await
}
async fn account_cookie_header(service: &AccountService, account_id: &str) -> Option<String> {
let domain = request_domain(&service.config.api.base_url)?;
service
.cookie_repository
.as_ref()?
.cookie_header(account_id, &domain)
.await
.ok()
.flatten()
}
pub(super) async fn apply_codex_account_error(
service: &AccountService,
repo: &AccountRepository,
account: &StoredAccount,
error: &CodexClientError,
) -> Option<AccountStatus> {
match classify_codex_account_error(error) {
Some(CodexAccountErrorAction::SetStatus(status)) => {
let _ = repo.set_status(&account.id, status).await;
service
.account_pool
.lock()
.await
.set_status(&account.id, status);
Some(status)
}
Some(CodexAccountErrorAction::RateLimited {
retry_after_seconds,
}) => {
let cooldown_until = Utc::now() + chrono::Duration::seconds(retry_after_seconds as i64);
service
.account_pool
.lock()
.await
.mark_quota_limited_until(&account.id, cooldown_until);
None
}
Some(CodexAccountErrorAction::CloudflareChallenge { cooldown_seconds }) => {
let cooldown_until = Utc::now() + chrono::Duration::seconds(cooldown_seconds as i64);
service
.account_pool
.lock()
.await
.set_cloudflare_cooldown_until(&account.id, cooldown_until);
None
}
None => None,
}
}
pub(super) fn skipped_probe_result(account: &StoredAccount, error: &str) -> AccountProbeResult {
AccountProbeResult {
id: account.id.clone(),
email: account.email.clone(),
previous_status: account.status,
outcome: AccountProbeOutcome::Skipped,
status: Some(account.status),
error: Some(error.to_string()),
duration_ms: None,
}
}
pub(super) fn filter_health_check_accounts(
accounts: Vec<StoredAccount>,
ids: Option<&[String]>,
) -> Vec<StoredAccount> {
let Some(ids) = ids else {
return accounts;
};
accounts
.into_iter()
.filter(|account| ids.iter().any(|id| id == &account.id))
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum CodexAccountErrorAction {
SetStatus(AccountStatus),
RateLimited { retry_after_seconds: u64 },
CloudflareChallenge { cooldown_seconds: u64 },
}
pub(super) fn classify_codex_account_error(
error: &CodexClientError,
) -> Option<CodexAccountErrorAction> {
const DEFAULT_RATE_LIMIT_BACKOFF_SECONDS: u64 = 60;
const MAX_RATE_LIMIT_BACKOFF_SECONDS: u64 = 3_600;
const CLOUDFLARE_CHALLENGE_COOLDOWN_SECONDS: u64 = 120;
let CodexClientError::Upstream {
status,
body,
retry_after_seconds,
} = error
else {
return None;
};
let lower = body.to_ascii_lowercase();
if *status == StatusCode::UNAUTHORIZED
|| lower.contains("invalid_grant")
|| lower.contains("invalid_token")
|| lower.contains("access_denied")
|| lower.contains("refresh_token_expired")
|| lower.contains("token_revoked")
{
return Some(CodexAccountErrorAction::SetStatus(AccountStatus::Expired));
}
if *status == StatusCode::PAYMENT_REQUIRED || lower.contains("quota") {
return Some(CodexAccountErrorAction::SetStatus(
AccountStatus::QuotaExhausted,
));
}
if *status == StatusCode::TOO_MANY_REQUESTS {
return Some(CodexAccountErrorAction::RateLimited {
retry_after_seconds: retry_after_seconds
.unwrap_or(DEFAULT_RATE_LIMIT_BACKOFF_SECONDS)
.min(MAX_RATE_LIMIT_BACKOFF_SECONDS),
});
}
if *status == StatusCode::FORBIDDEN {
if is_cloudflare_challenge(body) {
return Some(CodexAccountErrorAction::CloudflareChallenge {
cooldown_seconds: CLOUDFLARE_CHALLENGE_COOLDOWN_SECONDS,
});
}
return Some(CodexAccountErrorAction::SetStatus(AccountStatus::Banned));
}
if lower.contains("account has been deactivated")
|| lower.contains("deactivated")
|| lower.contains("banned")
|| lower.contains("suspended")
{
return Some(CodexAccountErrorAction::SetStatus(AccountStatus::Banned));
}
None
}
pub(super) fn public_codex_error(error: &CodexClientError) -> String {
match error {
CodexClientError::Upstream { status, .. } => {
format!("upstream returned status {}", status.as_u16())
}
CodexClientError::Http(_) => "upstream transport failed".to_string(),
CodexClientError::InvalidHeaderName(_) | CodexClientError::InvalidHeaderValue(_) => {
"invalid upstream request headers".to_string()
}
CodexClientError::UnsupportedTransport(_)
| CodexClientError::WebSocket(_)
| CodexClientError::InvalidSse(_)
| CodexClientError::ModelsUnavailable => "Codex backend request failed".to_string(),
}
}
fn is_cloudflare_challenge(body: &str) -> bool {
let lower = body.to_ascii_lowercase();
lower.contains("cf-mitigated")
|| lower.contains("cf-chl-bypass")
|| lower.contains("_cf_chl")
|| lower.contains("cf_chl")
|| lower.contains("attention required")
|| lower.contains("just a moment")
}
+319
View File
@@ -0,0 +1,319 @@
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use chrono::{DateTime, Utc};
use secrecy::SecretString;
use serde_json::{Map, Value};
use uuid::Uuid;
use crate::{
codex::accounts::{
model::{Account, AccountStatus},
repository::{AccountClaimsUpdate, AccountRepository, NewAccount, StoredAccount},
},
codex::oauth::RefreshFailure,
};
use super::{
runtime_pool::pool_account_from_stored, AccountImportCounts, AccountImportEntry,
AccountService, StoreImportAccountError, ValidatedAccountImportError,
};
enum StoredImportAccount {
Imported,
Skipped,
}
impl AccountService {
pub async fn import_validated(
&self,
token: Option<String>,
refresh_token: Option<String>,
) -> Result<StoredAccount, ValidatedAccountImportError> {
let repo = self
.repository
.as_ref()
.ok_or(ValidatedAccountImportError::RepositoryUnavailable)?;
let (access_token, refresh_token_update, new_account_refresh_token) = match (
empty_to_none(token.map(normalize_bearer_token)),
empty_to_none(refresh_token),
) {
(Some(token), refresh_token) => (token, refresh_token.clone(), refresh_token),
(None, Some(refresh_token)) => {
let Some(refresher) = self.token_refresher.as_ref() else {
return Err(ValidatedAccountImportError::TokenRefresherUnavailable);
};
let tokens = match refresher.refresh(&refresh_token).await {
Ok(tokens) => tokens,
Err(RefreshFailure::Transport) => {
return Err(ValidatedAccountImportError::RefreshTransport);
}
Err(_) => return Err(ValidatedAccountImportError::RefreshRejected),
};
let access_token = normalize_bearer_token(tokens.access_token);
let rotated_refresh_token = empty_to_none(tokens.refresh_token);
let new_account_refresh_token =
rotated_refresh_token.clone().or(Some(refresh_token));
(
access_token,
rotated_refresh_token,
new_account_refresh_token,
)
}
(None, None) => return Err(ValidatedAccountImportError::TokenRequired),
};
let claims = manual_account_claims(&access_token, Utc::now())
.map_err(ValidatedAccountImportError::InvalidToken)?;
let existing = repo
.find_by_chatgpt_identity(&claims.account_id, claims.user_id.as_deref())
.await
.map_err(|_| ValidatedAccountImportError::Inspect)?;
let account_id = if let Some(existing) = existing {
let updated = repo
.update_from_claims(
&existing.id,
AccountClaimsUpdate {
email: claims.email.clone(),
account_id: Some(claims.account_id.clone()),
user_id: claims.user_id.clone(),
plan_type: claims.plan_type.clone(),
access_token: SecretString::new(access_token.into()),
refresh_token: refresh_token_update
.map(|token| SecretString::new(token.into())),
access_token_expires_at: Some(claims.expires_at),
status: AccountStatus::Active,
},
)
.await
.map_err(|_| ValidatedAccountImportError::Update)?;
if !updated {
return Err(ValidatedAccountImportError::NotFound);
}
existing.id
} else {
let id = normalized_account_id(None);
let account = NewAccount {
id: id.clone(),
email: claims.email.clone(),
account_id: Some(claims.account_id.clone()),
user_id: claims.user_id.clone(),
label: None,
plan_type: claims.plan_type.clone(),
access_token: SecretString::new(access_token.into()),
refresh_token: new_account_refresh_token
.map(|token| SecretString::new(token.into())),
access_token_expires_at: Some(claims.expires_at),
status: AccountStatus::Active,
};
repo.insert(account)
.await
.map_err(|_| ValidatedAccountImportError::Insert)?;
id
};
let stored = repo
.get(&account_id)
.await
.map_err(|_| ValidatedAccountImportError::Load)?
.ok_or(ValidatedAccountImportError::NotFound)?;
self.account_pool
.lock()
.await
.insert(pool_account_from_stored(stored.clone()));
Ok(stored)
}
pub async fn import_entries(
&self,
entries: Vec<AccountImportEntry>,
) -> Result<AccountImportCounts, StoreImportAccountError> {
let repo = self
.repository
.as_ref()
.ok_or(StoreImportAccountError::RepositoryUnavailable)?;
let mut imported = 0u32;
let mut skipped = 0u32;
for entry in entries {
match self.store_import_entry(repo, entry).await? {
StoredImportAccount::Imported => imported += 1,
StoredImportAccount::Skipped => skipped += 1,
}
}
Ok(AccountImportCounts { imported, skipped })
}
async fn store_import_entry(
&self,
repo: &AccountRepository,
entry: AccountImportEntry,
) -> Result<StoredImportAccount, StoreImportAccountError> {
let access_token = entry.token.as_deref().unwrap_or_default().trim();
if access_token.is_empty() {
return Ok(StoredImportAccount::Skipped);
}
if entry
.label
.as_ref()
.is_some_and(|label| label.chars().count() > 64)
{
return Err(StoreImportAccountError::Invalid(
"Account label must be 64 characters or fewer".to_string(),
));
}
let id = normalized_account_id(entry.id);
match repo.exists(&id).await {
Ok(true) => return Ok(StoredImportAccount::Skipped),
Ok(false) => {}
Err(_) => return Err(StoreImportAccountError::Inspect),
}
let status = parse_import_status(entry.status.as_deref())
.map_err(StoreImportAccountError::Invalid)?;
let email = empty_to_none(entry.email);
let account_id = empty_to_none(entry.account_id);
let user_id = empty_to_none(entry.user_id);
let label = empty_to_none(entry.label);
let plan_type = empty_to_none(entry.plan_type);
let refresh_token = empty_to_none(entry.refresh_token);
let access_token = access_token.to_string();
let now = Utc::now().to_rfc3339();
let pool_account = Account {
id: id.clone(),
email: email.clone(),
account_id: account_id.clone(),
user_id: user_id.clone(),
label: label.clone(),
plan_type: plan_type.clone(),
access_token: access_token.clone(),
refresh_token: refresh_token.clone(),
access_token_expires_at: None,
status,
quota_limit_reached: false,
quota_cooldown_until: None,
cloudflare_cooldown_until: None,
added_at: now,
last_used_at: None,
};
let account = NewAccount {
id,
email,
account_id,
user_id,
label,
plan_type,
access_token: SecretString::new(access_token.into()),
refresh_token: refresh_token.map(|token| SecretString::new(token.into())),
access_token_expires_at: None,
status,
};
repo.insert(account)
.await
.map_err(|_| StoreImportAccountError::Insert)?;
self.account_pool.lock().await.insert(pool_account);
Ok(StoredImportAccount::Imported)
}
}
#[derive(Debug, Clone)]
pub(super) struct ManualAccountClaims {
pub account_id: String,
pub user_id: Option<String>,
pub email: Option<String>,
pub plan_type: Option<String>,
pub expires_at: DateTime<Utc>,
}
pub(super) fn manual_account_claims(
token: &str,
now: DateTime<Utc>,
) -> Result<ManualAccountClaims, &'static str> {
let payload = decode_jwt_payload(token).ok_or("Invalid JWT format")?;
let exp = payload
.get("exp")
.and_then(Value::as_i64)
.ok_or("Token is expired")?;
if now.timestamp() >= exp {
return Err("Token is expired");
}
let expires_at = DateTime::<Utc>::from_timestamp(exp, 0).ok_or("Invalid JWT exp claim")?;
let auth = payload
.get("https://api.openai.com/auth")
.and_then(Value::as_object)
.ok_or("Token missing chatgpt_account_id claim")?;
let account_id =
string_claim(auth, "chatgpt_account_id").ok_or("Token missing chatgpt_account_id claim")?;
let profile = payload
.get("https://api.openai.com/profile")
.and_then(Value::as_object);
let user_id = string_claim(auth, "chatgpt_user_id")
.or_else(|| profile.and_then(|profile| string_claim(profile, "chatgpt_user_id")));
let plan_type = string_claim(auth, "chatgpt_plan_type")
.or_else(|| profile.and_then(|profile| string_claim(profile, "chatgpt_plan_type")));
let email = profile.and_then(|profile| string_claim(profile, "email"));
Ok(ManualAccountClaims {
account_id,
user_id,
email,
plan_type,
expires_at,
})
}
pub(super) fn normalize_bearer_token(value: String) -> String {
value
.trim()
.strip_prefix("Bearer ")
.or_else(|| value.trim().strip_prefix("bearer "))
.unwrap_or(value.trim())
.trim()
.to_string()
}
pub(super) fn empty_to_none(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
pub(super) fn normalized_account_id(id: Option<String>) -> String {
id.and_then(|id| empty_to_none(Some(id)))
.unwrap_or_else(|| format!("acct_{}", Uuid::new_v4().simple()))
}
pub(super) fn parse_import_status(status: Option<&str>) -> Result<AccountStatus, String> {
let normalized = status.unwrap_or("active").trim().to_ascii_lowercase();
match normalized.as_str() {
"active" => Ok(AccountStatus::Active),
"expired" => Ok(AccountStatus::Expired),
"quota_exhausted" => Ok(AccountStatus::QuotaExhausted),
"refreshing" => Ok(AccountStatus::Refreshing),
"disabled" => Ok(AccountStatus::Disabled),
"banned" => Ok(AccountStatus::Banned),
other => Err(format!("Unsupported account status: {other}")),
}
}
fn decode_jwt_payload(token: &str) -> Option<Map<String, Value>> {
let mut parts = token.split('.');
let _header = parts.next()?;
let payload = parts.next()?;
if payload.is_empty() {
return None;
}
let bytes = URL_SAFE_NO_PAD.decode(payload).ok()?;
serde_json::from_slice::<Value>(&bytes)
.ok()?
.as_object()
.cloned()
}
fn string_claim(map: &Map<String, Value>, key: &str) -> Option<String> {
map.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
+262
View File
@@ -0,0 +1,262 @@
pub mod cookies;
pub mod health;
pub mod import;
pub mod mutation;
pub mod quota;
pub mod refresh;
pub mod runtime_pool;
use std::sync::Arc;
use serde_json::Value;
use tokio::sync::Mutex;
use crate::{
codex::accounts::{
model::AccountStatus,
pool::AccountPool,
repository::{
AccountRepository, AccountUsageRepository, StoredAccount, StoredAccountMetadata,
},
},
codex::cookies::repository::CookieRepository,
codex::oauth::TokenRefresher,
config::AppConfig,
utils::pagination::Page,
};
#[derive(Clone)]
pub struct AccountService {
config: AppConfig,
repository: Option<AccountRepository>,
usage_repository: Option<AccountUsageRepository>,
cookie_repository: Option<CookieRepository>,
token_refresher: Option<Arc<dyn TokenRefresher>>,
account_pool: Arc<Mutex<AccountPool>>,
}
#[derive(Debug)]
pub enum AccountServiceError {
RepositoryUnavailable,
UsageRepositoryUnavailable,
CookieRepositoryUnavailable,
AccountNotFound,
List,
Export,
Inspect,
ResetUsage,
UpdateLabel,
UpdateStatus,
SyncStatus,
Delete,
LoadCookies,
StoreCookies,
DeleteCookies,
NoValidCookies,
EmptyIds,
InvalidStatus(String),
LabelTooLong,
}
#[derive(Debug)]
pub struct BatchDeleteAccounts {
pub deleted: u32,
pub not_found: Vec<String>,
}
#[derive(Debug)]
pub struct BatchUpdateAccountStatus {
pub updated: u32,
pub not_found: Vec<String>,
}
#[derive(Debug)]
pub struct UpdateAccountStatus {
pub id: String,
pub status: AccountStatus,
}
#[derive(Debug)]
pub struct AccountImportCounts {
pub imported: u32,
pub skipped: u32,
}
#[derive(Debug, Clone)]
pub struct AccountImportEntry {
pub id: Option<String>,
pub email: Option<String>,
pub account_id: Option<String>,
pub user_id: Option<String>,
pub label: Option<String>,
pub plan_type: Option<String>,
pub token: Option<String>,
pub refresh_token: Option<String>,
pub status: Option<String>,
}
#[derive(Debug)]
pub enum AccountProbeOutcome {
Alive,
Dead,
Skipped,
}
impl AccountProbeOutcome {
pub fn as_str(&self) -> &'static str {
match self {
Self::Alive => "alive",
Self::Dead => "dead",
Self::Skipped => "skipped",
}
}
}
#[derive(Debug)]
pub struct AccountProbeResult {
pub id: String,
pub email: Option<String>,
pub previous_status: AccountStatus,
pub outcome: AccountProbeOutcome,
pub status: Option<AccountStatus>,
pub error: Option<String>,
pub duration_ms: Option<u128>,
}
#[derive(Debug)]
pub struct AccountQuotaResult {
pub quota: Value,
pub raw: Value,
}
#[derive(Debug)]
pub enum AccountQuotaError {
RepositoryUnavailable,
Load,
NotFound,
Inactive(AccountStatus),
StoreQuota,
Fetch(String),
}
#[derive(Debug)]
pub enum HealthCheckError {
RepositoryUnavailable,
List,
}
#[derive(Debug)]
pub enum RefreshAccountError {
RepositoryUnavailable,
Load,
NotFound,
TokenRefresherUnavailable,
StoreRefreshed,
}
#[derive(Debug)]
pub enum StoreImportAccountError {
RepositoryUnavailable,
Inspect,
Invalid(String),
Insert,
}
#[derive(Debug)]
pub enum ValidatedAccountImportError {
RepositoryUnavailable,
TokenRequired,
TokenRefresherUnavailable,
RefreshTransport,
RefreshRejected,
InvalidToken(&'static str),
Inspect,
NotFound,
Update,
Insert,
Load,
}
impl AccountService {
pub fn new(
config: AppConfig,
repository: Option<AccountRepository>,
usage_repository: Option<AccountUsageRepository>,
cookie_repository: Option<CookieRepository>,
token_refresher: Option<Arc<dyn TokenRefresher>>,
account_pool: Arc<Mutex<AccountPool>>,
) -> Self {
Self {
config,
repository,
usage_repository,
cookie_repository,
token_refresher,
account_pool,
}
}
pub fn has_repository(&self) -> bool {
self.repository.is_some()
}
pub async fn list(
&self,
cursor: Option<String>,
limit: u32,
) -> Result<Page<StoredAccountMetadata>, AccountServiceError> {
self.repository()?
.list_metadata(cursor, limit)
.await
.map_err(|_| AccountServiceError::List)
}
pub async fn export(
&self,
ids: Vec<String>,
) -> Result<Vec<StoredAccount>, AccountServiceError> {
let repo = self.repository()?;
if ids.is_empty() {
return repo
.list_all()
.await
.map_err(|_| AccountServiceError::Export);
}
let mut accounts = Vec::with_capacity(ids.len());
for id in ids {
match repo.get(&id).await {
Ok(Some(account)) => accounts.push(account),
Ok(None) => {}
Err(_) => return Err(AccountServiceError::Export),
}
}
Ok(accounts)
}
fn repository(&self) -> Result<&AccountRepository, AccountServiceError> {
self.repository
.as_ref()
.ok_or(AccountServiceError::RepositoryUnavailable)
}
fn usage_repository(&self) -> Result<&AccountUsageRepository, AccountServiceError> {
self.usage_repository
.as_ref()
.ok_or(AccountServiceError::UsageRepositoryUnavailable)
}
fn cookie_repository(&self) -> Result<&CookieRepository, AccountServiceError> {
self.cookie_repository
.as_ref()
.ok_or(AccountServiceError::CookieRepositoryUnavailable)
}
async fn ensure_account_exists(&self, account_id: &str) -> Result<(), AccountServiceError> {
match self.repository()?.exists(account_id).await {
Ok(true) => Ok(()),
Ok(false) => Err(AccountServiceError::AccountNotFound),
Err(_) => Err(AccountServiceError::Inspect),
}
}
}
+169
View File
@@ -0,0 +1,169 @@
use crate::codex::accounts::{model::AccountStatus, repository::AccountRepository};
use super::{
runtime_pool::pool_account_from_stored, AccountService, AccountServiceError,
BatchDeleteAccounts, BatchUpdateAccountStatus, UpdateAccountStatus,
};
impl AccountService {
pub async fn reset_usage(&self, account_id: &str) -> Result<bool, AccountServiceError> {
match self.repository()?.exists(account_id).await {
Ok(true) => {}
Ok(false) => return Ok(false),
Err(_) => return Err(AccountServiceError::Inspect),
}
self.usage_repository()?
.reset_account(account_id)
.await
.map_err(|_| AccountServiceError::ResetUsage)?;
self.account_pool.lock().await.reset_usage(account_id);
Ok(true)
}
pub async fn update_label(
&self,
account_id: &str,
label: Option<String>,
) -> Result<bool, AccountServiceError> {
if label
.as_ref()
.is_some_and(|label| label.chars().count() > 64)
{
return Err(AccountServiceError::LabelTooLong);
}
match self
.repository()?
.set_label(account_id, label.clone())
.await
{
Ok(true) => {
self.account_pool.lock().await.set_label(account_id, label);
Ok(true)
}
Ok(false) => Ok(false),
Err(_) => Err(AccountServiceError::UpdateLabel),
}
}
pub async fn update_status(
&self,
account_id: &str,
status: &str,
) -> Result<Option<UpdateAccountStatus>, AccountServiceError> {
let status = parse_admin_account_status(status)?;
let repo = self.repository()?;
match repo.set_status(account_id, status).await {
Ok(true) => {
self.sync_runtime_account_status(repo, account_id, status)
.await?;
Ok(Some(UpdateAccountStatus {
id: account_id.to_string(),
status,
}))
}
Ok(false) => Ok(None),
Err(_) => Err(AccountServiceError::UpdateStatus),
}
}
pub async fn delete(&self, account_id: &str) -> Result<bool, AccountServiceError> {
match self.repository()?.delete(account_id).await {
Ok(true) => {
self.account_pool.lock().await.remove(account_id);
Ok(true)
}
Ok(false) => Ok(false),
Err(_) => Err(AccountServiceError::Delete),
}
}
pub async fn batch_delete(
&self,
ids: Vec<String>,
) -> Result<BatchDeleteAccounts, AccountServiceError> {
if ids.is_empty() {
return Err(AccountServiceError::EmptyIds);
}
let repo = self.repository()?;
let mut deleted = 0u32;
let mut not_found = Vec::new();
for account_id in ids {
match repo.delete(&account_id).await {
Ok(true) => {
self.account_pool.lock().await.remove(&account_id);
deleted += 1;
}
Ok(false) => not_found.push(account_id),
Err(_) => return Err(AccountServiceError::Delete),
}
}
Ok(BatchDeleteAccounts { deleted, not_found })
}
pub async fn batch_update_status(
&self,
ids: Vec<String>,
status: &str,
) -> Result<BatchUpdateAccountStatus, AccountServiceError> {
if ids.is_empty() {
return Err(AccountServiceError::EmptyIds);
}
let status = parse_admin_account_status(status)?;
let repo = self.repository()?;
let mut updated = 0u32;
let mut not_found = Vec::new();
for account_id in ids {
match repo.set_status(&account_id, status).await {
Ok(true) => {
self.sync_runtime_account_status(repo, &account_id, status)
.await?;
updated += 1;
}
Ok(false) => not_found.push(account_id),
Err(_) => return Err(AccountServiceError::UpdateStatus),
}
}
Ok(BatchUpdateAccountStatus { updated, not_found })
}
async fn sync_runtime_account_status(
&self,
repo: &AccountRepository,
account_id: &str,
status: AccountStatus,
) -> Result<(), AccountServiceError> {
if status == AccountStatus::Active {
match repo.get(account_id).await {
Ok(Some(account)) => {
self.account_pool
.lock()
.await
.insert(pool_account_from_stored(account));
}
Ok(None) => {
self.account_pool.lock().await.remove(account_id);
}
Err(_) => return Err(AccountServiceError::SyncStatus),
}
return Ok(());
}
self.account_pool.lock().await.remove(account_id);
Ok(())
}
}
pub(super) fn parse_admin_account_status(
status: &str,
) -> Result<AccountStatus, AccountServiceError> {
match status.trim().to_ascii_lowercase().as_str() {
"active" => Ok(AccountStatus::Active),
"expired" => Ok(AccountStatus::Expired),
"quota_exhausted" | "quota-exhausted" => Ok(AccountStatus::QuotaExhausted),
"refreshing" => Ok(AccountStatus::Refreshing),
"disabled" => Ok(AccountStatus::Disabled),
"banned" => Ok(AccountStatus::Banned),
other => Err(AccountServiceError::InvalidStatus(format!(
"Unsupported account status: {other}"
))),
}
}
+198
View File
@@ -0,0 +1,198 @@
use crate::codex::accounts::model::AccountStatus;
use super::{
health::{apply_codex_account_error, fetch_account_usage, public_codex_error},
AccountQuotaError, AccountQuotaResult, AccountService,
};
use serde_json::{json, Map, Value};
impl AccountService {
pub async fn account_quota(
&self,
account_id: &str,
request_id: &str,
) -> Result<AccountQuotaResult, AccountQuotaError> {
let repo = self
.repository
.as_ref()
.ok_or(AccountQuotaError::RepositoryUnavailable)?;
let account = match repo.get(account_id).await {
Ok(Some(account)) => account,
Ok(None) => return Err(AccountQuotaError::NotFound),
Err(_) => return Err(AccountQuotaError::Load),
};
if account.status != AccountStatus::Active {
return Err(AccountQuotaError::Inactive(account.status));
}
match fetch_account_usage(self, &account, request_id).await {
Ok(raw) => {
let quota = quota_from_usage(&raw);
if repo
.update_quota_json(&account.id, &quota.to_string())
.await
.is_err()
{
return Err(AccountQuotaError::StoreQuota);
}
Ok(AccountQuotaResult { quota, raw })
}
Err(error) => {
apply_codex_account_error(self, repo, &account, &error).await;
Err(AccountQuotaError::Fetch(public_codex_error(&error)))
}
}
}
}
pub(super) fn quota_from_usage(usage: &Value) -> Value {
let additional = usage
.get("additional_rate_limits")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let mut rate_limits_by_limit_id = Map::new();
for item in &additional {
let Some(limit_id) = item
.get("metered_feature")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
else {
continue;
};
let quota = quota_from_rate_limit(item.get("rate_limit"));
if quota.is_null() {
continue;
}
rate_limits_by_limit_id.insert(
limit_id.to_string(),
json!({
"limit_id": limit_id,
"limit_name": item.get("limit_name").cloned().unwrap_or(Value::Null),
"allowed": quota.get("allowed").cloned().unwrap_or(Value::Null),
"limit_reached": quota.get("limit_reached").cloned().unwrap_or(Value::Null),
"used_percent": quota.get("used_percent").cloned().unwrap_or(Value::Null),
"remaining_percent": quota.get("remaining_percent").cloned().unwrap_or(Value::Null),
"reset_at": quota.get("reset_at").cloned().unwrap_or(Value::Null),
"limit_window_seconds": quota.get("limit_window_seconds").cloned().unwrap_or(Value::Null),
"secondary_rate_limit": secondary_quota_from_rate_limit(item.get("rate_limit")),
}),
);
}
let additional_review = additional.iter().find(|item| {
is_review_limit_id(item.get("metered_feature").and_then(Value::as_str))
|| is_review_limit_id(item.get("limit_name").and_then(Value::as_str))
});
let code_review_rate_limit = match quota_from_rate_limit(usage.get("code_review_rate_limit")) {
Value::Null => {
quota_from_rate_limit(additional_review.and_then(|item| item.get("rate_limit")))
}
quota => quota,
};
json!({
"plan_type": usage.get("plan_type").cloned().unwrap_or(Value::Null),
"rate_limit": quota_from_rate_limit(usage.get("rate_limit")),
"secondary_rate_limit": secondary_quota_from_rate_limit(usage.get("rate_limit")),
"code_review_rate_limit": code_review_rate_limit,
"rate_limits_by_limit_id": if rate_limits_by_limit_id.is_empty() {
Value::Null
} else {
Value::Object(rate_limits_by_limit_id)
},
"credits": normalize_quota_credits(usage.get("credits")),
})
}
fn quota_from_rate_limit(rate_limit: Option<&Value>) -> Value {
let Some(rate_limit) = rate_limit.filter(|value| !value.is_null()) else {
return Value::Null;
};
let primary = rate_limit.get("primary_window");
let used_percent = primary
.and_then(|window| window.get("used_percent"))
.cloned()
.unwrap_or(Value::Null);
json!({
"allowed": rate_limit.get("allowed").cloned().unwrap_or(Value::Null),
"limit_reached": rate_limit.get("limit_reached").cloned().unwrap_or(Value::Null),
"used_percent": used_percent,
"remaining_percent": remaining_percent(primary.and_then(|window| window.get("used_percent"))),
"reset_at": primary.and_then(|window| window.get("reset_at")).cloned().unwrap_or(Value::Null),
"limit_window_seconds": primary.and_then(|window| window.get("limit_window_seconds")).cloned().unwrap_or(Value::Null),
})
}
fn secondary_quota_from_rate_limit(rate_limit: Option<&Value>) -> Value {
let Some(secondary) = rate_limit
.and_then(|rate_limit| rate_limit.get("secondary_window"))
.filter(|value| !value.is_null())
else {
return Value::Null;
};
let used_percent = secondary
.get("used_percent")
.cloned()
.unwrap_or(Value::Null);
let limit_reached = secondary
.get("used_percent")
.and_then(Value::as_f64)
.map(|used| used >= 100.0)
.map(Value::Bool)
.or_else(|| {
rate_limit
.and_then(|rate_limit| rate_limit.get("limit_reached"))
.cloned()
})
.unwrap_or(Value::Null);
json!({
"limit_reached": limit_reached,
"used_percent": used_percent,
"remaining_percent": remaining_percent(secondary.get("used_percent")),
"reset_at": secondary.get("reset_at").cloned().unwrap_or(Value::Null),
"limit_window_seconds": secondary.get("limit_window_seconds").cloned().unwrap_or(Value::Null),
})
}
fn normalize_quota_credits(raw: Option<&Value>) -> Value {
let Some(raw) = raw.filter(|value| !value.is_null()) else {
return Value::Null;
};
let Some(balance) = raw
.get("balance")
.and_then(Value::as_str)
.and_then(|value| value.parse::<f64>().ok())
.filter(|value| value.is_finite())
else {
return Value::Null;
};
json!({
"has_credits": raw.get("has_credits").and_then(Value::as_bool).unwrap_or(false),
"unlimited": raw.get("unlimited").and_then(Value::as_bool).unwrap_or(false),
"overage_limit_reached": raw.get("overage_limit_reached").and_then(Value::as_bool).unwrap_or(false),
"balance": balance,
})
}
fn remaining_percent(used_percent: Option<&Value>) -> Value {
let Some(used_percent) = used_percent.and_then(Value::as_f64) else {
return Value::Null;
};
json!((100.0 - used_percent.clamp(0.0, 100.0)).round() as i64)
}
fn is_review_limit_id(value: Option<&str>) -> bool {
let normalized = value
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.replace(['-', ' '], "_");
normalized == "review"
|| normalized == "code_review"
|| normalized == "codex_review"
|| normalized == "codex_code_review"
|| normalized.contains("code_review")
|| normalized.contains("codex_review")
}
+137
View File
@@ -0,0 +1,137 @@
use secrecy::{ExposeSecret, SecretString};
use crate::{
codex::accounts::{
model::{Account, AccountStatus},
repository::{AccountRepository, StoredAccount, TokenUpdate},
},
codex::oauth::RefreshFailure,
};
use super::{
health::skipped_probe_result, runtime_pool::pool_account_from_stored, AccountProbeOutcome,
AccountProbeResult, AccountService, RefreshAccountError,
};
impl AccountService {
pub async fn refresh_account(
&self,
account_id: &str,
) -> Result<AccountProbeResult, RefreshAccountError> {
let repo = self
.repository
.as_ref()
.ok_or(RefreshAccountError::RepositoryUnavailable)?;
let account = match repo.get(account_id).await {
Ok(Some(account)) => account,
Ok(None) => return Err(RefreshAccountError::NotFound),
Err(_) => return Err(RefreshAccountError::Load),
};
if account.status == AccountStatus::Disabled {
return Ok(skipped_probe_result(&account, "manually disabled"));
}
let Some(refresh_token) = account.refresh_token.as_ref() else {
return Ok(skipped_probe_result(&account, "no refresh token"));
};
let Some(refresher) = self.token_refresher.as_ref() else {
return Err(RefreshAccountError::TokenRefresherUnavailable);
};
let started_at = std::time::Instant::now();
let previous_status = account.status;
match refresher.refresh(refresh_token.expose_secret()).await {
Ok(tokens) => {
let updated = self
.persist_refreshed_account(
repo,
&account.id,
tokens.access_token,
tokens.refresh_token,
)
.await
.map_err(|()| RefreshAccountError::StoreRefreshed)?;
Ok(AccountProbeResult {
id: updated.id,
email: updated.email,
previous_status,
outcome: AccountProbeOutcome::Alive,
status: Some(updated.status),
error: None,
duration_ms: Some(started_at.elapsed().as_millis()),
})
}
Err(failure) => {
let status = self
.apply_refresh_failure_status(repo, &account, failure)
.await;
Ok(AccountProbeResult {
id: account.id,
email: account.email,
previous_status,
outcome: AccountProbeOutcome::Dead,
status,
error: Some(public_refresh_failure(failure).to_string()),
duration_ms: Some(started_at.elapsed().as_millis()),
})
}
}
}
async fn persist_refreshed_account(
&self,
repo: &AccountRepository,
account_id: &str,
access_token: String,
refresh_token: Option<String>,
) -> Result<Account, ()> {
repo.update_tokens(
account_id,
TokenUpdate {
access_token: SecretString::new(access_token.into()),
refresh_token: refresh_token.map(|token| SecretString::new(token.into())),
access_token_expires_at: None,
},
)
.await
.map_err(|_| ())?;
let account = repo.get(account_id).await.map_err(|_| ())?.ok_or(())?;
let account = pool_account_from_stored(account);
self.account_pool.lock().await.insert(account.clone());
Ok(account)
}
async fn apply_refresh_failure_status(
&self,
repo: &AccountRepository,
account: &StoredAccount,
failure: RefreshFailure,
) -> Option<AccountStatus> {
let status = status_for_refresh_failure(failure)?;
let _ = repo.set_status(&account.id, status).await;
self.account_pool
.lock()
.await
.set_status(&account.id, status);
Some(status)
}
}
fn status_for_refresh_failure(failure: RefreshFailure) -> Option<AccountStatus> {
match failure {
RefreshFailure::InvalidGrant => Some(AccountStatus::Expired),
RefreshFailure::QuotaExhausted => Some(AccountStatus::QuotaExhausted),
RefreshFailure::Banned => Some(AccountStatus::Banned),
RefreshFailure::Disabled => Some(AccountStatus::Disabled),
RefreshFailure::Transport => None,
}
}
fn public_refresh_failure(failure: RefreshFailure) -> &'static str {
match failure {
RefreshFailure::InvalidGrant => "invalidGrant",
RefreshFailure::QuotaExhausted => "quotaExhausted",
RefreshFailure::Banned => "banned",
RefreshFailure::Disabled => "disabled",
RefreshFailure::Transport => "transport",
}
}
@@ -0,0 +1,54 @@
use secrecy::ExposeSecret;
use crate::codex::accounts::{
model::Account,
repository::{AccountRepositoryResult, StoredAccount},
};
use super::AccountService;
impl AccountService {
pub async fn reload_runtime_accounts_from_repository(&self) -> AccountRepositoryResult<usize> {
let Some(repository) = self.repository.as_ref() else {
return Ok(0);
};
let accounts = repository.list_pool_accounts().await?;
let restored = accounts.len();
let mut pool = self.account_pool.lock().await;
for account in accounts {
pool.insert(account);
}
Ok(restored)
}
pub async fn insert_runtime_account(&self, account: Account) {
self.account_pool.lock().await.insert(account);
}
pub async fn acquire_runtime_account(&self, model: &str) -> Option<Account> {
self.account_pool.lock().await.acquire(model)
}
}
pub(super) fn pool_account_from_stored(account: StoredAccount) -> Account {
Account {
id: account.id,
email: account.email,
account_id: account.account_id,
user_id: account.user_id,
label: account.label,
plan_type: account.plan_type,
access_token: account.access_token.expose_secret().to_string(),
refresh_token: account
.refresh_token
.as_ref()
.map(|token| token.expose_secret().to_string()),
access_token_expires_at: account.access_token_expires_at,
status: account.status,
quota_limit_reached: false,
quota_cooldown_until: None,
cloudflare_cooldown_until: None,
added_at: account.added_at.to_rfc3339(),
last_used_at: None,
}
}
@@ -4,7 +4,7 @@ use sqlx::{Row, SqlitePool};
use thiserror::Error;
use uuid::Uuid;
use crate::crypto::{CryptoError, SecretBox};
use crate::utils::crypto::{CryptoError, SecretBox};
#[derive(Debug, Error)]
pub enum CookieError {
@@ -2,7 +2,7 @@ use chrono::Utc;
use sqlx::{Row, SqlitePool};
use uuid::Uuid;
use crate::fingerprint::{
use crate::codex::fingerprint::{
model::Fingerprint,
updater::{FingerprintUpdate, CODEX_DESKTOP_UPDATE_SOURCE},
};
@@ -1,7 +1,7 @@
use serde::Deserialize;
use thiserror::Error;
use crate::fingerprint::repository::FingerprintRepository;
use crate::codex::fingerprint::repository::FingerprintRepository;
pub const CODEX_DESKTOP_UPDATE_SOURCE: &str = "codex_desktop_update_source";
+8 -6
View File
@@ -1,6 +1,8 @@
pub mod client;
pub mod headers;
pub mod sse;
pub mod types;
pub mod usage;
pub mod websocket;
pub mod accounts;
pub mod cookies;
pub mod fingerprint;
pub mod models;
pub mod oauth;
pub mod protocol;
pub mod transport;
pub mod upstream;
@@ -1,2 +1,3 @@
pub mod catalog;
pub mod repository;
pub mod service;
@@ -2,7 +2,7 @@ use chrono::Utc;
use sqlx::{Row, SqlitePool};
use thiserror::Error;
use crate::models::catalog::{CodexModelInfo, ModelPlanSnapshot};
use crate::codex::models::catalog::{CodexModelInfo, ModelPlanSnapshot};
#[derive(Debug, Error)]
pub enum ModelSnapshotRepositoryError {
+184
View File
@@ -0,0 +1,184 @@
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::{
codex::accounts::{
model::{Account, AccountStatus},
pool::AccountPool,
repository::AccountRepository,
},
codex::fingerprint::model::Fingerprint,
codex::models::{
catalog::{ModelCatalog, ModelPlanSnapshot},
repository::ModelSnapshotRepository,
},
codex::transport::client::{build_reqwest_client, CodexBackendClient, CodexRequestContext},
config::AppConfig,
};
#[derive(Clone)]
pub struct ModelService {
config: AppConfig,
snapshot_repository: Option<ModelSnapshotRepository>,
account_repository: Option<AccountRepository>,
account_pool: Arc<Mutex<AccountPool>>,
}
#[derive(Debug, Clone, Copy)]
pub struct ModelRefreshResult {
pub refreshed_plans: usize,
pub model_count: usize,
pub failed_plans: usize,
}
#[derive(Debug)]
pub enum ModelServiceError {
AccountRepositoryUnavailable,
ModelRepositoryUnavailable,
ListAccounts,
NoAccounts,
BuildClient,
StoreSnapshot,
AllPlansFailed(ModelRefreshResult),
LoadSnapshots,
}
impl ModelService {
pub fn new(
config: AppConfig,
snapshot_repository: Option<ModelSnapshotRepository>,
account_repository: Option<AccountRepository>,
account_pool: Arc<Mutex<AccountPool>>,
) -> Self {
Self {
config,
snapshot_repository,
account_repository,
account_pool,
}
}
pub async fn catalog(&self) -> ModelCatalog {
let Some(repo) = self.snapshot_repository.as_ref() else {
return ModelCatalog::from_config(&self.config.model);
};
match repo.list_plan_snapshots().await {
Ok(snapshots) if !snapshots.is_empty() => {
ModelCatalog::from_config_and_snapshots(&self.config.model, &snapshots)
}
Ok(_) => ModelCatalog::from_config(&self.config.model),
Err(error) => {
// Cached model snapshots extend the catalog; fallback keeps proxy routes available.
tracing::warn!(?error, "failed to load cached model snapshots");
ModelCatalog::from_config(&self.config.model)
}
}
}
pub async fn refresh_backend_models(
&self,
request_id: &str,
) -> Result<ModelRefreshResult, ModelServiceError> {
let account_repo = self
.account_repository
.as_ref()
.ok_or(ModelServiceError::AccountRepositoryUnavailable)?;
let model_repo = self
.snapshot_repository
.as_ref()
.ok_or(ModelServiceError::ModelRepositoryUnavailable)?;
let accounts = account_repo
.list_pool_accounts()
.await
.map_err(|_| ModelServiceError::ListAccounts)?;
let plan_accounts = distinct_active_plan_accounts(accounts);
if plan_accounts.is_empty() {
return Err(ModelServiceError::NoAccounts);
}
let client = build_reqwest_client(self.config.tls.force_http11)
.map(|client| {
CodexBackendClient::new(
client,
self.config.api.base_url.clone(),
Fingerprint::default_codex_desktop(),
)
})
.map_err(|_| ModelServiceError::BuildClient)?;
let mut result = ModelRefreshResult {
refreshed_plans: 0,
model_count: 0,
failed_plans: 0,
};
for (plan_type, account) in plan_accounts {
let context = CodexRequestContext {
access_token: &account.access_token,
account_id: account.account_id.as_deref(),
request_id,
turn_state: None,
turn_metadata: None,
beta_features: None,
include_timing_metrics: None,
version: None,
codex_window_id: None,
parent_thread_id: None,
cookie_header: None,
};
let entries = match client.fetch_models(context).await {
Ok(entries) if !entries.is_empty() => entries,
Ok(_) => {
result.failed_plans += 1;
continue;
}
Err(error) => {
tracing::warn!(?error, plan_type, "failed to refresh backend models");
result.failed_plans += 1;
continue;
}
};
let snapshot = ModelPlanSnapshot::from_backend_entries(plan_type, entries);
result.model_count += snapshot.models.len();
model_repo
.replace_plan_snapshot(&snapshot)
.await
.map_err(|_| ModelServiceError::StoreSnapshot)?;
result.refreshed_plans += 1;
}
if result.refreshed_plans == 0 {
return Err(ModelServiceError::AllPlansFailed(result));
}
let snapshots = model_repo
.list_plan_snapshots()
.await
.map_err(|_| ModelServiceError::LoadSnapshots)?;
let allowlist = ModelCatalog::from_config_and_snapshots(&self.config.model, &snapshots)
.model_plan_allowlist();
self.account_pool
.lock()
.await
.set_model_plan_allowlist(allowlist);
Ok(result)
}
}
fn distinct_active_plan_accounts(accounts: Vec<Account>) -> Vec<(String, Account)> {
let mut by_plan = std::collections::BTreeMap::new();
for account in accounts {
if account.status != AccountStatus::Active {
continue;
}
let plan_type = account
.plan_type
.as_deref()
.unwrap_or("unknown")
.to_string();
by_plan.entry(plan_type).or_insert(account);
}
by_plan.into_iter().collect()
}
@@ -9,7 +9,7 @@ use serde::Deserialize;
use sha2::{Digest, Sha256};
use thiserror::Error;
use crate::auth::{
use super::{
refresh::{RefreshFailure, TokenRefresher},
token::TokenPair,
};
+9
View File
@@ -0,0 +1,9 @@
pub mod cli_import;
pub mod client;
pub mod refresh;
pub mod token;
pub use cli_import::*;
pub use client::*;
pub use refresh::*;
pub use token::*;
@@ -5,10 +5,9 @@ use chrono::{DateTime, Duration, Utc};
use thiserror::Error;
use tokio::sync::Semaphore;
use crate::{
accounts::model::{Account, AccountStatus},
auth::token::TokenPair,
};
use crate::codex::accounts::model::{Account, AccountStatus};
use super::token::TokenPair;
#[derive(Debug, Clone, Copy)]
pub struct RefreshPolicy {
@@ -4,7 +4,7 @@ use chrono::Utc;
use serde_json::{json, Value};
use uuid::Uuid;
use crate::codex::sse::{parse_sse_events, SseError};
use crate::codex::transport::sse::{parse_sse_events, SseError};
pub fn openai_error(message: &str, code: &str) -> Value {
json!({
@@ -39,11 +39,9 @@ pub fn chat_completion_from_codex_sse(
content.push_str(delta);
}
}
Some("response.reasoning_summary_text.delta") => {
if include_reasoning {
if let Some(delta) = value.get("delta").and_then(Value::as_str) {
reasoning.push_str(delta);
}
Some("response.reasoning_summary_text.delta") if include_reasoning => {
if let Some(delta) = value.get("delta").and_then(Value::as_str) {
reasoning.push_str(delta);
}
}
Some("response.output_item.added") => {
@@ -281,10 +279,8 @@ pub fn chat_completion_stream_from_codex_sse(
call_ids_with_deltas.insert(call_id);
}
}
Some("response.output_item.done") => {
if is_function_call_item(value.get("item")) {
has_tool_calls = true;
}
Some("response.output_item.done") if is_function_call_item(value.get("item")) => {
has_tool_calls = true;
}
Some("response.completed") => {
completed_response = value.get("response").cloned();
@@ -1,3 +1,4 @@
pub mod codex_to_openai;
pub mod error;
pub mod openai_to_codex;
pub mod schema;
@@ -2,8 +2,8 @@ use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::{
codex::types::CodexResponsesRequest,
error::{AppError, AppResult},
codex::protocol::error::{AppError, AppResult},
codex::transport::types::CodexResponsesRequest,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -8,7 +8,9 @@ use serde_json::Value;
use thiserror::Error;
use crate::{
codex::{
codex::fingerprint::model::Fingerprint,
codex::models::catalog::BackendModelEntry,
codex::transport::{
headers::build_codex_headers,
sse::SseError,
types::CodexResponsesRequest,
@@ -18,8 +20,6 @@ use crate::{
CodexTransport, CodexWebSocketError, WebSocketSupportError,
},
},
fingerprint::model::Fingerprint,
models::catalog::BackendModelEntry,
};
#[derive(Debug, Error)]
@@ -1,6 +1,6 @@
use std::collections::BTreeMap;
use crate::fingerprint::model::Fingerprint;
use crate::codex::fingerprint::model::Fingerprint;
pub fn build_codex_headers(
fp: &Fingerprint,
+6
View File
@@ -0,0 +1,6 @@
pub mod client;
pub mod headers;
pub mod sse;
pub mod types;
pub mod usage;
pub mod websocket;
@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::codex::sse::{parse_sse_events, SseError};
use crate::codex::transport::sse::{parse_sse_events, SseError};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -12,7 +12,7 @@ use futures::{SinkExt, StreamExt};
use reqwest::{header::HeaderMap, StatusCode};
use serde_json::{json, Value};
use crate::codex::{sse::encode_sse_event, types::CodexResponsesRequest};
use crate::codex::transport::{sse::encode_sse_event, types::CodexResponsesRequest};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodexTransport {
+29
View File
@@ -0,0 +1,29 @@
use axum::{http::StatusCode, Json};
use reqwest::Url;
use serde_json::Value;
use crate::codex::protocol::codex_to_openai::openai_error;
pub(crate) fn no_available_accounts_response() -> (StatusCode, Json<Value>) {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(openai_error(
"No available Codex accounts",
"no_available_accounts",
)),
)
}
pub(crate) fn normalize_service_tier_for_upstream(service_tier: String) -> String {
if service_tier == "fast" {
"priority".to_string()
} else {
service_tier
}
}
pub(super) fn request_domain(base_url: &str) -> Option<String> {
Url::parse(base_url)
.ok()
.and_then(|url| url.host_str().map(ToString::to_string))
}
+132
View File
@@ -0,0 +1,132 @@
use axum::http::StatusCode;
use serde_json::{json, Value};
use crate::codex::transport::client::CodexClientError;
const DEFAULT_RATE_LIMIT_BACKOFF_SECONDS: u64 = 60;
const MAX_RATE_LIMIT_BACKOFF_SECONDS: u64 = 86_400 * 7;
const CLOUDFLARE_CHALLENGE_COOLDOWN_SECONDS: u64 = 10;
#[derive(Debug, Clone, Copy)]
pub(crate) enum UpstreamAccountRetry {
RateLimited { retry_after_seconds: u64 },
QuotaExhausted,
CloudflareChallenge { cooldown_seconds: u64 },
Banned,
}
impl UpstreamAccountRetry {
pub(crate) fn status(self) -> StatusCode {
match self {
Self::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS,
Self::QuotaExhausted => StatusCode::PAYMENT_REQUIRED,
Self::CloudflareChallenge { .. } => StatusCode::FORBIDDEN,
Self::Banned => StatusCode::FORBIDDEN,
}
}
pub(crate) fn metadata(self, stream: bool) -> Value {
match self {
Self::RateLimited {
retry_after_seconds,
} => json!({
"stream": stream,
"retry": true,
"reason": "rateLimited",
"retryAfterSeconds": retry_after_seconds,
}),
Self::QuotaExhausted => json!({
"stream": stream,
"retry": true,
"reason": "quotaExhausted",
}),
Self::CloudflareChallenge { cooldown_seconds } => json!({
"stream": stream,
"retry": true,
"reason": "cloudflareChallenge",
"cooldownSeconds": cooldown_seconds,
}),
Self::Banned => json!({
"stream": stream,
"retry": true,
"reason": "banned",
}),
}
}
}
pub(crate) fn classify_upstream_account_retry(
error: &CodexClientError,
) -> Option<UpstreamAccountRetry> {
match error {
CodexClientError::Upstream {
status,
retry_after_seconds,
..
} if *status == StatusCode::TOO_MANY_REQUESTS => Some(UpstreamAccountRetry::RateLimited {
retry_after_seconds: retry_after_seconds
.unwrap_or(DEFAULT_RATE_LIMIT_BACKOFF_SECONDS)
.min(MAX_RATE_LIMIT_BACKOFF_SECONDS),
}),
CodexClientError::Upstream { status, .. } if *status == StatusCode::PAYMENT_REQUIRED => {
Some(UpstreamAccountRetry::QuotaExhausted)
}
CodexClientError::Upstream { status, body, .. } if *status == StatusCode::FORBIDDEN => {
if is_cloudflare_challenge(body) {
Some(UpstreamAccountRetry::CloudflareChallenge {
cooldown_seconds: CLOUDFLARE_CHALLENGE_COOLDOWN_SECONDS,
})
} else {
Some(UpstreamAccountRetry::Banned)
}
}
_ => None,
}
}
pub(crate) fn websocket_history_retry_metadata(retry: UpstreamAccountRetry, stream: bool) -> Value {
match retry {
UpstreamAccountRetry::RateLimited {
retry_after_seconds,
} => json!({
"stream": stream,
"transport": "websocket",
"retry": false,
"reason": "rateLimited",
"retryAfterSeconds": retry_after_seconds,
"accountAffinity": "previousResponseId",
}),
UpstreamAccountRetry::QuotaExhausted => json!({
"stream": stream,
"transport": "websocket",
"retry": false,
"reason": "quotaExhausted",
"accountAffinity": "previousResponseId",
}),
UpstreamAccountRetry::CloudflareChallenge { cooldown_seconds } => json!({
"stream": stream,
"transport": "websocket",
"retry": false,
"reason": "cloudflareChallenge",
"cooldownSeconds": cooldown_seconds,
"accountAffinity": "previousResponseId",
}),
UpstreamAccountRetry::Banned => json!({
"stream": stream,
"transport": "websocket",
"retry": false,
"reason": "banned",
"accountAffinity": "previousResponseId",
}),
}
}
fn is_cloudflare_challenge(body: &str) -> bool {
let lower = body.to_ascii_lowercase();
lower.contains("cf-mitigated")
|| lower.contains("cf-chl-bypass")
|| lower.contains("_cf_chl")
|| lower.contains("cf_chl")
|| lower.contains("attention required")
|| lower.contains("just a moment")
}
+859
View File
@@ -0,0 +1,859 @@
pub mod dispatch;
pub mod fallback;
pub mod refresh;
pub mod stream;
pub mod usage;
use std::{sync::Arc, time::Instant};
use axum::{
body::Body,
http::{
header::{CACHE_CONTROL, CONTENT_TYPE},
StatusCode,
},
response::{IntoResponse, Response},
Json,
};
use chrono::{Duration, Utc};
use futures::{stream as futures_stream, StreamExt};
use serde_json::{json, Value};
use tokio::sync::Mutex;
use crate::{
codex::accounts::{
model::{Account, AccountStatus},
pool::{AccountAcquireRequest, AccountPool},
repository::AccountRepository,
},
codex::cookies::repository::CookieRepository,
codex::fingerprint::model::Fingerprint,
codex::oauth::TokenRefresher,
codex::protocol::codex_to_openai::openai_error,
codex::transport::{
client::{
build_reqwest_client, CodexBackendClient, CodexBackendStream, CodexClientError,
CodexRequestContext,
},
types::CodexResponsesRequest,
usage::{extract_sse_usage, TokenUsage},
websocket::{transport_for_request, CodexTransport},
},
config::AppConfig,
logs::event::{EventLevel, EventLog},
logs::repository::EventLogRepository,
};
use crate::http::v1::errors::codex_client_error_response;
pub(crate) use self::{
dispatch::{no_available_accounts_response, normalize_service_tier_for_upstream},
fallback::{
classify_upstream_account_retry, websocket_history_retry_metadata, UpstreamAccountRetry,
},
stream::{completed_response_json, CollectedResponse},
};
use self::{
dispatch::request_domain,
refresh::refresh_account_after_unauthorized,
stream::{ensure_stream_metadata, responses_sse_failure},
usage::{record_request_attempt, record_usage_with_deps},
};
#[derive(Clone)]
struct CodexUpstreamDependencies {
config: AppConfig,
account_pool: Arc<Mutex<AccountPool>>,
account_repository: Option<AccountRepository>,
cookie_repository: Option<CookieRepository>,
event_logs: Option<EventLogRepository>,
token_refresher: Option<Arc<dyn TokenRefresher>>,
}
#[derive(Clone)]
pub(crate) struct CodexUpstreamService {
deps: CodexUpstreamDependencies,
}
impl CodexUpstreamService {
pub(crate) fn new(
config: AppConfig,
account_pool: Arc<Mutex<AccountPool>>,
account_repository: Option<AccountRepository>,
cookie_repository: Option<CookieRepository>,
event_logs: Option<EventLogRepository>,
token_refresher: Option<Arc<dyn TokenRefresher>>,
) -> Self {
Self {
deps: CodexUpstreamDependencies {
config,
account_pool,
account_repository,
cookie_repository,
event_logs,
token_refresher,
},
}
}
pub(crate) async fn acquire_account(&self, model: &str) -> Option<Account> {
self.deps
.account_pool
.lock()
.await
.acquire_with(AccountAcquireRequest::new(model, Utc::now()))
.map(|acquired| acquired.account)
}
pub(crate) async fn release_account(&self, account_id: &str) {
self.deps.account_pool.lock().await.release(account_id);
}
pub(crate) async fn send_codex_request_with_refresh_retry(
&self,
request: &CodexResponsesRequest,
account: &Account,
request_id: &str,
) -> Result<crate::codex::transport::client::CodexBackendResponse, CodexClientError> {
send_codex_request_with_refresh_retry_deps(&self.deps, request, account, request_id).await
}
pub(crate) async fn apply_retry_and_acquire_fallback(
&self,
account: &Account,
retry: UpstreamAccountRetry,
model: &str,
excluded_account_ids: &mut Vec<String>,
) -> Option<Account> {
apply_upstream_retry_and_acquire_fallback_with_deps(
&self.deps,
account,
retry,
model,
excluded_account_ids,
)
.await
}
pub(crate) async fn apply_account_retry(&self, account: &Account, retry: UpstreamAccountRetry) {
apply_upstream_account_retry_with_deps(&self.deps, account, retry).await;
}
pub(crate) async fn responses_stream(
&self,
request: CodexResponsesRequest,
mut account: Account,
mut log_context: CodexRequestLogContext,
) -> Response {
let deps = self.deps.clone();
if transport_for_request(&request) == CodexTransport::WebSocketRequired {
return responses_websocket_stream(deps, request, account, log_context).await;
}
let mut excluded_account_ids = Vec::new();
let stream_response = loop {
let stream_response = send_codex_stream_request_with_refresh_retry(
&deps,
&request,
&account,
log_context.request_id.as_str(),
)
.await;
match stream_response {
Ok(response) => break response,
Err(error) => {
deps.account_pool.lock().await.release(&account.id);
if let Some(retry) = classify_upstream_account_retry(&error) {
let fallback = apply_upstream_retry_and_acquire_fallback_with_deps(
&deps,
&account,
retry,
&request.model,
&mut excluded_account_ids,
)
.await;
log_codex_upstream_response_with_deps(
&deps,
&log_context,
retry.status(),
EventLevel::Warn,
"v1 responses stream upstream retrying with fallback account",
retry.metadata(true),
)
.await;
if let Some(fallback) = fallback {
account = fallback;
log_context = log_context.with_account(&account.id);
continue;
}
}
let error_response = codex_client_error_response(error);
log_codex_upstream_response_with_deps(
&deps,
&log_context,
error_response.0,
EventLevel::Error,
"v1 responses stream upstream request failed",
json!({"stream": true}),
)
.await;
return error_response.into_response();
}
}
};
if persist_upstream_cookies_with_deps(
&deps,
&account.id,
&stream_response.set_cookie_headers,
)
.await
.is_err()
{
deps.account_pool.lock().await.release(&account.id);
log_codex_upstream_response_with_deps(
&deps,
&log_context,
StatusCode::INTERNAL_SERVER_ERROR,
EventLevel::Error,
"v1 responses stream cookie persistence failed",
json!({"stream": true, "cookieStoreError": true}),
)
.await;
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(openai_error(
"Failed to persist upstream cookies",
"cookie_store_error",
)),
)
.into_response();
}
let upstream = Box::pin(stream_response.response.bytes_stream());
let audit = StreamAudit::new(deps, log_context, account.id);
let body_stream =
futures_stream::unfold(Some((upstream, Vec::new(), audit)), |state| async move {
let (mut upstream, mut collected, mut audit) = state?;
match upstream.next().await {
Some(Ok(chunk)) => {
collected.extend_from_slice(&chunk);
Some((Ok(chunk), Some((upstream, collected, audit))))
}
Some(Err(error)) => {
audit.log_transport_error(&error).await;
Some((Err(error), None))
}
None => {
audit.complete(&collected).await;
None
}
}
});
Response::builder()
.status(StatusCode::OK)
.header(CONTENT_TYPE, "text/event-stream")
.header(CACHE_CONTROL, "no-cache")
.body(Body::from_stream(body_stream))
.unwrap_or_else(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(openai_error(
"Failed to build stream response",
"stream_response_error",
)),
)
.into_response()
})
}
pub(crate) async fn persist_cookies(
&self,
account_id: &str,
set_cookie_headers: &[String],
) -> Result<(), ()> {
persist_upstream_cookies_with_deps(&self.deps, account_id, set_cookie_headers).await
}
pub(crate) async fn record_usage(&self, account_id: &str, usage: TokenUsage) -> Result<(), ()> {
record_usage_with_deps(&self.deps, account_id, usage).await
}
pub(crate) async fn log_response(
&self,
context: &CodexRequestLogContext,
status: StatusCode,
level: EventLevel,
message: &str,
metadata: Value,
) {
log_codex_upstream_response_with_deps(
&self.deps, context, status, level, message, metadata,
)
.await;
}
}
async fn send_codex_request_with_refresh_retry_deps(
deps: &CodexUpstreamDependencies,
request: &CodexResponsesRequest,
account: &Account,
request_id: &str,
) -> Result<crate::codex::transport::client::CodexBackendResponse, CodexClientError> {
match send_codex_request(deps, request, account, request_id).await {
Err(CodexClientError::Upstream {
status,
body,
retry_after_seconds,
}) if status == StatusCode::UNAUTHORIZED => {
let Some(refreshed) =
refresh_account_after_unauthorized(deps, request, account, request_id).await
else {
return Err(CodexClientError::Upstream {
status,
body,
retry_after_seconds,
});
};
send_codex_request(deps, request, &refreshed, request_id).await
}
result => result,
}
}
async fn send_codex_request(
deps: &CodexUpstreamDependencies,
request: &CodexResponsesRequest,
account: &Account,
request_id: &str,
) -> Result<crate::codex::transport::client::CodexBackendResponse, CodexClientError> {
let request_domain = request_domain(&deps.config.api.base_url);
let cookie_header = match (deps.cookie_repository.as_ref(), request_domain.as_deref()) {
(Some(repo), Some(domain)) => repo.cookie_header(&account.id, domain).await.ok().flatten(),
_ => None,
};
let client = CodexBackendClient::new(
build_reqwest_client(deps.config.tls.force_http11)?,
deps.config.api.base_url.clone(),
Fingerprint::default_codex_desktop(),
);
client
.create_response(
request,
CodexRequestContext {
access_token: &account.access_token,
account_id: account.account_id.as_deref(),
request_id,
turn_state: request.turn_state.as_deref(),
turn_metadata: request.turn_metadata.as_deref(),
beta_features: request.beta_features.as_deref(),
include_timing_metrics: request.include_timing_metrics.as_deref(),
version: request.version.as_deref(),
codex_window_id: request.codex_window_id.as_deref(),
parent_thread_id: request.parent_thread_id.as_deref(),
cookie_header: cookie_header.as_deref(),
},
)
.await
}
async fn send_codex_stream_request_with_refresh_retry(
deps: &CodexUpstreamDependencies,
request: &CodexResponsesRequest,
account: &Account,
request_id: &str,
) -> Result<CodexBackendStream, CodexClientError> {
match send_codex_stream_request(deps, request, account, request_id).await {
Err(CodexClientError::Upstream {
status,
body,
retry_after_seconds,
}) if status == StatusCode::UNAUTHORIZED => {
let Some(refreshed) =
refresh_account_after_unauthorized(deps, request, account, request_id).await
else {
return Err(CodexClientError::Upstream {
status,
body,
retry_after_seconds,
});
};
send_codex_stream_request(deps, request, &refreshed, request_id).await
}
result => result,
}
}
async fn send_codex_stream_request(
deps: &CodexUpstreamDependencies,
request: &CodexResponsesRequest,
account: &Account,
request_id: &str,
) -> Result<CodexBackendStream, CodexClientError> {
let request_domain = request_domain(&deps.config.api.base_url);
let cookie_header = match (deps.cookie_repository.as_ref(), request_domain.as_deref()) {
(Some(repo), Some(domain)) => repo.cookie_header(&account.id, domain).await.ok().flatten(),
_ => None,
};
let client = CodexBackendClient::new(
build_reqwest_client(deps.config.tls.force_http11)?,
deps.config.api.base_url.clone(),
Fingerprint::default_codex_desktop(),
);
client
.stream_response(
request,
CodexRequestContext {
access_token: &account.access_token,
account_id: account.account_id.as_deref(),
request_id,
turn_state: request.turn_state.as_deref(),
turn_metadata: request.turn_metadata.as_deref(),
beta_features: request.beta_features.as_deref(),
include_timing_metrics: request.include_timing_metrics.as_deref(),
version: request.version.as_deref(),
codex_window_id: request.codex_window_id.as_deref(),
parent_thread_id: request.parent_thread_id.as_deref(),
cookie_header: cookie_header.as_deref(),
},
)
.await
}
async fn apply_upstream_retry_and_acquire_fallback_with_deps(
deps: &CodexUpstreamDependencies,
account: &Account,
retry: UpstreamAccountRetry,
model: &str,
excluded_account_ids: &mut Vec<String>,
) -> Option<Account> {
apply_upstream_account_retry_with_deps(deps, account, retry).await;
excluded_account_ids.push(account.id.clone());
deps.account_pool
.lock()
.await
.acquire_with(
AccountAcquireRequest::new(model, Utc::now())
.with_exclude_account_ids(excluded_account_ids.iter().cloned()),
)
.map(|fallback| fallback.account)
}
async fn apply_upstream_account_retry_with_deps(
deps: &CodexUpstreamDependencies,
account: &Account,
retry: UpstreamAccountRetry,
) {
match retry {
UpstreamAccountRetry::RateLimited {
retry_after_seconds,
} => {
let cooldown_until = Utc::now() + Duration::seconds(retry_after_seconds as i64);
deps.account_pool
.lock()
.await
.mark_quota_limited_until(&account.id, cooldown_until);
if record_request_attempt(deps, &account.id).await.is_err() {
tracing::warn!(
account_id = %account.id,
"failed to record rate-limited account attempt"
);
}
}
UpstreamAccountRetry::QuotaExhausted => {
set_account_status(deps, account, AccountStatus::QuotaExhausted).await;
}
UpstreamAccountRetry::CloudflareChallenge { cooldown_seconds } => {
let cooldown_until = Utc::now() + Duration::seconds(cooldown_seconds as i64);
deps.account_pool
.lock()
.await
.set_cloudflare_cooldown_until(&account.id, cooldown_until);
}
UpstreamAccountRetry::Banned => {
set_account_status(deps, account, AccountStatus::Banned).await;
}
}
}
async fn set_account_status(
deps: &CodexUpstreamDependencies,
account: &Account,
status: AccountStatus,
) {
if let Some(repo) = deps.account_repository.as_ref() {
if repo.set_status(&account.id, status).await.is_err() {
tracing::warn!(
account_id = %account.id,
"failed to persist upstream account status"
);
}
}
deps.account_pool
.lock()
.await
.set_status(&account.id, status);
}
async fn responses_websocket_stream(
deps: CodexUpstreamDependencies,
request: CodexResponsesRequest,
mut account: Account,
mut log_context: CodexRequestLogContext,
) -> Response {
let mut excluded_account_ids = Vec::new();
let response = loop {
let response = send_codex_request_with_refresh_retry_deps(
&deps,
&request,
&account,
log_context.request_id.as_str(),
)
.await;
deps.account_pool.lock().await.release(&account.id);
match response {
Ok(response) => break response,
Err(error) => {
if let Some(retry) = classify_upstream_account_retry(&error) {
if request.previous_response_id.is_some() {
// previous_response_id 的历史由上游账号持有,换账号会静默丢失会话上下文。
apply_upstream_account_retry_with_deps(&deps, &account, retry).await;
log_codex_upstream_response_with_deps(
&deps,
&log_context,
retry.status(),
EventLevel::Warn,
"v1 responses websocket history request kept on original account",
websocket_history_retry_metadata(retry, true),
)
.await;
} else {
let fallback = apply_upstream_retry_and_acquire_fallback_with_deps(
&deps,
&account,
retry,
&request.model,
&mut excluded_account_ids,
)
.await;
log_codex_upstream_response_with_deps(
&deps,
&log_context,
retry.status(),
EventLevel::Warn,
"v1 responses websocket upstream retrying with fallback account",
retry.metadata(true),
)
.await;
if let Some(fallback) = fallback {
account = fallback;
log_context = log_context.with_account(&account.id);
continue;
}
}
}
let error_response = codex_client_error_response(error);
log_codex_upstream_response_with_deps(
&deps,
&log_context,
error_response.0,
EventLevel::Error,
"v1 responses websocket stream upstream request failed",
json!({"stream": true, "transport": "websocket"}),
)
.await;
return error_response.into_response();
}
}
};
if persist_upstream_cookies_with_deps(&deps, &account.id, &response.set_cookie_headers)
.await
.is_err()
{
log_codex_upstream_response_with_deps(
&deps,
&log_context,
StatusCode::INTERNAL_SERVER_ERROR,
EventLevel::Error,
"v1 responses websocket stream cookie persistence failed",
json!({"stream": true, "transport": "websocket", "cookieStoreError": true}),
)
.await;
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(openai_error(
"Failed to persist upstream cookies",
"cookie_store_error",
)),
)
.into_response();
}
let rate_limit_headers = response.rate_limit_headers.clone();
let mut level = EventLevel::Info;
let mut message = "v1 responses websocket stream completed";
let mut metadata = json!({
"stream": true,
"transport": "websocket",
"usage": response.usage,
"rateLimitHeaders": rate_limit_headers.clone(),
});
if let Some(usage) = response.usage {
if record_usage_with_deps(&deps, &account.id, usage)
.await
.is_err()
{
level = EventLevel::Warn;
message = "v1 responses websocket stream completed with usage store error";
metadata = json!({
"stream": true,
"transport": "websocket",
"usage": usage,
"rateLimitHeaders": rate_limit_headers.clone(),
"usageStoreError": true,
});
}
}
log_codex_upstream_response_with_deps(
&deps,
&log_context,
StatusCode::OK,
level,
message,
metadata,
)
.await;
Response::builder()
.status(StatusCode::OK)
.header(CONTENT_TYPE, "text/event-stream")
.header(CACHE_CONTROL, "no-cache")
.body(Body::from(response.body))
.unwrap_or_else(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(openai_error(
"Failed to build stream response",
"stream_response_error",
)),
)
.into_response()
})
}
async fn persist_upstream_cookies_with_deps(
deps: &CodexUpstreamDependencies,
account_id: &str,
set_cookie_headers: &[String],
) -> Result<(), ()> {
let Some(cookie_repo) = deps.cookie_repository.as_ref() else {
return Ok(());
};
for cookie in set_cookie_headers {
cookie_repo
.capture_set_cookie(account_id, cookie)
.await
.map_err(|_| ())?;
}
Ok(())
}
#[derive(Clone)]
pub(crate) struct CodexRequestLogContext {
request_id: String,
account_id: String,
model: String,
stream: bool,
started_at: Instant,
}
impl CodexRequestLogContext {
pub(crate) fn new(
request_id: &str,
account_id: &str,
model: &str,
stream: bool,
started_at: Instant,
) -> Self {
Self {
request_id: request_id.to_string(),
account_id: account_id.to_string(),
model: model.to_string(),
stream,
started_at,
}
}
fn latency_ms(&self) -> i64 {
self.started_at.elapsed().as_millis().min(i64::MAX as u128) as i64
}
pub(crate) fn with_account(&self, account_id: &str) -> Self {
Self {
request_id: self.request_id.clone(),
account_id: account_id.to_string(),
model: self.model.clone(),
stream: self.stream,
started_at: self.started_at,
}
}
}
struct StreamAudit {
deps: CodexUpstreamDependencies,
context: CodexRequestLogContext,
account_slot: AccountSlotGuard,
}
impl StreamAudit {
fn new(
deps: CodexUpstreamDependencies,
context: CodexRequestLogContext,
account_id: String,
) -> Self {
let account_slot = AccountSlotGuard::new(deps.account_pool.clone(), account_id);
Self {
deps,
context,
account_slot,
}
}
async fn complete(&mut self, body: &[u8]) {
let body = String::from_utf8_lossy(body);
let mut status = StatusCode::OK;
let mut level = EventLevel::Info;
let mut message = "v1 responses stream completed";
let mut metadata = match extract_sse_usage(&body) {
Ok(usage) => {
if let Some(usage) = usage {
if record_usage_with_deps(&self.deps, &self.context.account_id, usage)
.await
.is_err()
{
level = EventLevel::Warn;
message = "v1 responses stream completed with usage store error";
json!({"stream": true, "usage": usage, "usageStoreError": true})
} else {
json!({"stream": true, "usage": usage})
}
} else {
json!({"stream": true, "usage": null})
}
}
Err(error) => {
level = EventLevel::Warn;
message = "v1 responses stream completed with invalid SSE usage";
json!({"stream": true, "sseParseError": error.to_string()})
}
};
match responses_sse_failure(&body) {
Ok(Some(failure)) => {
// SSE 响应头已发出,HTTP 状态不能回滚;用终止事件透传给客户端,并在审计里标记上游失败。
status = StatusCode::BAD_GATEWAY;
level = EventLevel::Error;
message = "v1 responses stream upstream SSE failed";
failure.extend_metadata(&mut metadata);
}
Ok(None) => {}
Err(error) => {
level = EventLevel::Warn;
message = "v1 responses stream completed with invalid SSE failure metadata";
metadata = json!({"stream": true, "sseParseError": error.to_string()});
}
}
ensure_stream_metadata(&mut metadata, true);
log_codex_upstream_response_with_deps(
&self.deps,
&self.context,
status,
level,
message,
metadata,
)
.await;
self.account_slot.release().await;
}
async fn log_transport_error(&mut self, error: &reqwest::Error) {
log_codex_upstream_response_with_deps(
&self.deps,
&self.context,
StatusCode::BAD_GATEWAY,
EventLevel::Error,
"v1 responses stream transport failed",
json!({"stream": true, "transportError": error.to_string()}),
)
.await;
self.account_slot.release().await;
}
}
struct AccountSlotGuard {
pool: Arc<Mutex<AccountPool>>,
account_id: String,
released: bool,
}
impl AccountSlotGuard {
fn new(pool: Arc<Mutex<AccountPool>>, account_id: String) -> Self {
Self {
pool,
account_id,
released: false,
}
}
async fn release(&mut self) {
if self.released {
return;
}
self.pool.lock().await.release(&self.account_id);
self.released = true;
}
}
impl Drop for AccountSlotGuard {
fn drop(&mut self) {
if self.released {
return;
}
let pool = self.pool.clone();
let account_id = self.account_id.clone();
let Ok(handle) = tokio::runtime::Handle::try_current() else {
return;
};
handle.spawn(async move {
pool.lock().await.release(&account_id);
});
}
}
async fn log_codex_upstream_response_with_deps(
deps: &CodexUpstreamDependencies,
context: &CodexRequestLogContext,
status: StatusCode,
level: EventLevel,
message: &str,
mut metadata: Value,
) {
let Some(repo) = deps.event_logs.as_ref() else {
return;
};
ensure_stream_metadata(&mut metadata, context.stream);
let mut event = EventLog::new("v1.response", level, message);
event.request_id = Some(context.request_id.clone());
event.account_id = Some(context.account_id.clone());
event.route = Some("/v1/responses".to_string());
event.model = Some(context.model.clone());
event.status_code = Some(i64::from(status.as_u16()));
event.latency_ms = Some(context.latency_ms());
event.metadata = metadata;
if let Err(error) = repo.insert(event).await {
tracing::warn!(?error, "failed to insert v1 response event log");
}
}
+147
View File
@@ -0,0 +1,147 @@
use axum::http::StatusCode;
use secrecy::SecretString;
use serde_json::json;
use crate::{
codex::accounts::{
model::{Account, AccountStatus},
repository::TokenUpdate,
},
codex::oauth::{RefreshFailure, TokenPair},
codex::transport::types::CodexResponsesRequest,
logs::event::{EventLevel, EventLog},
};
use super::CodexUpstreamDependencies;
pub(super) async fn refresh_account_after_unauthorized(
deps: &CodexUpstreamDependencies,
request: &CodexResponsesRequest,
account: &Account,
request_id: &str,
) -> Option<Account> {
if !deps.config.auth.refresh_enabled {
return None;
}
let refresh_token = account.refresh_token.as_deref()?;
let refresher = deps.token_refresher.as_ref()?;
match refresher.refresh(refresh_token).await {
Ok(tokens) => persist_refreshed_account(deps, account, tokens).await,
Err(failure) => {
mark_refresh_failure(deps, account, failure, request_id, &request.model).await;
None
}
}
}
async fn persist_refreshed_account(
deps: &CodexUpstreamDependencies,
account: &Account,
tokens: TokenPair,
) -> Option<Account> {
let repo = deps.account_repository.as_ref()?;
let access_token = tokens.access_token;
let refresh_token = tokens.refresh_token;
repo.update_tokens(
&account.id,
TokenUpdate {
access_token: SecretString::new(access_token.clone().into()),
refresh_token: refresh_token
.clone()
.map(|token| SecretString::new(token.into())),
access_token_expires_at: None,
},
)
.await
.ok()?;
let mut refreshed = account.clone();
refreshed.access_token = access_token;
if let Some(refresh_token) = refresh_token {
refreshed.refresh_token = Some(refresh_token);
}
refreshed.status = AccountStatus::Active;
deps.account_pool.lock().await.insert(refreshed.clone());
Some(refreshed)
}
async fn mark_refresh_failure(
deps: &CodexUpstreamDependencies,
account: &Account,
failure: RefreshFailure,
request_id: &str,
model: &str,
) {
let status = status_for_refresh_failure(failure);
if let Some(status) = status {
if let Some(repo) = deps.account_repository.as_ref() {
let _ = repo.set_status(&account.id, status).await;
}
let mut updated = account.clone();
updated.status = status;
deps.account_pool.lock().await.insert(updated);
}
log_account_refresh_failure(deps, account, failure, status, request_id, model).await;
}
fn status_for_refresh_failure(failure: RefreshFailure) -> Option<AccountStatus> {
match failure {
RefreshFailure::InvalidGrant => Some(AccountStatus::Expired),
RefreshFailure::QuotaExhausted => Some(AccountStatus::QuotaExhausted),
RefreshFailure::Banned => Some(AccountStatus::Banned),
RefreshFailure::Disabled => Some(AccountStatus::Disabled),
RefreshFailure::Transport => None,
}
}
async fn log_account_refresh_failure(
deps: &CodexUpstreamDependencies,
account: &Account,
failure: RefreshFailure,
status: Option<AccountStatus>,
request_id: &str,
model: &str,
) {
let Some(repo) = deps.event_logs.as_ref() else {
return;
};
let mut event = EventLog::new(
"account.refresh",
EventLevel::Warn,
"account refresh failed after upstream 401",
);
event.request_id = Some(request_id.to_string());
event.account_id = Some(account.id.clone());
event.route = Some("/v1/responses".to_string());
event.model = Some(model.to_string());
event.status_code = Some(i64::from(StatusCode::UNAUTHORIZED.as_u16()));
event.metadata = json!({
"trigger": "upstream_401",
"failure": refresh_failure_value(failure),
"accountStatus": status.map(account_status_value),
});
if let Err(error) = repo.insert(event).await {
tracing::warn!(?error, "failed to insert account refresh event log");
}
}
fn refresh_failure_value(failure: RefreshFailure) -> &'static str {
match failure {
RefreshFailure::InvalidGrant => "invalidGrant",
RefreshFailure::QuotaExhausted => "quotaExhausted",
RefreshFailure::Banned => "banned",
RefreshFailure::Disabled => "disabled",
RefreshFailure::Transport => "transport",
}
}
fn account_status_value(status: AccountStatus) -> &'static str {
match status {
AccountStatus::Active => "active",
AccountStatus::Expired => "expired",
AccountStatus::QuotaExhausted => "quotaExhausted",
AccountStatus::Refreshing => "refreshing",
AccountStatus::Disabled => "disabled",
AccountStatus::Banned => "banned",
}
}
+207
View File
@@ -0,0 +1,207 @@
use serde_json::{json, Value};
use crate::codex::transport::sse::parse_sse_events;
pub(crate) enum CollectedResponse {
Completed(Value),
Failed(ResponsesSseFailure),
MissingCompleted,
}
#[derive(Debug, Clone)]
pub(crate) struct ResponsesSseFailure {
event: String,
message: String,
upstream_code: Option<String>,
}
impl ResponsesSseFailure {
fn from_event(event: &str, value: &Value) -> Self {
Self {
event: event.to_string(),
message: failure_message(value).unwrap_or_else(|| "Codex upstream SSE failed".into()),
upstream_code: failure_code(value),
}
}
pub(crate) fn openai_error_message(&self) -> String {
format!("Codex upstream SSE failed: {}", self.message)
}
pub(crate) fn metadata(&self, stream: bool) -> Value {
let mut metadata = json!({"stream": stream});
self.extend_metadata(&mut metadata);
metadata
}
pub(super) fn extend_metadata(&self, metadata: &mut Value) {
let Some(object) = metadata.as_object_mut() else {
*metadata = self.metadata(true);
return;
};
object.insert("failureEvent".to_string(), json!(self.event));
object.insert("failureMessage".to_string(), json!(self.message));
if let Some(code) = &self.upstream_code {
object.insert("upstreamCode".to_string(), json!(code));
}
}
}
pub(crate) fn completed_response_json(
body: &str,
) -> Result<CollectedResponse, crate::codex::transport::sse::SseError> {
let events = parse_sse_events(body)?;
let mut output_text = String::new();
let mut output_items = Vec::new();
let mut completed_response = None;
let mut failed_response = None;
for event in events {
let Ok(value) = serde_json::from_str::<Value>(&event.data) else {
continue;
};
match event.event.as_deref() {
Some("response.output_text.delta") => {
if let Some(delta) = value.get("delta").and_then(Value::as_str) {
output_text.push_str(delta);
}
}
Some("response.output_item.done") => {
if let Some(item) = value.get("item") {
output_items.push(item.clone());
}
}
Some("response.completed") => {
if let Some(response) = value.get("response") {
completed_response = Some(response.clone());
}
}
Some(event_name @ ("error" | "response.failed")) if failed_response.is_none() => {
failed_response = Some(ResponsesSseFailure::from_event(event_name, &value));
}
_ => {}
}
}
if let Some(failure) = failed_response {
return Ok(CollectedResponse::Failed(failure));
}
let Some(mut response) = completed_response else {
return Ok(CollectedResponse::MissingCompleted);
};
ensure_completed_response_output(&mut response, &output_items, &output_text);
sync_output_text_from_output(&mut response);
Ok(CollectedResponse::Completed(response))
}
pub(super) fn responses_sse_failure(
body: &str,
) -> Result<Option<ResponsesSseFailure>, crate::codex::transport::sse::SseError> {
for event in parse_sse_events(body)? {
let Some(event_name @ ("error" | "response.failed")) = event.event.as_deref() else {
continue;
};
let Ok(value) = serde_json::from_str::<Value>(&event.data) else {
continue;
};
return Ok(Some(ResponsesSseFailure::from_event(event_name, &value)));
}
Ok(None)
}
pub(super) fn ensure_stream_metadata(metadata: &mut Value, stream_value: bool) {
let Some(object) = metadata.as_object_mut() else {
*metadata = json!({"stream": stream_value});
return;
};
object
.entry("stream".to_string())
.or_insert_with(|| json!(stream_value));
}
fn failure_message(value: &Value) -> Option<String> {
value
.pointer("/response/error/message")
.or_else(|| value.pointer("/error/message"))
.or_else(|| value.get("message"))
.and_then(Value::as_str)
.filter(|message| !message.trim().is_empty())
.map(ToString::to_string)
}
fn failure_code(value: &Value) -> Option<String> {
value
.pointer("/response/error/code")
.or_else(|| value.pointer("/error/code"))
.or_else(|| value.get("code"))
.and_then(Value::as_str)
.filter(|code| !code.trim().is_empty())
.map(ToString::to_string)
}
fn ensure_completed_response_output(
response: &mut Value,
output_items: &[Value],
output_text: &str,
) {
let output_is_empty = response
.get("output")
.and_then(Value::as_array)
.is_none_or(Vec::is_empty);
if !output_is_empty {
return;
}
if !output_items.is_empty() {
response["output"] = Value::Array(output_items.to_vec());
return;
}
if output_text.is_empty() {
return;
}
response["output"] = json!([{
"type": "message",
"status": "completed",
"role": "assistant",
"content": [{
"type": "output_text",
"text": output_text,
"annotations": []
}]
}]);
}
fn sync_output_text_from_output(response: &mut Value) {
let output_text_is_empty = response
.get("output_text")
.and_then(Value::as_str)
.is_none_or(str::is_empty);
if !output_text_is_empty {
return;
}
let Some(items) = response.get("output").and_then(Value::as_array) else {
return;
};
let texts = items
.iter()
.filter_map(output_text_from_item)
.collect::<Vec<_>>();
if texts.is_empty() {
return;
}
response["output_text"] = Value::String(texts.join("\n\n"));
}
fn output_text_from_item(item: &Value) -> Option<String> {
let content = item.get("content")?.as_array()?;
let text = content
.iter()
.filter_map(|part| {
let part_type = part.get("type")?.as_str()?;
if part_type != "output_text" && part_type != "text" {
return None;
}
part.get("text")?.as_str()
})
.collect::<String>();
(!text.is_empty()).then_some(text)
}
+46
View File
@@ -0,0 +1,46 @@
use crate::{codex::accounts::repository::UsageDelta, codex::transport::usage::TokenUsage};
use super::CodexUpstreamDependencies;
pub(super) async fn record_usage_with_deps(
deps: &CodexUpstreamDependencies,
account_id: &str,
usage: TokenUsage,
) -> Result<(), ()> {
let Some(repo) = deps.account_repository.as_ref() else {
return Ok(());
};
repo.record_usage(
account_id,
UsageDelta {
input_tokens: u64_to_i64_saturating(usage.input_tokens),
output_tokens: u64_to_i64_saturating(usage.output_tokens),
cached_tokens: u64_to_i64_saturating(usage.cached_tokens),
},
)
.await
.map_err(|_| ())
}
pub(super) async fn record_request_attempt(
deps: &CodexUpstreamDependencies,
account_id: &str,
) -> Result<(), ()> {
let Some(repo) = deps.account_repository.as_ref() else {
return Ok(());
};
repo.record_usage(
account_id,
UsageDelta {
input_tokens: 0,
output_tokens: 0,
cached_tokens: 0,
},
)
.await
.map_err(|_| ())
}
fn u64_to_i64_saturating(value: u64) -> i64 {
value.min(i64::MAX as u64) as i64
}
-216
View File
@@ -1,216 +0,0 @@
use std::{collections::BTreeMap, path::Path};
use serde::{Deserialize, Serialize};
pub type ConfigResult<T> = Result<T, config::ConfigError>;
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct AppConfig {
pub server: ServerConfig,
pub api: ApiConfig,
pub model: ModelConfig,
pub auth: AuthConfig,
pub quota: QuotaConfig,
pub usage_stats: UsageStatsConfig,
pub database: DatabaseConfig,
pub security: SecurityConfig,
pub tls: TlsConfig,
pub admin: AdminConfig,
pub logging: LoggingConfig,
}
impl AppConfig {
pub fn load() -> ConfigResult<Self> {
let _ = dotenvy::dotenv();
Self::load_from_dir_with_env("config", std::env::vars())
}
pub fn load_from_dir_with_env<K, V, I>(
config_dir: impl AsRef<Path>,
env: I,
) -> ConfigResult<Self>
where
K: AsRef<str>,
V: AsRef<str>,
I: IntoIterator<Item = (K, V)>,
{
let config_dir = config_dir.as_ref();
let mut cfg: Self = config::Config::builder()
.add_source(config::File::from(config_dir.join("default.yaml")).required(true))
.add_source(config::File::from(config_dir.join("local.yaml")).required(false))
.add_source(config::File::from(config_dir.join("local.yml")).required(false))
.build()?
.try_deserialize()?;
cfg.apply_env(env)?;
Ok(cfg)
}
fn apply_env<K, V, I>(&mut self, env: I) -> ConfigResult<()>
where
K: AsRef<str>,
V: AsRef<str>,
I: IntoIterator<Item = (K, V)>,
{
for (key, value) in env {
let key = key.as_ref();
let value = value.as_ref();
match key {
"CPRS_HOST" => self.server.host = value.to_string(),
"CPRS_PORT" => self.server.port = parse_env(key, value)?,
"CPRS_BASE_URL" => self.api.base_url = value.to_string(),
"CPRS_DEFAULT_MODEL" => self.model.default_model = value.to_string(),
"CPRS_DEFAULT_REASONING_EFFORT" => {
self.model.default_reasoning_effort = parse_optional_string(value);
}
"CPRS_SERVICE_TIER" => self.model.service_tier = parse_optional_string(value),
"CPRS_REFRESH_MARGIN_SECONDS" => {
self.auth.refresh_margin_seconds = parse_env(key, value)?;
}
"CPRS_REFRESH_ENABLED" => self.auth.refresh_enabled = parse_env(key, value)?,
"CPRS_REFRESH_CONCURRENCY" => {
self.auth.refresh_concurrency = parse_env(key, value)?;
}
"CPRS_MAX_CONCURRENT_PER_ACCOUNT" => {
self.auth.max_concurrent_per_account = parse_env(key, value)?;
}
"CPRS_REQUEST_INTERVAL_MS" => {
self.auth.request_interval_ms = parse_env(key, value)?;
}
"CPRS_ROTATION_STRATEGY" => self.auth.rotation_strategy = value.to_string(),
"CPRS_QUOTA_REFRESH_INTERVAL_MINUTES" => {
self.quota.refresh_interval_minutes = parse_env(key, value)?;
}
"CPRS_QUOTA_SKIP_EXHAUSTED" => {
self.quota.skip_exhausted = parse_env(key, value)?;
}
"CPRS_USAGE_HISTORY_RETENTION_DAYS" => {
self.usage_stats.history_retention_days = parse_optional_env(key, value)?;
}
"CPRS_DATABASE_URL" => self.database.url = value.to_string(),
"CPRS_MASTER_KEY_FILE" => self.security.master_key_file = value.to_string(),
"CPRS_API_KEY_PEPPER_FILE" => {
self.security.api_key_pepper_file = value.to_string();
}
"CPRS_FORCE_HTTP11" => self.tls.force_http11 = parse_env(key, value)?,
"CPRS_ADMIN_SESSION_TTL_MINUTES" => {
self.admin.session_ttl_minutes = parse_env(key, value)?;
}
"CPRS_LOG_DIR" => self.logging.directory = value.to_string(),
"CPRS_LOG_MAX_FILE_BYTES" => self.logging.max_file_bytes = parse_env(key, value)?,
"CPRS_LOG_RETENTION_DAYS" => self.logging.retention_days = parse_env(key, value)?,
"CPRS_LOGS_ENABLED" => self.logging.enabled = parse_env(key, value)?,
"CPRS_LOGS_CAPACITY" => self.logging.capacity = parse_env(key, value)?,
"CPRS_LOGS_CAPTURE_BODY" => self.logging.capture_body = parse_env(key, value)?,
_ => {}
}
}
Ok(())
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct ApiConfig {
pub base_url: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct ModelConfig {
#[serde(rename = "default")]
pub default_model: String,
pub default_reasoning_effort: Option<String>,
#[serde(rename = "default_service_tier")]
pub service_tier: Option<String>,
pub aliases: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct AuthConfig {
pub refresh_margin_seconds: u64,
pub refresh_enabled: bool,
pub refresh_concurrency: u32,
pub max_concurrent_per_account: usize,
pub request_interval_ms: u64,
pub rotation_strategy: String,
pub tier_priority: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct QuotaConfig {
pub refresh_interval_minutes: u64,
pub warning_thresholds: QuotaWarningThresholds,
pub skip_exhausted: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct QuotaWarningThresholds {
pub primary: Vec<u8>,
pub secondary: Vec<u8>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct UsageStatsConfig {
pub history_retention_days: Option<u64>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct DatabaseConfig {
pub url: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct SecurityConfig {
pub master_key_file: String,
pub api_key_pepper_file: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct TlsConfig {
pub force_http11: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct AdminConfig {
pub session_ttl_minutes: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct LoggingConfig {
pub directory: String,
pub max_file_bytes: u64,
pub retention_days: u64,
pub enabled: bool,
pub capacity: u32,
pub capture_body: bool,
}
fn parse_env<T>(key: &str, value: &str) -> ConfigResult<T>
where
T: std::str::FromStr,
T::Err: std::fmt::Display,
{
value
.parse::<T>()
.map_err(|error| config::ConfigError::Message(format!("invalid {key}: {error}")))
}
fn parse_optional_env<T>(key: &str, value: &str) -> ConfigResult<Option<T>>
where
T: std::str::FromStr,
T::Err: std::fmt::Display,
{
match parse_optional_string(value) {
Some(value) => parse_env(key, &value).map(Some),
None => Ok(None),
}
}
fn parse_optional_string(value: &str) -> Option<String> {
let value = value.trim();
(!value.is_empty() && value != "null").then(|| value.to_string())
}
+19
View File
@@ -0,0 +1,19 @@
use std::path::Path;
use super::{types::AppConfig, ConfigResult};
impl AppConfig {
pub fn load() -> ConfigResult<Self> {
Self::load_from_dir(".")
}
pub fn load_from_dir(config_dir: impl AsRef<Path>) -> ConfigResult<Self> {
let config_dir = config_dir.as_ref();
::config::Config::builder()
.add_source(::config::File::from(config_dir.join("config.yaml")).required(true))
.add_source(::config::File::from(config_dir.join("local.yaml")).required(false))
.add_source(::config::File::from(config_dir.join("local.yml")).required(false))
.build()?
.try_deserialize()
}
}
+6
View File
@@ -0,0 +1,6 @@
mod loader;
mod types;
pub use types::*;
pub type ConfigResult<T> = Result<T, ::config::ConfigError>;
+99
View File
@@ -0,0 +1,99 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct AppConfig {
pub server: ServerConfig,
pub api: ApiConfig,
pub model: ModelConfig,
pub auth: AuthConfig,
pub quota: QuotaConfig,
pub usage_stats: UsageStatsConfig,
pub database: DatabaseConfig,
pub security: SecurityConfig,
pub tls: TlsConfig,
pub admin: AdminConfig,
pub logging: LoggingConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct ApiConfig {
pub base_url: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct ModelConfig {
#[serde(rename = "default")]
pub default_model: String,
pub default_reasoning_effort: Option<String>,
#[serde(rename = "default_service_tier")]
pub service_tier: Option<String>,
pub aliases: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct AuthConfig {
pub refresh_margin_seconds: u64,
pub refresh_enabled: bool,
pub refresh_concurrency: u32,
pub max_concurrent_per_account: usize,
pub request_interval_ms: u64,
pub rotation_strategy: String,
pub tier_priority: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct QuotaConfig {
pub refresh_interval_minutes: u64,
pub warning_thresholds: QuotaWarningThresholds,
pub skip_exhausted: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct QuotaWarningThresholds {
pub primary: Vec<u8>,
pub secondary: Vec<u8>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct UsageStatsConfig {
pub history_retention_days: Option<u64>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct DatabaseConfig {
pub url: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct SecurityConfig {
pub master_key_file: String,
pub api_key_pepper_file: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct TlsConfig {
pub force_http11: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct AdminConfig {
pub session_ttl_minutes: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct LoggingConfig {
pub directory: String,
pub max_file_bytes: u64,
pub retention_days: u64,
pub enabled: bool,
pub capacity: u32,
pub capture_body: bool,
}
-5169
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+520
View File
@@ -0,0 +1,520 @@
use axum::{
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
Extension, Json,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{
app::state::AppState,
auth::api_key_repository::StoredClientApiKey,
http::middleware::RequestId,
service::api_key::{ApiKeyServiceError, ImportedClientApiKey},
utils::pagination::{clamp_limit, Page},
};
use super::{
account_export_ids, require_admin_session, AdminEnvelope, AdminError, AdminPageEnvelope,
AdminResponse,
};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApiKeysQuery {
pub cursor: Option<String>,
pub limit: Option<u32>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApiKeyExportQuery {
pub ids: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateApiKeyRequest {
pub name: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientApiKeyData {
pub id: String,
pub name: String,
pub label: Option<String>,
pub prefix: String,
pub enabled: bool,
pub created_at: String,
pub last_used_at: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreatedClientApiKeyData {
pub id: String,
pub name: String,
pub label: Option<String>,
pub prefix: String,
pub enabled: bool,
pub created_at: String,
pub last_used_at: Option<String>,
pub plaintext: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateClientApiKeyStatusRequest {
pub status: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateClientApiKeyLabelRequest {
pub label: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateClientApiKeyStatusData {
pub id: String,
pub enabled: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BatchDeleteClientApiKeysRequest {
pub ids: Vec<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BatchDeleteClientApiKeysData {
pub deleted: u32,
pub not_found: Vec<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteClientApiKeyData {
pub deleted: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientApiKeyExportData {
pub source_format: &'static str,
pub rotation_required: bool,
pub api_keys: Vec<ClientApiKeyExportEntry>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientApiKeyExportEntry {
pub id: String,
pub name: String,
pub label: Option<String>,
pub prefix: String,
pub enabled: bool,
pub created_at: String,
pub last_used_at: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientApiKeyImportData {
pub imported: u32,
pub skipped: u32,
pub rotated: bool,
pub keys: Vec<ImportedClientApiKeyData>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportedClientApiKeyData {
pub source_id: Option<String>,
pub source_prefix: Option<String>,
pub id: String,
pub name: String,
pub label: Option<String>,
pub prefix: String,
pub enabled: bool,
pub created_at: String,
pub last_used_at: Option<String>,
pub plaintext: String,
}
impl From<StoredClientApiKey> for ClientApiKeyData {
fn from(key: StoredClientApiKey) -> Self {
Self {
id: key.id,
name: key.name,
label: key.label,
prefix: key.prefix,
enabled: key.enabled,
created_at: key.created_at,
last_used_at: key.last_used_at,
}
}
}
impl From<StoredClientApiKey> for ClientApiKeyExportEntry {
fn from(key: StoredClientApiKey) -> Self {
Self {
id: key.id,
name: key.name,
label: key.label,
prefix: key.prefix,
enabled: key.enabled,
created_at: key.created_at,
last_used_at: key.last_used_at,
}
}
}
impl CreatedClientApiKeyData {
fn new(key: StoredClientApiKey, plaintext: String) -> Self {
Self {
id: key.id,
name: key.name,
label: key.label,
prefix: key.prefix,
enabled: key.enabled,
created_at: key.created_at,
last_used_at: key.last_used_at,
plaintext,
}
}
}
impl ImportedClientApiKeyData {
fn new(imported: ImportedClientApiKey) -> Self {
let ImportedClientApiKey {
key,
plaintext,
source_id,
source_prefix,
} = imported;
Self {
source_id,
source_prefix,
id: key.id,
name: key.name,
label: key.label,
prefix: key.prefix,
enabled: key.enabled,
created_at: key.created_at,
last_used_at: key.last_used_at,
plaintext,
}
}
}
fn api_key_service_error(error: ApiKeyServiceError, request_id: String) -> AdminError {
match error {
ApiKeyServiceError::RepositoryUnavailable => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"API key repository is not initialized",
request_id,
),
ApiKeyServiceError::HasherUnavailable => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"API key hasher is not initialized",
request_id,
),
ApiKeyServiceError::List => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to list API keys",
request_id,
),
ApiKeyServiceError::Export => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to export API keys",
request_id,
),
ApiKeyServiceError::Import => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to import API key",
request_id,
),
ApiKeyServiceError::Create => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to create API key",
request_id,
),
ApiKeyServiceError::Delete => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to delete API key",
request_id,
),
ApiKeyServiceError::UpdateLabel => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to update API key label",
request_id,
),
ApiKeyServiceError::UpdateStatus => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to update API key status",
request_id,
),
ApiKeyServiceError::Verify => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to verify API key",
request_id,
),
ApiKeyServiceError::InvalidStatus(message) => {
AdminError::new(StatusCode::BAD_REQUEST, 40001, message, request_id)
}
ApiKeyServiceError::EmptyName => AdminError::new(
StatusCode::BAD_REQUEST,
40001,
"API key name is required",
request_id,
),
ApiKeyServiceError::EmptyIds => AdminError::new(
StatusCode::BAD_REQUEST,
40001,
"API key ids are required",
request_id,
),
ApiKeyServiceError::LabelTooLong => AdminError::new(
StatusCode::BAD_REQUEST,
40001,
"API key label must be 64 characters or fewer",
request_id,
),
ApiKeyServiceError::NoImportableKeys => AdminError::new(
StatusCode::BAD_REQUEST,
40001,
"No importable API keys found",
request_id,
),
}
}
fn api_key_not_found(request_id: String) -> AdminError {
AdminError::new(
StatusCode::NOT_FOUND,
40401,
"API key not found",
request_id,
)
}
pub async fn api_keys(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Query(query): Query<ApiKeysQuery>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
let limit = clamp_limit(query.limit.unwrap_or(50));
match state.services.api_keys.list(query.cursor, limit).await {
Ok(page) => {
let Page { items, next_cursor } = page;
let page = Page {
items: items.into_iter().map(ClientApiKeyData::from).collect(),
next_cursor,
};
Ok(AdminResponse::new(
StatusCode::OK,
AdminPageEnvelope::ok(page, limit, request_id),
))
}
Err(error) => Err(api_key_service_error(error, request_id)),
}
}
pub async fn export_api_keys(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Query(query): Query<ApiKeyExportQuery>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
let ids = account_export_ids(query.ids.as_deref());
let keys = match state.services.api_keys.export(ids).await {
Ok(keys) => keys,
Err(error) => return Err(api_key_service_error(error, request_id)),
};
// 安全边界:本地 cpr_ key 只导出可展示元数据,绝不导出 plaintext、key_hash 或 pepper。
let data = ClientApiKeyExportData {
source_format: "rustLocalClientApiKeys",
rotation_required: true,
api_keys: keys
.into_iter()
.map(ClientApiKeyExportEntry::from)
.collect(),
};
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(data, request_id),
))
}
pub async fn import_api_keys(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Json(payload): Json<Value>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
let imported = match state.services.api_keys.import(&payload).await {
Ok(imported) => imported,
Err(error) => return Err(api_key_service_error(error, request_id)),
};
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(
ClientApiKeyImportData {
imported: imported.imported,
skipped: imported.skipped,
rotated: true,
keys: imported
.keys
.into_iter()
.map(ImportedClientApiKeyData::new)
.collect(),
},
request_id,
),
))
}
pub async fn create_api_key(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Json(payload): Json<CreateApiKeyRequest>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
match state.services.api_keys.create(&payload.name).await {
Ok(created) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(
CreatedClientApiKeyData::new(created.key, created.plaintext),
request_id,
),
)),
Err(error) => Err(api_key_service_error(error, request_id)),
}
}
pub async fn batch_delete_api_keys(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Json(payload): Json<BatchDeleteClientApiKeysRequest>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
let deleted = match state.services.api_keys.batch_delete(payload.ids).await {
Ok(deleted) => deleted,
Err(error) => return Err(api_key_service_error(error, request_id)),
};
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(
BatchDeleteClientApiKeysData {
deleted: deleted.deleted,
not_found: deleted.not_found,
},
request_id,
),
))
}
pub async fn update_api_key_label(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Path(key_id): Path<String>,
Json(payload): Json<UpdateClientApiKeyLabelRequest>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
// label 是后台显示备注,不能影响 cpr_ key 的 hash、prefix 或启用状态。
match state
.services
.api_keys
.update_label(&key_id, payload.label)
.await
{
Ok(Some(key)) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(ClientApiKeyData::from(key), request_id),
)),
Ok(None) => Err(api_key_not_found(request_id)),
Err(error) => Err(api_key_service_error(error, request_id)),
}
}
pub async fn update_api_key_status(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Path(key_id): Path<String>,
Json(payload): Json<UpdateClientApiKeyStatusRequest>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
match state
.services
.api_keys
.update_status(key_id, &payload.status)
.await
{
Ok(Some(updated)) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(
UpdateClientApiKeyStatusData {
id: updated.id,
enabled: updated.enabled,
},
request_id,
),
)),
Ok(None) => Err(api_key_not_found(request_id)),
Err(error) => Err(api_key_service_error(error, request_id)),
}
}
pub async fn delete_api_key(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Path(key_id): Path<String>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
match state.services.api_keys.delete(&key_id).await {
Ok(true) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(DeleteClientApiKeyData { deleted: true }, request_id),
)),
Ok(false) => Err(api_key_not_found(request_id)),
Err(error) => Err(api_key_service_error(error, request_id)),
}
}
+660
View File
@@ -0,0 +1,660 @@
use axum::{
extract::{Path, Query, State},
http::{
header::{HeaderValue, HOST, LOCATION, SET_COOKIE},
HeaderMap, StatusCode,
},
response::{IntoResponse, Response},
Extension, Json,
};
use reqwest::Url;
use serde::{Deserialize, Serialize};
use crate::{
app::state::AppState,
codex::oauth::{DeviceCode, OAuthError},
config::AppConfig,
http::auth::admin_session_id,
http::middleware::RequestId,
service::admin_auth::{
AdminAuthOAuthError, AdminAuthPkceExchange,
AdminAuthPoolSummary as ServiceAdminAuthPoolSummary, AdminAuthServiceError,
AdminAuthStatus as ServiceAdminAuthStatus, AdminAuthUser as ServiceAdminAuthUser,
AdminSessionValidationError,
},
};
use super::{
account_status_value, validated_account_import_error, AdminEnvelope, AdminError, AdminResponse,
};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LoginRequest {
pub password: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LoginData {
pub expires_at: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminAuthStatusData {
pub authenticated: bool,
pub user: Option<AdminAuthUserData>,
pub pool: AdminAuthPoolSummaryData,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminAuthUserData {
pub id: String,
pub email: Option<String>,
pub account_id: Option<String>,
pub user_id: Option<String>,
pub label: Option<String>,
pub plan_type: Option<String>,
pub status: String,
pub access_token_expires_at: Option<String>,
}
#[derive(Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminAuthPoolSummaryData {
pub total: usize,
pub active: usize,
pub expired: usize,
pub quota_exhausted: usize,
pub refreshing: usize,
pub disabled: usize,
pub banned: usize,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminAuthLogoutData {
pub success: bool,
pub deleted: u64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminAuthDeviceLoginData {
pub user_code: String,
pub verification_uri: String,
pub verification_uri_complete: String,
pub device_code: String,
pub expires_in: u64,
pub interval: u64,
}
impl From<DeviceCode> for AdminAuthDeviceLoginData {
fn from(device: DeviceCode) -> Self {
Self {
user_code: device.user_code,
verification_uri: device.verification_uri,
verification_uri_complete: device.verification_uri_complete,
device_code: device.device_code,
expires_in: device.expires_in,
interval: device.interval,
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminAuthDevicePollData {
pub success: bool,
pub pending: bool,
pub code: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminAuthLoginStartData {
pub auth_url: String,
pub state: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminAuthCodeRelayRequest {
pub callback_url: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminAuthCallbackQuery {
pub code: Option<String>,
pub state: Option<String>,
pub error: Option<String>,
pub error_description: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminAuthCodeRelayData {
pub success: bool,
}
fn auth_status_data(status: ServiceAdminAuthStatus) -> AdminAuthStatusData {
AdminAuthStatusData {
authenticated: status.authenticated,
user: status.user.map(auth_user_data),
pool: auth_pool_summary_data(status.pool),
}
}
fn auth_user_data(user: ServiceAdminAuthUser) -> AdminAuthUserData {
AdminAuthUserData {
id: user.id,
email: user.email,
account_id: user.account_id,
user_id: user.user_id,
label: user.label,
plan_type: user.plan_type,
status: account_status_value(user.status).to_string(),
access_token_expires_at: user.access_token_expires_at.map(|value| value.to_rfc3339()),
}
}
fn auth_pool_summary_data(summary: ServiceAdminAuthPoolSummary) -> AdminAuthPoolSummaryData {
AdminAuthPoolSummaryData {
total: summary.total,
active: summary.active,
expired: summary.expired,
quota_exhausted: summary.quota_exhausted,
refreshing: summary.refreshing,
disabled: summary.disabled,
banned: summary.banned,
}
}
fn admin_session_set_cookie(session_id: &str, ttl_minutes: u64) -> Option<HeaderValue> {
let max_age = ttl_minutes.checked_mul(60)?;
let cookie = format!(
"cpr_admin_session={session_id}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}"
);
HeaderValue::from_str(&cookie).ok()
}
pub(super) async fn require_admin_session(
state: &AppState,
headers: &HeaderMap,
request_id: &str,
) -> Result<(), AdminError> {
match state
.services
.admin_auth
.validate_session(admin_session_id(headers))
.await
{
Ok(true) => Ok(()),
Ok(false) => Err(AdminError::new(
StatusCode::UNAUTHORIZED,
40101,
"Admin session required",
request_id,
)),
Err(AdminSessionValidationError::DatabaseUnavailable) => Err(AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Database is not initialized",
request_id,
)),
Err(AdminSessionValidationError::ValidateSession) => Err(AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to validate admin session",
request_id,
)),
}
}
fn oauth_error(error: OAuthError, request_id: &str, message: &'static str) -> AdminError {
let status = match error {
OAuthError::Rejected(_) => StatusCode::BAD_REQUEST,
OAuthError::AuthorizationPending | OAuthError::SlowDown | OAuthError::Transport => {
StatusCode::BAD_GATEWAY
}
};
let body_code = if status == StatusCode::BAD_REQUEST {
40001
} else {
50201
};
AdminError::new(status, body_code, message, request_id)
}
fn admin_oauth_error(error: AdminAuthOAuthError, request_id: &str) -> AdminError {
match error {
AdminAuthOAuthError::OAuthClientUnavailable => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"OAuth client is not initialized",
request_id,
),
AdminAuthOAuthError::AccountRepositoryUnavailable => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Account repository is not initialized",
request_id,
),
AdminAuthOAuthError::DeviceCodeRequired => AdminError::new(
StatusCode::BAD_REQUEST,
40001,
"Device code is required",
request_id,
),
AdminAuthOAuthError::InvalidOrExpiredSession => AdminError::new(
StatusCode::BAD_REQUEST,
40001,
"Invalid or expired OAuth session",
request_id,
),
AdminAuthOAuthError::DeviceCodeRequest(error) => {
oauth_error(error, request_id, "Device code request failed")
}
AdminAuthOAuthError::DeviceAuthorization(error) => {
oauth_error(error, request_id, "Device authorization failed")
}
AdminAuthOAuthError::TokenExchange(error) => {
oauth_error(error, request_id, "Token exchange failed")
}
AdminAuthOAuthError::Import(error) => validated_account_import_error(error, request_id),
}
}
fn request_host(headers: &HeaderMap, config: &AppConfig) -> String {
headers
.get(HOST)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(|| format!("localhost:{}", config.server.port))
}
fn parse_callback_url(callback_url: &str) -> Result<(String, String), ()> {
let callback_url = callback_url.trim();
if callback_url.is_empty() {
return Err(());
}
let url = Url::parse(callback_url).map_err(|_| ())?;
if url.query_pairs().any(|(key, _)| key == "error") {
return Err(());
}
let code = url
.query_pairs()
.find_map(|(key, value)| (key == "code").then(|| value.into_owned()))
.filter(|value| !value.trim().is_empty())
.ok_or(())?;
let state = url
.query_pairs()
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
.filter(|value| !value.trim().is_empty())
.ok_or(())?;
Ok((code, state))
}
fn redirect_to_host(return_host: &str, request_id: &str) -> Result<Response, AdminError> {
let location = format!("http://{return_host}/");
let Ok(location) = HeaderValue::from_str(&location) else {
return Err(AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Invalid OAuth return host",
request_id,
));
};
let mut response = StatusCode::SEE_OTHER.into_response();
response.headers_mut().insert(LOCATION, location);
Ok(response)
}
fn login_error(error: AdminAuthServiceError, request_id: &str) -> AdminError {
match error {
AdminAuthServiceError::DatabaseUnavailable => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Database is not initialized",
request_id,
),
AdminAuthServiceError::AdminPasswordInvalid => AdminError::new(
StatusCode::UNAUTHORIZED,
40102,
"Admin password invalid",
request_id,
),
AdminAuthServiceError::LoadAdminUser => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to load admin user",
request_id,
),
AdminAuthServiceError::VerifyAdminPassword => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to verify admin password",
request_id,
),
AdminAuthServiceError::InvalidSessionTtl => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Admin session ttl is invalid",
request_id,
),
AdminAuthServiceError::CreateSession => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to create admin session",
request_id,
),
AdminAuthServiceError::AccountRepositoryUnavailable
| AdminAuthServiceError::InspectAccountAuthStatus
| AdminAuthServiceError::ClearAccounts => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to create admin session",
request_id,
),
}
}
fn auth_status_error(error: AdminAuthServiceError, request_id: &str) -> AdminError {
match error {
AdminAuthServiceError::AccountRepositoryUnavailable => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Account repository is not initialized",
request_id,
),
AdminAuthServiceError::InspectAccountAuthStatus => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to inspect account auth status",
request_id,
),
AdminAuthServiceError::DatabaseUnavailable
| AdminAuthServiceError::LoadAdminUser
| AdminAuthServiceError::AdminPasswordInvalid
| AdminAuthServiceError::VerifyAdminPassword
| AdminAuthServiceError::InvalidSessionTtl
| AdminAuthServiceError::CreateSession
| AdminAuthServiceError::ClearAccounts => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to inspect account auth status",
request_id,
),
}
}
fn logout_error(error: AdminAuthServiceError, request_id: &str) -> AdminError {
match error {
AdminAuthServiceError::AccountRepositoryUnavailable => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Account repository is not initialized",
request_id,
),
AdminAuthServiceError::ClearAccounts => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to clear accounts",
request_id,
),
AdminAuthServiceError::DatabaseUnavailable
| AdminAuthServiceError::LoadAdminUser
| AdminAuthServiceError::AdminPasswordInvalid
| AdminAuthServiceError::VerifyAdminPassword
| AdminAuthServiceError::InvalidSessionTtl
| AdminAuthServiceError::CreateSession
| AdminAuthServiceError::InspectAccountAuthStatus => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to clear accounts",
request_id,
),
}
}
pub async fn login(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
Json(payload): Json<LoginRequest>,
) -> Result<Response, AdminError> {
let request_id = request_id.as_str().to_string();
let login = state
.services
.admin_auth
.login(&payload.password)
.await
.map_err(|error| login_error(error, &request_id))?;
let Some(cookie) = admin_session_set_cookie(&login.session_id, login.ttl_minutes) else {
return Err(AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to create admin session cookie",
request_id,
));
};
let mut response = AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(
LoginData {
expires_at: login.expires_at.to_rfc3339(),
},
request_id,
),
)
.into_response();
response.headers_mut().insert(SET_COOKIE, cookie);
Ok(response)
}
pub async fn auth_status(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
let status = state
.services
.admin_auth
.status()
.await
.map_err(|error| auth_status_error(error, &request_id))?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(auth_status_data(status), request_id),
))
}
pub async fn auth_logout(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
let logout = state
.services
.admin_auth
.logout()
.await
.map_err(|error| logout_error(error, &request_id))?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(
AdminAuthLogoutData {
success: true,
deleted: logout.deleted,
},
request_id,
),
))
}
pub async fn auth_device_login(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
let device = state
.services
.admin_auth
.request_device_code()
.await
.map_err(|error| admin_oauth_error(error, &request_id))?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AdminAuthDeviceLoginData::from(device), request_id),
))
}
pub async fn auth_device_poll(
State(state): State<AppState>,
Path(device_code): Path<String>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
let result = state
.services
.admin_auth
.poll_device_token(&device_code)
.await
.map_err(|error| admin_oauth_error(error, &request_id))?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(
AdminAuthDevicePollData {
success: result.success,
pending: result.pending,
code: result.code,
},
request_id,
),
))
}
pub async fn auth_login_start(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
let return_host = request_host(&headers, state.config());
let login = state
.services
.admin_auth
.start_pkce_login(&return_host)
.await;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(
AdminAuthLoginStartData {
auth_url: login.auth_url,
state: login.state,
},
request_id,
),
))
}
pub async fn auth_code_relay(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Json(payload): Json<AdminAuthCodeRelayRequest>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
let Ok((code, oauth_state)) = parse_callback_url(&payload.callback_url) else {
return Err(AdminError::new(
StatusCode::BAD_REQUEST,
40001,
"Invalid OAuth callback URL",
request_id,
));
};
let exchange = state
.services
.admin_auth
.exchange_pkce_code(&oauth_state, &code)
.await
.map_err(|error| admin_oauth_error(error, &request_id))?;
match exchange {
AdminAuthPkceExchange::Imported { .. } | AdminAuthPkceExchange::AlreadyCompleted => {
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AdminAuthCodeRelayData { success: true }, request_id),
))
}
}
}
pub async fn auth_callback(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Query(query): Query<AdminAuthCallbackQuery>,
) -> Result<Response, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
if query.error.is_some() {
return Err(AdminError::new(
StatusCode::BAD_REQUEST,
40001,
query
.error_description
.unwrap_or_else(|| "OAuth error".to_string()),
request_id,
));
}
let (Some(code), Some(oauth_state)) = (query.code.as_deref(), query.state.as_deref()) else {
return Err(AdminError::new(
StatusCode::BAD_REQUEST,
40001,
"Missing OAuth code or state",
request_id,
));
};
let exchange = state
.services
.admin_auth
.exchange_pkce_code(oauth_state, code)
.await
.map_err(|error| admin_oauth_error(error, &request_id))?;
match exchange {
AdminAuthPkceExchange::Imported { return_host } => {
redirect_to_host(&return_host, &request_id)
}
AdminAuthPkceExchange::AlreadyCompleted => {
redirect_to_host(&request_host(&headers, state.config()), &request_id)
}
}
}
+57
View File
@@ -0,0 +1,57 @@
use axum::{
extract::{Query, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
Extension,
};
use serde::Deserialize;
use crate::{
app::state::AppState, http::middleware::RequestId, service::log::LogServiceError,
utils::pagination::clamp_limit,
};
use super::{require_admin_session, AdminError, AdminPageEnvelope, AdminResponse};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LogsQuery {
pub cursor: Option<String>,
pub limit: Option<u32>,
}
pub async fn logs(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Query(query): Query<LogsQuery>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
let limit = clamp_limit(query.limit.unwrap_or(50));
match state.services.logs.list(query.cursor, limit).await {
Ok(page) => Ok(AdminResponse::new(
StatusCode::OK,
AdminPageEnvelope::ok(page, limit, request_id),
)),
Err(error) => Err(log_service_error(error, request_id)),
}
}
fn log_service_error(error: LogServiceError, request_id: String) -> AdminError {
match error {
LogServiceError::RepositoryUnavailable => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Event log repository is not initialized",
request_id,
),
LogServiceError::List => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to list event logs",
request_id,
),
}
}
+19
View File
@@ -0,0 +1,19 @@
pub mod accounts;
pub mod api_keys;
pub mod auth;
pub mod logs;
pub mod models;
pub mod response;
pub mod router;
pub mod settings;
pub mod usage;
use self::{
accounts::{account_export_ids, account_status_value, validated_account_import_error},
auth::require_admin_session,
};
pub use self::{
response::{AdminEnvelope, AdminError, AdminPageEnvelope, AdminResponse, PageMeta},
router::router,
};
+114
View File
@@ -0,0 +1,114 @@
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::IntoResponse,
Extension,
};
use serde::Serialize;
use crate::{
app::state::AppState,
codex::models::service::{ModelRefreshResult, ModelServiceError},
http::middleware::RequestId,
};
use super::{require_admin_session, AdminEnvelope, AdminError, AdminResponse};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RefreshModelsData {
pub refreshed_plans: usize,
pub model_count: usize,
pub failed_plans: usize,
}
impl From<ModelRefreshResult> for RefreshModelsData {
fn from(result: ModelRefreshResult) -> Self {
Self {
refreshed_plans: result.refreshed_plans,
model_count: result.model_count,
failed_plans: result.failed_plans,
}
}
}
pub async fn refresh_models(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
match state
.services
.models
.refresh_backend_models(&request_id)
.await
{
Ok(result) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(RefreshModelsData::from(result), request_id),
)),
Err(error) => model_service_error_response(error, request_id),
}
}
fn model_service_error_response(
error: ModelServiceError,
request_id: String,
) -> Result<AdminResponse<AdminEnvelope<RefreshModelsData>>, AdminError> {
match error {
ModelServiceError::AccountRepositoryUnavailable => Err(AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Account repository is not initialized",
request_id,
)),
ModelServiceError::ModelRepositoryUnavailable => Err(AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Model repository is not initialized",
request_id,
)),
ModelServiceError::ListAccounts => Err(AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to list accounts",
request_id,
)),
ModelServiceError::NoAccounts => Err(AdminError::new(
StatusCode::BAD_REQUEST,
40001,
"No accounts available for model refresh",
request_id,
)),
ModelServiceError::BuildClient => Err(AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to build Codex client",
request_id,
)),
ModelServiceError::StoreSnapshot => Err(AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to store model snapshot",
request_id,
)),
ModelServiceError::AllPlansFailed(result) => Ok(AdminResponse::new(
StatusCode::BAD_GATEWAY,
AdminEnvelope::new(
50201,
"Failed to refresh backend models",
RefreshModelsData::from(result),
request_id,
),
)),
ModelServiceError::LoadSnapshots => Err(AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to load model snapshots",
request_id,
)),
}
}
+133
View File
@@ -0,0 +1,133 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
use crate::utils::pagination::Page;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminEnvelope<T> {
pub code: u32,
pub message: String,
pub data: T,
pub request_id: String,
}
impl<T> AdminEnvelope<T> {
pub fn new(
code: u32,
message: impl Into<String>,
data: T,
request_id: impl Into<String>,
) -> Self {
// Body codes drive frontend branches; HTTP status remains the transport truth.
Self {
code,
message: message.into(),
data,
request_id: request_id.into(),
}
}
pub fn ok(data: T, request_id: impl Into<String>) -> Self {
Self::new(200, "OK", data, request_id)
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PageMeta {
pub limit: u32,
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminPageEnvelope<T> {
pub code: u32,
pub message: String,
pub data: Vec<T>,
pub page: PageMeta,
pub request_id: String,
}
impl<T> AdminPageEnvelope<T> {
pub fn new(
code: u32,
message: impl Into<String>,
page: Page<T>,
limit: u32,
request_id: impl Into<String>,
) -> Self {
let Page { items, next_cursor } = page;
Self {
code,
message: message.into(),
data: items,
page: PageMeta { limit, next_cursor },
request_id: request_id.into(),
}
}
pub fn ok(page: Page<T>, limit: u32, request_id: impl Into<String>) -> Self {
Self::new(200, "OK", page, limit, request_id)
}
}
#[derive(Debug, Clone)]
pub struct AdminResponse<T> {
pub status: StatusCode,
pub body: T,
}
impl<T> AdminResponse<T> {
pub fn new(status: StatusCode, body: T) -> Self {
Self { status, body }
}
}
#[derive(Debug, Clone)]
pub struct AdminError {
pub status: StatusCode,
pub code: u32,
pub message: String,
pub request_id: String,
}
impl AdminError {
pub fn new(
status: StatusCode,
code: u32,
message: impl Into<String>,
request_id: impl Into<String>,
) -> Self {
Self {
status,
code,
message: message.into(),
request_id: request_id.into(),
}
}
}
impl IntoResponse for AdminError {
fn into_response(self) -> Response {
AdminResponse::new(
self.status,
AdminEnvelope::new(self.code, self.message, (), self.request_id),
)
.into_response()
}
}
impl<T> IntoResponse for AdminResponse<T>
where
T: Serialize,
{
fn into_response(self) -> Response {
(self.status, Json(self.body)).into_response()
}
}
+22 -34
View File
@@ -1,43 +1,34 @@
use axum::{
middleware::from_fn,
routing::{delete, get, patch, post},
Router,
};
use tower_http::trace::TraceLayer;
use crate::{
http::{
admin::{
account_quota, accounts, api_keys, auth_callback, auth_code_relay, auth_device_login,
auth_device_poll, auth_login_start, auth_logout, auth_status, batch_delete_accounts,
batch_delete_api_keys, batch_update_account_status, create_account, create_api_key,
delete_account, delete_account_cookies, delete_api_key, export_accounts,
export_api_keys, get_account_cookies, health_check_accounts, import_accounts,
import_api_keys, import_cli_auth, login, logs, refresh_account, refresh_models,
reset_account_usage, set_account_cookies, settings, update_account_label,
update_account_status, update_api_key_label, update_api_key_status, usage_stats,
usage_stats_summary,
},
health::health,
middleware::attach_request_id,
v1::{
chat_completions, debug_models, model_catalog, model_detail, model_info, models,
responses,
},
use crate::app::state::AppState;
use super::{
accounts::{
account_quota, accounts, batch_delete_accounts, batch_update_account_status,
create_account, delete_account, delete_account_cookies, export_accounts,
get_account_cookies, health_check_accounts, import_accounts, import_cli_auth,
refresh_account, reset_account_usage, set_account_cookies, update_account_label,
update_account_status,
},
state::AppState,
api_keys::{
api_keys, batch_delete_api_keys, create_api_key, delete_api_key, export_api_keys,
import_api_keys, update_api_key_label, update_api_key_status,
},
auth::{
auth_callback, auth_code_relay, auth_device_login, auth_device_poll, auth_login_start,
auth_logout, auth_status, login,
},
logs::logs,
models::refresh_models,
settings::settings,
usage::{usage_stats, usage_stats_summary},
};
pub fn build_router(state: AppState) -> Router {
pub fn router() -> Router<AppState> {
Router::new()
.route("/health", get(health))
.route("/v1/responses", post(responses))
.route("/v1/chat/completions", post(chat_completions))
.route("/v1/models", get(models))
.route("/v1/models/catalog", get(model_catalog))
.route("/v1/models/{model_id}", get(model_detail))
.route("/v1/models/{model_id}/info", get(model_info))
.route("/debug/models", get(debug_models))
.route("/admin/login", post(login))
.route("/admin/auth/status", get(auth_status))
.route("/admin/auth/logout", post(auth_logout))
@@ -103,7 +94,4 @@ pub fn build_router(state: AppState) -> Router {
"/admin/api-keys/{key_id}/status",
patch(update_api_key_status),
)
.with_state(state)
.layer(TraceLayer::new_for_http())
.layer(from_fn(attach_request_id))
}
+77
View File
@@ -0,0 +1,77 @@
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::IntoResponse,
Extension,
};
use serde::Serialize;
use crate::{
app::state::AppState,
config::{AppConfig, QuotaWarningThresholds},
http::middleware::RequestId,
};
use super::{require_admin_session, AdminEnvelope, AdminError, AdminResponse};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminSettingsData {
pub default_model: String,
pub default_reasoning_effort: Option<String>,
pub service_tier: Option<String>,
pub model_aliases: std::collections::BTreeMap<String, String>,
pub refresh_enabled: bool,
pub refresh_margin_seconds: u64,
pub refresh_concurrency: u32,
pub max_concurrent_per_account: usize,
pub request_interval_ms: u64,
pub rotation_strategy: String,
pub tier_priority: Vec<String>,
pub quota_refresh_interval_minutes: u64,
pub quota_warning_thresholds: QuotaWarningThresholds,
pub quota_skip_exhausted: bool,
pub logs_enabled: bool,
pub logs_capacity: u32,
pub logs_capture_body: bool,
pub usage_history_retention_days: Option<u64>,
}
impl AdminSettingsData {
fn from_config(config: &AppConfig) -> Self {
Self {
default_model: config.model.default_model.clone(),
default_reasoning_effort: config.model.default_reasoning_effort.clone(),
service_tier: config.model.service_tier.clone(),
model_aliases: config.model.aliases.clone(),
refresh_enabled: config.auth.refresh_enabled,
refresh_margin_seconds: config.auth.refresh_margin_seconds,
refresh_concurrency: config.auth.refresh_concurrency,
max_concurrent_per_account: config.auth.max_concurrent_per_account,
request_interval_ms: config.auth.request_interval_ms,
rotation_strategy: config.auth.rotation_strategy.clone(),
tier_priority: config.auth.tier_priority.clone(),
quota_refresh_interval_minutes: config.quota.refresh_interval_minutes,
quota_warning_thresholds: config.quota.warning_thresholds.clone(),
quota_skip_exhausted: config.quota.skip_exhausted,
logs_enabled: config.logging.enabled,
logs_capacity: config.logging.capacity,
logs_capture_body: config.logging.capture_body,
usage_history_retention_days: config.usage_stats.history_retention_days,
}
}
}
pub async fn settings(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AdminSettingsData::from_config(state.config()), request_id),
))
}
+158
View File
@@ -0,0 +1,158 @@
use axum::{
extract::{Query, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
Extension,
};
use serde::{Deserialize, Serialize};
use crate::{
app::state::AppState,
codex::accounts::repository::{
AccountRepositoryError, AccountUsageListRecord, AccountUsageSummary,
},
http::middleware::RequestId,
service::usage::UsageServiceError,
utils::pagination::{clamp_limit, Page},
};
use super::{require_admin_session, AdminEnvelope, AdminError, AdminPageEnvelope, AdminResponse};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageStatsQuery {
pub cursor: Option<String>,
pub limit: Option<u32>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminUsageStatsData {
pub account_id: String,
pub email: Option<String>,
pub label: Option<String>,
pub plan_type: Option<String>,
pub request_count: i64,
pub input_tokens: i64,
pub output_tokens: i64,
pub cached_tokens: i64,
pub last_used_at: Option<String>,
}
impl From<AccountUsageListRecord> for AdminUsageStatsData {
fn from(usage: AccountUsageListRecord) -> Self {
Self {
account_id: usage.account_id,
email: usage.email,
label: usage.label,
plan_type: usage.plan_type,
request_count: usage.request_count,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cached_tokens: usage.cached_tokens,
last_used_at: usage.last_used_at.map(|value| value.to_rfc3339()),
}
}
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminUsageStatsSummaryData {
pub account_count: i64,
pub request_count: i64,
pub input_tokens: i64,
pub output_tokens: i64,
pub cached_tokens: i64,
}
impl From<AccountUsageSummary> for AdminUsageStatsSummaryData {
fn from(summary: AccountUsageSummary) -> Self {
Self {
account_count: summary.account_count,
request_count: summary.request_count,
input_tokens: summary.input_tokens,
output_tokens: summary.output_tokens,
cached_tokens: summary.cached_tokens,
}
}
}
pub async fn usage_stats(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Query(query): Query<UsageStatsQuery>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
let limit = clamp_limit(query.limit.unwrap_or(50));
match state.services.usage.list(query.cursor, limit).await {
Ok(page) => {
let Page { items, next_cursor } = page;
let page = Page {
items: items.into_iter().map(AdminUsageStatsData::from).collect(),
next_cursor,
};
Ok(AdminResponse::new(
StatusCode::OK,
AdminPageEnvelope::ok(page, limit, request_id),
))
}
Err(error) => Err(usage_list_error(error, request_id)),
}
}
fn usage_list_error(error: UsageServiceError, request_id: String) -> AdminError {
match error {
UsageServiceError::RepositoryUnavailable => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Account usage repository is not initialized",
request_id,
),
UsageServiceError::Repository(AccountRepositoryError::InvalidCursor) => {
AdminError::new(StatusCode::BAD_REQUEST, 40002, "Invalid cursor", request_id)
}
UsageServiceError::Repository(_) => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to list usage stats",
request_id,
),
}
}
pub async fn usage_stats_summary(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
match state.services.usage.summary().await {
Ok(summary) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AdminUsageStatsSummaryData::from(summary), request_id),
)),
Err(error) => Err(usage_summary_error(error, request_id)),
}
}
fn usage_summary_error(error: UsageServiceError, request_id: String) -> AdminError {
match error {
UsageServiceError::RepositoryUnavailable => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Account usage repository is not initialized",
request_id,
),
UsageServiceError::Repository(_) => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to summarize usage stats",
request_id,
),
}
}
-1998
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
use axum::http::HeaderMap;
use crate::{app::state::AppState, http::auth::client_api_key};
pub(super) async fn authorize_client_api_key(state: &AppState, headers: &HeaderMap) -> bool {
let Some(api_key) = client_api_key(headers) else {
return false;
};
state
.services
.api_keys
.verify(api_key.as_str())
.await
.unwrap_or(false)
}
+44
View File
@@ -0,0 +1,44 @@
use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
Extension, Json,
};
use std::time::Instant;
use crate::{
app::state::AppState,
codex::protocol::{codex_to_openai::openai_error, openai_to_codex::ChatCompletionRequest},
http::middleware::RequestId,
};
use super::{auth::authorize_client_api_key, errors::missing_client_api_key_response};
pub async fn chat_completions(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
body: Bytes,
) -> Response {
let started_at = Instant::now();
if !authorize_client_api_key(&state, &headers).await {
return missing_client_api_key_response().into_response();
}
let Ok(chat_request) = serde_json::from_slice::<ChatCompletionRequest>(&body) else {
return (
StatusCode::BAD_REQUEST,
Json(openai_error(
"Invalid chat completion request",
"invalid_request",
)),
)
.into_response();
};
state
.services
.chat
.handle(request_id.as_str(), chat_request, started_at)
.await
}
+49
View File
@@ -0,0 +1,49 @@
use axum::{http::StatusCode, Json};
use serde_json::Value;
use crate::{
codex::protocol::codex_to_openai::openai_error, codex::transport::client::CodexClientError,
};
pub(crate) fn codex_client_error_response(error: CodexClientError) -> (StatusCode, Json<Value>) {
match error {
CodexClientError::UnsupportedTransport(_) => (
StatusCode::BAD_REQUEST,
Json(openai_error(
"previous_response_id requires Codex WebSocket transport",
"websocket_required",
)),
),
CodexClientError::Upstream { status, body, .. } => (
status,
Json(openai_error(
&format!(
"Codex upstream error: {}",
body.chars().take(300).collect::<String>()
),
"upstream_error",
)),
),
_ => (
StatusCode::BAD_GATEWAY,
Json(openai_error(
"Codex upstream request failed",
"upstream_error",
)),
),
}
}
pub(super) fn missing_client_api_key_response() -> (StatusCode, Json<Value>) {
(
StatusCode::UNAUTHORIZED,
Json(openai_error("Missing client API key", "invalid_api_key")),
)
}
pub(crate) fn model_not_found_response() -> (StatusCode, Json<Value>) {
(
StatusCode::NOT_FOUND,
Json(openai_error("Model not found", "model_not_found")),
)
}
+8
View File
@@ -0,0 +1,8 @@
pub mod auth;
pub mod chat;
pub mod errors;
pub mod models;
pub mod responses;
pub mod router;
pub use self::router::router;
+99
View File
@@ -0,0 +1,99 @@
use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
Json,
};
use serde_json::{json, Value};
use crate::{app::state::AppState, codex::models::catalog::ModelCatalog};
use super::{
auth::authorize_client_api_key,
errors::{missing_client_api_key_response, model_not_found_response},
};
const MODEL_CREATED_TIMESTAMP: i64 = 1_700_000_000;
pub async fn models(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
if !authorize_client_api_key(&state, &headers).await {
return missing_client_api_key_response();
}
let catalog = model_catalog_for_state(&state).await;
let data = catalog
.models()
.iter()
.map(|model| openai_model_json(&model.id))
.collect::<Vec<_>>();
(
StatusCode::OK,
Json(json!({
"object": "list",
"data": data
})),
)
}
pub async fn model_catalog(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
if !authorize_client_api_key(&state, &headers).await {
return missing_client_api_key_response();
}
let catalog = model_catalog_for_state(&state).await;
(StatusCode::OK, Json(json!(catalog.models())))
}
pub async fn model_detail(
State(state): State<AppState>,
headers: HeaderMap,
Path(model_id): Path<String>,
) -> impl IntoResponse {
if !authorize_client_api_key(&state, &headers).await {
return missing_client_api_key_response();
}
let catalog = model_catalog_for_state(&state).await;
if catalog.model_info(&model_id).is_none() {
return model_not_found_response();
}
(StatusCode::OK, Json(openai_model_json(&model_id)))
}
pub async fn model_info(
State(state): State<AppState>,
headers: HeaderMap,
Path(model_id): Path<String>,
) -> impl IntoResponse {
if !authorize_client_api_key(&state, &headers).await {
return missing_client_api_key_response();
}
let catalog = model_catalog_for_state(&state).await;
let Some(info) = catalog.model_info(&model_id) else {
return model_not_found_response();
};
(StatusCode::OK, Json(json!(info)))
}
pub async fn debug_models(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
if !authorize_client_api_key(&state, &headers).await {
return missing_client_api_key_response();
}
let catalog = model_catalog_for_state(&state).await;
(StatusCode::OK, Json(json!(catalog.debug())))
}
pub(super) async fn model_catalog_for_state(state: &AppState) -> ModelCatalog {
state.services.models.catalog().await
}
fn openai_model_json(id: &str) -> Value {
json!({
"id": id,
"object": "model",
"created": MODEL_CREATED_TIMESTAMP,
"owned_by": "openai"
})
}
+31
View File
@@ -0,0 +1,31 @@
use std::time::Instant;
use axum::{
body::Bytes,
extract::State,
http::HeaderMap,
response::{IntoResponse, Response},
Extension,
};
use crate::{app::state::AppState, http::middleware::RequestId};
use super::{auth::authorize_client_api_key, errors::missing_client_api_key_response};
pub async fn responses(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
body: Bytes,
) -> Response {
let started_at = Instant::now();
if !authorize_client_api_key(&state, &headers).await {
return missing_client_api_key_response().into_response();
}
state
.services
.responses
.handle(request_id.as_str(), headers, body, started_at)
.await
}
+23
View File
@@ -0,0 +1,23 @@
use axum::{
routing::{get, post},
Router,
};
use crate::app::state::AppState;
use super::{
chat::chat_completions,
models::{debug_models, model_catalog, model_detail, model_info, models},
responses::responses,
};
pub fn router() -> Router<AppState> {
Router::new()
.route("/v1/responses", post(responses))
.route("/v1/chat/completions", post(chat_completions))
.route("/v1/models", get(models))
.route("/v1/models/catalog", get(model_catalog))
.route("/v1/models/{model_id}", get(model_detail))
.route("/v1/models/{model_id}/info", get(model_info))
.route("/debug/models", get(debug_models))
}
+4 -9
View File
@@ -1,16 +1,11 @@
pub mod accounts;
pub mod app;
pub mod auth;
pub mod codex;
pub mod config;
pub mod cookies;
pub mod crypto;
pub mod error;
pub mod fingerprint;
pub mod utils;
pub mod http;
pub mod logs;
pub mod models;
pub mod pagination;
pub mod state;
pub mod service;
pub mod storage;
pub mod translation;
+1 -1
View File
@@ -2,7 +2,7 @@ use sqlx::{Row, SqlitePool};
use crate::{
logs::event::{EventLevel, EventLog},
pagination::{clamp_limit, decode_cursor, encode_cursor, Page},
utils::pagination::{clamp_limit, decode_cursor, encode_cursor, Page},
};
#[derive(Clone)]
+2 -22
View File
@@ -1,11 +1,7 @@
use codex_proxy_rs::{
app::build_router,
auth::{api_key::ApiKeyHasher, oauth::OpenAiOAuthRefresher},
app::{bootstrap::build_state, build_router},
config::AppConfig,
crypto::SecretBox,
logs::rotation::{init_tracing, RotationConfig},
state::AppState,
storage::db::connect_sqlite,
};
#[tokio::main]
@@ -19,23 +15,7 @@ async fn main() -> anyhow::Result<()> {
))?;
let host = config.server.host.clone();
let port = config.server.port;
let secret_box = SecretBox::load_or_create(&config.security.master_key_file)?;
let api_key_hasher = ApiKeyHasher::load_or_create(&config.security.api_key_pepper_file)?;
let oauth_client = OpenAiOAuthRefresher::codex_default(
reqwest::Client::builder()
.use_rustls_tls()
.no_proxy()
.build()?,
);
let pool = connect_sqlite(&config.database.url).await?;
let state = AppState::with_pool_secret_api_key_hasher_and_oauth_client(
config,
pool,
secret_box,
api_key_hasher,
oauth_client,
);
let restored_accounts = state.reload_account_pool_from_repository().await?;
let (state, restored_accounts) = build_state(config).await?;
tracing::info!(restored_accounts, "account pool restored from sqlite");
let app = build_router(state);
let listener = tokio::net::TcpListener::bind((host.as_str(), port)).await?;
+432
View File
@@ -0,0 +1,432 @@
use std::sync::Arc;
use chrono::{DateTime, Duration, Utc};
use sqlx::{Row, SqlitePool};
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::{
auth::admin_session::verify_admin_password,
codex::accounts::service::{AccountService, ValidatedAccountImportError},
codex::accounts::{
model::AccountStatus,
pool::AccountPool,
repository::{AccountRepository, StoredAccountMetadata},
},
codex::oauth::{
DeviceCode, OAuthClient, OAuthConfig, OAuthError, PkceSession, PkceSessionStore,
},
config::AppConfig,
};
#[derive(Clone)]
pub struct AdminAuthService {
config: AppConfig,
db: Option<SqlitePool>,
account_repository: Option<AccountRepository>,
account_pool: Arc<Mutex<AccountPool>>,
oauth_client: Option<Arc<dyn OAuthClient>>,
oauth_sessions: Arc<Mutex<PkceSessionStore>>,
accounts: AccountService,
}
#[derive(Debug)]
pub enum AdminAuthServiceError {
DatabaseUnavailable,
LoadAdminUser,
AdminPasswordInvalid,
VerifyAdminPassword,
InvalidSessionTtl,
CreateSession,
AccountRepositoryUnavailable,
InspectAccountAuthStatus,
ClearAccounts,
}
#[derive(Debug)]
pub enum AdminSessionValidationError {
DatabaseUnavailable,
ValidateSession,
}
#[derive(Debug)]
pub enum AdminAuthOAuthError {
OAuthClientUnavailable,
AccountRepositoryUnavailable,
DeviceCodeRequired,
InvalidOrExpiredSession,
DeviceCodeRequest(OAuthError),
DeviceAuthorization(OAuthError),
TokenExchange(OAuthError),
Import(ValidatedAccountImportError),
}
#[derive(Debug)]
pub struct AdminLogin {
pub session_id: String,
pub expires_at: DateTime<Utc>,
pub ttl_minutes: u64,
}
#[derive(Debug)]
pub struct AdminAuthStatus {
pub authenticated: bool,
pub user: Option<AdminAuthUser>,
pub pool: AdminAuthPoolSummary,
}
#[derive(Debug)]
pub struct AdminAuthUser {
pub id: String,
pub email: Option<String>,
pub account_id: Option<String>,
pub user_id: Option<String>,
pub label: Option<String>,
pub plan_type: Option<String>,
pub status: AccountStatus,
pub access_token_expires_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Default)]
pub struct AdminAuthPoolSummary {
pub total: usize,
pub active: usize,
pub expired: usize,
pub quota_exhausted: usize,
pub refreshing: usize,
pub disabled: usize,
pub banned: usize,
}
#[derive(Debug)]
pub struct AdminLogout {
pub deleted: u64,
}
#[derive(Debug)]
pub struct AdminAuthLoginStart {
pub auth_url: String,
pub state: String,
}
#[derive(Debug)]
pub struct AdminAuthDevicePoll {
pub success: bool,
pub pending: bool,
pub code: Option<String>,
}
#[derive(Debug)]
pub enum AdminAuthPkceExchange {
Imported { return_host: String },
AlreadyCompleted,
}
#[derive(Debug)]
struct AdminUserRow {
id: String,
password_hash: String,
}
impl AdminAuthService {
pub fn new(
config: AppConfig,
db: Option<SqlitePool>,
account_repository: Option<AccountRepository>,
account_pool: Arc<Mutex<AccountPool>>,
oauth_client: Option<Arc<dyn OAuthClient>>,
oauth_sessions: Arc<Mutex<PkceSessionStore>>,
accounts: AccountService,
) -> Self {
Self {
config,
db,
account_repository,
account_pool,
oauth_client,
oauth_sessions,
accounts,
}
}
pub async fn login(&self, password: &str) -> Result<AdminLogin, AdminAuthServiceError> {
let pool = self.pool()?;
let admin = load_first_admin(pool)
.await
.map_err(|_| AdminAuthServiceError::LoadAdminUser)?
.ok_or(AdminAuthServiceError::AdminPasswordInvalid)?;
match verify_admin_password(password, &admin.password_hash) {
Ok(true) => {}
Ok(false) => return Err(AdminAuthServiceError::AdminPasswordInvalid),
Err(_) => return Err(AdminAuthServiceError::VerifyAdminPassword),
}
let ttl_minutes = self.config.admin.session_ttl_minutes;
let ttl_minutes_i64 =
i64::try_from(ttl_minutes).map_err(|_| AdminAuthServiceError::InvalidSessionTtl)?;
let expires_at = Utc::now() + Duration::minutes(ttl_minutes_i64);
let session_id = format!("sess_{}", Uuid::new_v4().simple());
create_admin_session(pool, &session_id, &admin.id, expires_at)
.await
.map_err(|_| AdminAuthServiceError::CreateSession)?;
Ok(AdminLogin {
session_id,
expires_at,
ttl_minutes,
})
}
pub async fn validate_session(
&self,
session_id: Option<&str>,
) -> Result<bool, AdminSessionValidationError> {
let Some(session_id) = session_id else {
return Ok(false);
};
let pool = self
.pool()
.map_err(|_| AdminSessionValidationError::DatabaseUnavailable)?;
validate_admin_session(pool, session_id)
.await
.map_err(|_| AdminSessionValidationError::ValidateSession)
}
pub async fn status(&self) -> Result<AdminAuthStatus, AdminAuthServiceError> {
let repo = self.account_repository()?;
let accounts = repo
.list_all_metadata()
.await
.map_err(|_| AdminAuthServiceError::InspectAccountAuthStatus)?;
let pool = account_auth_pool_summary(&accounts);
let user = accounts.first().map(account_auth_user);
Ok(AdminAuthStatus {
authenticated: pool.total > 0,
user,
pool,
})
}
pub async fn logout(&self) -> Result<AdminLogout, AdminAuthServiceError> {
let repo = self.account_repository()?;
let deleted = repo
.delete_all()
.await
.map_err(|_| AdminAuthServiceError::ClearAccounts)?;
self.account_pool.lock().await.clear();
Ok(AdminLogout { deleted })
}
pub async fn request_device_code(&self) -> Result<DeviceCode, AdminAuthOAuthError> {
self.oauth_client()?
.request_device_code()
.await
.map_err(AdminAuthOAuthError::DeviceCodeRequest)
}
pub async fn poll_device_token(
&self,
device_code: &str,
) -> Result<AdminAuthDevicePoll, AdminAuthOAuthError> {
let oauth_client = self.oauth_client()?;
if !self.accounts.has_repository() {
return Err(AdminAuthOAuthError::AccountRepositoryUnavailable);
}
let device_code = device_code.trim();
if device_code.is_empty() {
return Err(AdminAuthOAuthError::DeviceCodeRequired);
}
match oauth_client.poll_device_token(device_code).await {
Ok(tokens) => {
self.accounts
.import_validated(Some(tokens.access_token), tokens.refresh_token)
.await
.map_err(AdminAuthOAuthError::Import)?;
Ok(AdminAuthDevicePoll {
success: true,
pending: false,
code: None,
})
}
Err(error) => {
if let Some(code) = error.pending_code() {
return Ok(AdminAuthDevicePoll {
success: false,
pending: true,
code: Some(code.to_string()),
});
}
Err(AdminAuthOAuthError::DeviceAuthorization(error))
}
}
}
pub async fn start_pkce_login(&self, return_host: &str) -> AdminAuthLoginStart {
let login = self
.oauth_sessions
.lock()
.await
.start_login(return_host, &OAuthConfig::codex_default());
AdminAuthLoginStart {
auth_url: login.auth_url,
state: login.state,
}
}
pub async fn exchange_pkce_code(
&self,
oauth_state: &str,
code: &str,
) -> Result<AdminAuthPkceExchange, AdminAuthOAuthError> {
if !self.accounts.has_repository() {
return Err(AdminAuthOAuthError::AccountRepositoryUnavailable);
}
let oauth_client = self.oauth_client()?;
let Some(session) = self.acquire_pkce_session(oauth_state).await? else {
return Ok(AdminAuthPkceExchange::AlreadyCompleted);
};
self.exchange_pkce_session(oauth_client.as_ref(), oauth_state, code, session)
.await
}
fn pool(&self) -> Result<&SqlitePool, AdminAuthServiceError> {
self.db
.as_ref()
.ok_or(AdminAuthServiceError::DatabaseUnavailable)
}
fn account_repository(&self) -> Result<&AccountRepository, AdminAuthServiceError> {
self.account_repository
.as_ref()
.ok_or(AdminAuthServiceError::AccountRepositoryUnavailable)
}
fn oauth_client(&self) -> Result<Arc<dyn OAuthClient>, AdminAuthOAuthError> {
self.oauth_client
.clone()
.ok_or(AdminAuthOAuthError::OAuthClientUnavailable)
}
async fn acquire_pkce_session(
&self,
oauth_state: &str,
) -> Result<Option<PkceSession>, AdminAuthOAuthError> {
let mut sessions = self.oauth_sessions.lock().await;
if let Some(session) = sessions.try_acquire(oauth_state) {
return Ok(Some(session));
}
if sessions.is_completed_or_exchanging(oauth_state) {
return Ok(None);
}
Err(AdminAuthOAuthError::InvalidOrExpiredSession)
}
async fn exchange_pkce_session(
&self,
oauth_client: &dyn OAuthClient,
oauth_state: &str,
code: &str,
session: PkceSession,
) -> Result<AdminAuthPkceExchange, AdminAuthOAuthError> {
let tokens = match oauth_client
.exchange_code(code, &session.code_verifier, &session.redirect_uri)
.await
{
Ok(tokens) => tokens,
Err(error) => {
self.oauth_sessions.lock().await.release(oauth_state);
return Err(AdminAuthOAuthError::TokenExchange(error));
}
};
match self
.accounts
.import_validated(Some(tokens.access_token), tokens.refresh_token)
.await
{
Ok(_) => {
self.oauth_sessions.lock().await.complete(oauth_state);
Ok(AdminAuthPkceExchange::Imported {
return_host: session.return_host,
})
}
Err(error) => {
self.oauth_sessions.lock().await.complete(oauth_state);
Err(AdminAuthOAuthError::Import(error))
}
}
}
}
async fn load_first_admin(pool: &SqlitePool) -> Result<Option<AdminUserRow>, sqlx::Error> {
let row =
sqlx::query("select id, password_hash from admin_users order by created_at asc limit 1")
.fetch_optional(pool)
.await?;
Ok(row.map(|row| AdminUserRow {
id: row.get("id"),
password_hash: row.get("password_hash"),
}))
}
async fn validate_admin_session(pool: &SqlitePool, session_id: &str) -> Result<bool, sqlx::Error> {
let now = Utc::now().to_rfc3339();
let count: (i64,) =
sqlx::query_as("select count(*) from admin_sessions where id = ? and expires_at > ?")
.bind(session_id)
.bind(now)
.fetch_one(pool)
.await?;
Ok(count.0 > 0)
}
async fn create_admin_session(
pool: &SqlitePool,
session_id: &str,
user_id: &str,
expires_at: DateTime<Utc>,
) -> Result<(), sqlx::Error> {
let now = Utc::now().to_rfc3339();
sqlx::query(
"insert into admin_sessions (id, user_id, expires_at, created_at) values (?, ?, ?, ?)",
)
.bind(session_id)
.bind(user_id)
.bind(expires_at.to_rfc3339())
.bind(now)
.execute(pool)
.await?;
Ok(())
}
fn account_auth_user(account: &StoredAccountMetadata) -> AdminAuthUser {
AdminAuthUser {
id: account.id.clone(),
email: account.email.clone(),
account_id: account.account_id.clone(),
user_id: account.user_id.clone(),
label: account.label.clone(),
plan_type: account.plan_type.clone(),
status: account.status,
access_token_expires_at: account.access_token_expires_at,
}
}
fn account_auth_pool_summary(accounts: &[StoredAccountMetadata]) -> AdminAuthPoolSummary {
let mut summary = AdminAuthPoolSummary {
total: accounts.len(),
..AdminAuthPoolSummary::default()
};
for account in accounts {
match account.status {
AccountStatus::Active => summary.active += 1,
AccountStatus::Expired => summary.expired += 1,
AccountStatus::QuotaExhausted => summary.quota_exhausted += 1,
AccountStatus::Refreshing => summary.refreshing += 1,
AccountStatus::Disabled => summary.disabled += 1,
AccountStatus::Banned => summary.banned += 1,
}
}
summary
}
+334
View File
@@ -0,0 +1,334 @@
use serde_json::Value;
use crate::{
auth::{
api_key::ApiKeyHasher,
api_key_repository::{ClientApiKeyRepository, StoredClientApiKey},
},
utils::{json::first_string, pagination::Page},
};
#[derive(Clone)]
pub struct ApiKeyService {
repository: Option<ClientApiKeyRepository>,
hasher: Option<ApiKeyHasher>,
}
#[derive(Debug)]
pub enum ApiKeyServiceError {
RepositoryUnavailable,
HasherUnavailable,
List,
Export,
Import,
Create,
Delete,
UpdateLabel,
UpdateStatus,
Verify,
InvalidStatus(String),
EmptyName,
EmptyIds,
LabelTooLong,
NoImportableKeys,
}
#[derive(Debug)]
pub struct CreatedClientApiKey {
pub key: StoredClientApiKey,
pub plaintext: String,
}
#[derive(Debug)]
pub struct ImportedClientApiKey {
pub source_id: Option<String>,
pub source_prefix: Option<String>,
pub key: StoredClientApiKey,
pub plaintext: String,
}
#[derive(Debug)]
pub struct ImportedClientApiKeys {
pub imported: u32,
pub skipped: u32,
pub keys: Vec<ImportedClientApiKey>,
}
#[derive(Debug)]
pub struct BatchDeleteClientApiKeys {
pub deleted: u32,
pub not_found: Vec<String>,
}
#[derive(Debug)]
pub struct UpdateClientApiKeyStatus {
pub id: String,
pub enabled: bool,
}
#[derive(Debug, Clone)]
struct ClientApiKeyImportEntry {
source_id: Option<String>,
source_prefix: Option<String>,
name: String,
label: Option<String>,
enabled: bool,
}
impl ApiKeyService {
pub fn new(repository: Option<ClientApiKeyRepository>, hasher: Option<ApiKeyHasher>) -> Self {
Self { repository, hasher }
}
pub async fn list(
&self,
cursor: Option<String>,
limit: u32,
) -> Result<Page<StoredClientApiKey>, ApiKeyServiceError> {
self.repository()?
.list(cursor, limit)
.await
.map_err(|_| ApiKeyServiceError::List)
}
pub async fn export(
&self,
ids: Vec<String>,
) -> Result<Vec<StoredClientApiKey>, ApiKeyServiceError> {
let repo = self.repository()?;
if ids.is_empty() {
return repo
.list_all()
.await
.map_err(|_| ApiKeyServiceError::Export);
}
let mut keys = Vec::with_capacity(ids.len());
for id in ids {
match repo.get(&id).await {
Ok(Some(key)) => keys.push(key),
Ok(None) => {}
Err(_) => return Err(ApiKeyServiceError::Export),
}
}
Ok(keys)
}
pub async fn import(
&self,
payload: &Value,
) -> Result<ImportedClientApiKeys, ApiKeyServiceError> {
let repo = self.repository()?;
let hasher = self.hasher()?;
let entries = parse_client_api_key_import_payload(payload);
if entries.is_empty() {
return Err(ApiKeyServiceError::NoImportableKeys);
}
let mut imported = 0u32;
let mut skipped = 0u32;
let mut keys = Vec::with_capacity(entries.len());
for entry in entries {
let name = entry.name.trim();
if name.is_empty() {
skipped += 1;
continue;
}
if entry
.label
.as_ref()
.is_some_and(|label| label.chars().count() > 64)
{
return Err(ApiKeyServiceError::LabelTooLong);
}
let generated = hasher.generate_client_api_key(name);
let plaintext = generated.plaintext.clone();
let source_id = entry.source_id;
let source_prefix = entry.source_prefix;
match repo
.insert_generated_with_metadata(
name,
entry.label.as_deref(),
entry.enabled,
&generated,
)
.await
{
Ok(key) => {
imported += 1;
keys.push(ImportedClientApiKey {
source_id,
source_prefix,
key,
plaintext,
});
}
Err(_) => return Err(ApiKeyServiceError::Import),
}
}
Ok(ImportedClientApiKeys {
imported,
skipped,
keys,
})
}
pub async fn create(&self, name: &str) -> Result<CreatedClientApiKey, ApiKeyServiceError> {
let name = name.trim();
if name.is_empty() {
return Err(ApiKeyServiceError::EmptyName);
}
let repo = self.repository()?;
let hasher = self.hasher()?;
let generated = hasher.generate_client_api_key(name);
let plaintext = generated.plaintext.clone();
repo.insert_generated(name, &generated)
.await
.map(|key| CreatedClientApiKey { key, plaintext })
.map_err(|_| ApiKeyServiceError::Create)
}
pub async fn batch_delete(
&self,
ids: Vec<String>,
) -> Result<BatchDeleteClientApiKeys, ApiKeyServiceError> {
if ids.is_empty() {
return Err(ApiKeyServiceError::EmptyIds);
}
let repo = self.repository()?;
let mut deleted = 0u32;
let mut not_found = Vec::new();
for key_id in ids {
match repo.delete(&key_id).await {
Ok(true) => deleted += 1,
Ok(false) => not_found.push(key_id),
Err(_) => return Err(ApiKeyServiceError::Delete),
}
}
Ok(BatchDeleteClientApiKeys { deleted, not_found })
}
pub async fn update_label(
&self,
key_id: &str,
label: Option<String>,
) -> Result<Option<StoredClientApiKey>, ApiKeyServiceError> {
if label
.as_ref()
.is_some_and(|label| label.chars().count() > 64)
{
return Err(ApiKeyServiceError::LabelTooLong);
}
self.repository()?
.set_label(key_id, label)
.await
.map_err(|_| ApiKeyServiceError::UpdateLabel)
}
pub async fn update_status(
&self,
key_id: String,
status: &str,
) -> Result<Option<UpdateClientApiKeyStatus>, ApiKeyServiceError> {
let enabled = parse_client_api_key_enabled_status(status)?;
match self.repository()?.set_enabled(&key_id, enabled).await {
Ok(true) => Ok(Some(UpdateClientApiKeyStatus {
id: key_id,
enabled,
})),
Ok(false) => Ok(None),
Err(_) => Err(ApiKeyServiceError::UpdateStatus),
}
}
pub async fn delete(&self, key_id: &str) -> Result<bool, ApiKeyServiceError> {
self.repository()?
.delete(key_id)
.await
.map_err(|_| ApiKeyServiceError::Delete)
}
pub async fn verify(&self, plaintext: &str) -> Result<bool, ApiKeyServiceError> {
let repo = self.repository()?;
let hasher = self.hasher()?;
repo.verify_and_touch(plaintext, hasher)
.await
.map_err(|_| ApiKeyServiceError::Verify)
}
fn repository(&self) -> Result<&ClientApiKeyRepository, ApiKeyServiceError> {
self.repository
.as_ref()
.ok_or(ApiKeyServiceError::RepositoryUnavailable)
}
fn hasher(&self) -> Result<&ApiKeyHasher, ApiKeyServiceError> {
self.hasher
.as_ref()
.ok_or(ApiKeyServiceError::HasherUnavailable)
}
}
fn parse_client_api_key_import_payload(payload: &Value) -> Vec<ClientApiKeyImportEntry> {
let payload = payload
.get("data")
.filter(|data| data.get("apiKeys").is_some() || data.get("keys").is_some())
.unwrap_or(payload);
if let Some(keys) = payload.get("apiKeys").and_then(Value::as_array) {
return keys
.iter()
.filter_map(client_api_key_import_entry_from_value)
.collect();
}
if let Some(keys) = payload.get("keys").and_then(Value::as_array) {
return keys
.iter()
.filter_map(client_api_key_import_entry_from_value)
.collect();
}
if let Some(keys) = payload.as_array() {
return keys
.iter()
.filter_map(client_api_key_import_entry_from_value)
.collect();
}
client_api_key_import_entry_from_value(payload)
.into_iter()
.collect()
}
fn client_api_key_import_entry_from_value(value: &Value) -> Option<ClientApiKeyImportEntry> {
value.as_object()?;
let name = first_string(value, &[&["name"]])?;
Some(ClientApiKeyImportEntry {
source_id: first_string(value, &[&["id"], &["sourceId"]]),
source_prefix: first_string(value, &[&["prefix"], &["sourcePrefix"]]),
name,
label: first_string(value, &[&["label"]]),
enabled: client_api_key_import_enabled(value),
})
}
fn client_api_key_import_enabled(value: &Value) -> bool {
if let Some(enabled) = value.get("enabled").and_then(Value::as_bool) {
return enabled;
}
!first_string(value, &[&["status"]])
.unwrap_or_else(|| "active".to_string())
.trim()
.eq_ignore_ascii_case("disabled")
}
fn parse_client_api_key_enabled_status(status: &str) -> Result<bool, ApiKeyServiceError> {
match status.trim().to_ascii_lowercase().as_str() {
"active" => Ok(true),
"disabled" => Ok(false),
other => Err(ApiKeyServiceError::InvalidStatus(format!(
"Unsupported API key status: {other}"
))),
}
}
+255
View File
@@ -0,0 +1,255 @@
use std::time::Instant;
use axum::{
body::Body,
http::{
header::{CACHE_CONTROL, CONTENT_TYPE},
StatusCode,
},
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use crate::{
codex::models::catalog::ModelCatalog,
codex::models::service::ModelService,
codex::protocol::{
codex_to_openai::{
chat_completion_from_codex_sse, chat_completion_stream_from_codex_sse, openai_error,
},
openai_to_codex::{translate_chat_to_codex, ChatCompletionRequest},
},
codex::upstream::{
classify_upstream_account_retry, no_available_accounts_response,
normalize_service_tier_for_upstream, CodexRequestLogContext, CodexUpstreamService,
},
config::ModelConfig,
http::v1::errors::{codex_client_error_response, model_not_found_response},
logs::event::EventLevel,
};
#[derive(Clone)]
pub struct ChatService {
model_config: ModelConfig,
models: ModelService,
upstream: CodexUpstreamService,
}
impl ChatService {
pub(crate) fn new(
model_config: ModelConfig,
models: ModelService,
upstream: CodexUpstreamService,
) -> Self {
Self {
model_config,
models,
upstream,
}
}
pub async fn handle(
&self,
request_id: &str,
chat_request: ChatCompletionRequest,
started_at: Instant,
) -> Response {
let client_stream = chat_request.stream;
let requested_model = chat_request.model.clone();
let catalog = self.models.catalog().await;
if !catalog.is_recognized_model_name(&requested_model) {
return model_not_found_response().into_response();
}
let parsed_model = catalog.parse_model_name(&requested_model);
let display_model = ModelCatalog::build_display_model_name(&parsed_model);
let mut codex_request = match translate_chat_to_codex(chat_request) {
Ok(request) => request,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(openai_error(
"Invalid chat completion request",
"invalid_request",
)),
)
.into_response();
}
};
codex_request.model = parsed_model.model_id.clone();
if codex_request.reasoning.is_none() {
let effort = parsed_model
.reasoning_effort
.clone()
.or_else(|| self.model_config.default_reasoning_effort.clone());
if let Some(effort) = effort {
codex_request.reasoning = Some(json!({"effort": effort, "summary": "auto"}));
}
}
if codex_request.service_tier.is_none() {
codex_request.service_tier = parsed_model
.service_tier
.clone()
.or_else(|| self.model_config.service_tier.clone());
}
codex_request.service_tier = codex_request
.service_tier
.map(normalize_service_tier_for_upstream);
let include_reasoning = codex_request.reasoning.is_some();
let Some(mut account) = self.upstream.acquire_account(&codex_request.model).await else {
return no_available_accounts_response().into_response();
};
let mut log_context = CodexRequestLogContext::new(
request_id,
&account.id,
&codex_request.model,
client_stream,
started_at,
);
let mut excluded_account_ids = Vec::new();
let response = loop {
let response = self
.upstream
.send_codex_request_with_refresh_retry(&codex_request, &account, request_id)
.await;
self.upstream.release_account(&account.id).await;
match response {
Ok(response) => break response,
Err(error) => {
if let Some(retry) = classify_upstream_account_retry(&error) {
let fallback = self
.upstream
.apply_retry_and_acquire_fallback(
&account,
retry,
&codex_request.model,
&mut excluded_account_ids,
)
.await;
self.upstream
.log_response(
&log_context,
retry.status(),
EventLevel::Warn,
"v1 chat completions upstream retrying with fallback account",
retry.metadata(client_stream),
)
.await;
if let Some(fallback) = fallback {
account = fallback;
log_context = log_context.with_account(&account.id);
continue;
}
}
let error_response = codex_client_error_response(error);
self.upstream
.log_response(
&log_context,
error_response.0,
EventLevel::Error,
"v1 chat completions upstream request failed",
json!({"stream": client_stream}),
)
.await;
return error_response.into_response();
}
}
};
if self
.upstream
.persist_cookies(&account.id, &response.set_cookie_headers)
.await
.is_err()
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(openai_error(
"Failed to persist upstream cookies",
"cookie_store_error",
)),
)
.into_response();
}
if let Some(usage) = response.usage {
if self
.upstream
.record_usage(&account.id, usage)
.await
.is_err()
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(openai_error(
"Failed to record account usage",
"usage_store_error",
)),
)
.into_response();
}
}
if client_stream {
match chat_completion_stream_from_codex_sse(
&response.body,
&display_model,
include_reasoning,
) {
Ok(Some(body)) => Response::builder()
.status(StatusCode::OK)
.header(CONTENT_TYPE, "text/event-stream")
.header(CACHE_CONTROL, "no-cache")
.body(Body::from(body))
.unwrap_or_else(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(openai_error(
"Failed to build stream response",
"stream_response_error",
)),
)
.into_response()
}),
Ok(None) => (
StatusCode::BAD_GATEWAY,
Json(openai_error(
"Codex response did not include response.completed",
"empty_upstream_response",
)),
)
.into_response(),
Err(_) => (
StatusCode::BAD_GATEWAY,
Json(openai_error(
"Invalid Codex SSE response",
"invalid_upstream_sse",
)),
)
.into_response(),
}
} else {
match chat_completion_from_codex_sse(&response.body, &display_model, include_reasoning)
{
Ok(Some(body)) => (StatusCode::OK, Json(body)).into_response(),
Ok(None) => (
StatusCode::BAD_GATEWAY,
Json(openai_error(
"Codex response did not include response.completed",
"empty_upstream_response",
)),
)
.into_response(),
Err(_) => (
StatusCode::BAD_GATEWAY,
Json(openai_error(
"Invalid Codex SSE response",
"invalid_upstream_sse",
)),
)
.into_response(),
}
}
}
}
+38
View File
@@ -0,0 +1,38 @@
use crate::{
logs::{event::EventLog, repository::EventLogRepository},
utils::pagination::Page,
};
#[derive(Clone)]
pub struct LogService {
repository: Option<EventLogRepository>,
}
#[derive(Debug)]
pub enum LogServiceError {
RepositoryUnavailable,
List,
}
impl LogService {
pub fn new(repository: Option<EventLogRepository>) -> Self {
Self { repository }
}
pub async fn list(
&self,
cursor: Option<String>,
limit: u32,
) -> Result<Page<EventLog>, LogServiceError> {
self.repository()?
.list(cursor, limit)
.await
.map_err(|_| LogServiceError::List)
}
fn repository(&self) -> Result<&EventLogRepository, LogServiceError> {
self.repository
.as_ref()
.ok_or(LogServiceError::RepositoryUnavailable)
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod admin_auth;
pub mod api_key;
pub mod chat;
pub mod log;
pub mod responses;
pub mod usage;
+423
View File
@@ -0,0 +1,423 @@
use std::time::Instant;
use axum::{
body::Bytes,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
Json,
};
use serde::Deserialize;
use serde_json::{json, Value};
use crate::{
codex::models::service::ModelService,
codex::protocol::codex_to_openai::openai_error,
codex::transport::types::CodexResponsesRequest,
codex::upstream::{
classify_upstream_account_retry, completed_response_json, no_available_accounts_response,
normalize_service_tier_for_upstream, websocket_history_retry_metadata,
CodexRequestLogContext, CodexUpstreamService, CollectedResponse,
},
config::ModelConfig,
http::v1::errors::codex_client_error_response,
logs::event::EventLevel,
};
#[derive(Clone)]
pub struct ResponsesService {
model_config: ModelConfig,
models: ModelService,
upstream: CodexUpstreamService,
}
impl ResponsesService {
pub(crate) fn new(
model_config: ModelConfig,
models: ModelService,
upstream: CodexUpstreamService,
) -> Self {
Self {
model_config,
models,
upstream,
}
}
pub async fn handle(
&self,
request_id: &str,
headers: HeaderMap,
body: Bytes,
started_at: Instant,
) -> Response {
let default_model = self.model_config.default_model.clone();
let body = serde_json::from_slice::<ResponsesBody>(&body)
.unwrap_or_else(|_| default_body(default_model.clone()));
let client_stream = body.stream.unwrap_or(true);
let requested_model = body.model.clone().unwrap_or(default_model);
let catalog = self.models.catalog().await;
if !catalog.is_recognized_model_name(&requested_model) {
return (
StatusCode::NOT_FOUND,
Json(openai_error("Model not found", "model_not_found")),
)
.into_response();
}
let parsed_model = catalog.parse_model_name(&requested_model);
let mut codex_request = CodexResponsesRequest::new_http_sse(
parsed_model.model_id.clone(),
body.instructions.unwrap_or_default(),
body.input.unwrap_or_default(),
);
codex_request.reasoning = body.reasoning;
codex_request.tools = body.tools;
codex_request.tool_choice = body.tool_choice;
codex_request.parallel_tool_calls = body.parallel_tool_calls;
codex_request.text = body.text;
codex_request.prompt_cache_key = body.prompt_cache_key;
codex_request.include = body.include;
codex_request.client_metadata = body.client_metadata;
codex_request.previous_response_id = body.previous_response_id;
codex_request.reasoning = responses_reasoning(
codex_request.reasoning.take(),
parsed_model.reasoning_effort.as_deref(),
self.model_config.default_reasoning_effort.as_deref(),
);
codex_request.service_tier = body
.service_tier
.or(parsed_model.service_tier)
.or_else(|| self.model_config.service_tier.clone())
.map(normalize_service_tier_for_upstream);
ensure_reasoning_include(&mut codex_request);
codex_request.turn_state = body
.turn_state
.or_else(|| header_string(&headers, "x-codex-turn-state"));
codex_request.turn_metadata = body
.turn_metadata
.or_else(|| header_string(&headers, "x-codex-turn-metadata"));
codex_request.beta_features = body
.beta_features
.or_else(|| header_string(&headers, "x-codex-beta-features"));
codex_request.include_timing_metrics = body
.include_timing_metrics
.or_else(|| header_string(&headers, "x-responsesapi-include-timing-metrics"));
codex_request.version = header_string(&headers, "version");
codex_request.codex_window_id = body
.codex_window_id
.or_else(|| header_string(&headers, "x-codex-window-id"));
codex_request.parent_thread_id = body
.parent_thread_id
.or_else(|| header_string(&headers, "x-codex-parent-thread-id"));
codex_request.use_websocket = body.use_websocket.unwrap_or(false);
let Some(mut account) = self.upstream.acquire_account(&codex_request.model).await else {
return no_available_accounts_response().into_response();
};
let mut log_context = CodexRequestLogContext::new(
request_id,
&account.id,
&codex_request.model,
client_stream,
started_at,
);
if client_stream {
return self
.upstream
.responses_stream(codex_request, account, log_context)
.await;
}
let mut excluded_account_ids = Vec::new();
let response = loop {
let response = self
.upstream
.send_codex_request_with_refresh_retry(&codex_request, &account, request_id)
.await;
self.upstream.release_account(&account.id).await;
match response {
Ok(response) => break response,
Err(error) => {
if let Some(retry) = classify_upstream_account_retry(&error) {
if codex_request.previous_response_id.is_some() {
// previous_response_id history is account-affine upstream.
self.upstream.apply_account_retry(&account, retry).await;
self.upstream
.log_response(
&log_context,
retry.status(),
EventLevel::Warn,
"v1 responses websocket history request kept on original account",
websocket_history_retry_metadata(retry, false),
)
.await;
} else {
let fallback = self
.upstream
.apply_retry_and_acquire_fallback(
&account,
retry,
&codex_request.model,
&mut excluded_account_ids,
)
.await;
self.upstream
.log_response(
&log_context,
retry.status(),
EventLevel::Warn,
"v1 responses upstream retrying with fallback account",
retry.metadata(false),
)
.await;
if let Some(fallback) = fallback {
account = fallback;
log_context = log_context.with_account(&account.id);
continue;
}
}
}
let error_response = codex_client_error_response(error);
self.upstream
.log_response(
&log_context,
error_response.0,
EventLevel::Error,
"v1 responses upstream request failed",
json!({"stream": false}),
)
.await;
return error_response.into_response();
}
}
};
if self
.upstream
.persist_cookies(&account.id, &response.set_cookie_headers)
.await
.is_err()
{
self.upstream
.log_response(
&log_context,
StatusCode::INTERNAL_SERVER_ERROR,
EventLevel::Error,
"v1 responses cookie persistence failed",
json!({"stream": false, "cookieStoreError": true}),
)
.await;
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(openai_error(
"Failed to persist upstream cookies",
"cookie_store_error",
)),
)
.into_response();
}
if let Some(usage) = response.usage {
if self
.upstream
.record_usage(&account.id, usage)
.await
.is_err()
{
self.upstream
.log_response(
&log_context,
StatusCode::INTERNAL_SERVER_ERROR,
EventLevel::Error,
"v1 responses usage persistence failed",
json!({"stream": false, "usage": usage, "usageStoreError": true}),
)
.await;
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(openai_error(
"Failed to record account usage",
"usage_store_error",
)),
)
.into_response();
}
}
match completed_response_json(&response.body) {
Ok(CollectedResponse::Completed(body)) => {
self.upstream
.log_response(
&log_context,
StatusCode::OK,
EventLevel::Info,
"v1 responses completed",
json!({"stream": false, "usage": response.usage}),
)
.await;
(StatusCode::OK, Json(body)).into_response()
}
Ok(CollectedResponse::Failed(failure)) => {
self.upstream
.log_response(
&log_context,
StatusCode::BAD_GATEWAY,
EventLevel::Error,
"v1 responses upstream SSE failed",
failure.metadata(false),
)
.await;
(
StatusCode::BAD_GATEWAY,
Json(openai_error(
&failure.openai_error_message(),
"upstream_error",
)),
)
.into_response()
}
Ok(CollectedResponse::MissingCompleted) => {
self.upstream
.log_response(
&log_context,
StatusCode::BAD_GATEWAY,
EventLevel::Warn,
"v1 responses completed event missing",
json!({"stream": false, "usage": response.usage}),
)
.await;
(
StatusCode::BAD_GATEWAY,
Json(openai_error(
"Codex response did not include response.completed",
"empty_upstream_response",
)),
)
.into_response()
}
Err(error) => {
self.upstream
.log_response(
&log_context,
StatusCode::BAD_GATEWAY,
EventLevel::Warn,
"v1 responses invalid SSE response",
json!({"stream": false, "sseParseError": error.to_string()}),
)
.await;
(
StatusCode::BAD_GATEWAY,
Json(openai_error(
"Invalid Codex SSE response",
"invalid_upstream_sse",
)),
)
.into_response()
}
}
}
}
#[derive(Deserialize)]
struct ResponsesBody {
model: Option<String>,
input: Option<Vec<Value>>,
instructions: Option<String>,
reasoning: Option<Value>,
tools: Option<Vec<Value>>,
service_tier: Option<String>,
tool_choice: Option<Value>,
parallel_tool_calls: Option<bool>,
text: Option<Value>,
prompt_cache_key: Option<String>,
include: Option<Vec<String>>,
client_metadata: Option<Value>,
previous_response_id: Option<String>,
stream: Option<bool>,
#[serde(rename = "turnState")]
turn_state: Option<String>,
#[serde(rename = "turnMetadata")]
turn_metadata: Option<String>,
#[serde(rename = "betaFeatures")]
beta_features: Option<String>,
#[serde(rename = "includeTimingMetrics")]
include_timing_metrics: Option<String>,
#[serde(rename = "codexWindowId")]
codex_window_id: Option<String>,
#[serde(rename = "parentThreadId")]
parent_thread_id: Option<String>,
#[serde(alias = "useWebSocket")]
use_websocket: Option<bool>,
}
fn default_body(default_model: String) -> ResponsesBody {
ResponsesBody {
model: Some(default_model),
input: Some(Vec::new()),
instructions: Some(String::new()),
reasoning: None,
tools: None,
service_tier: None,
tool_choice: None,
parallel_tool_calls: None,
text: None,
prompt_cache_key: None,
include: None,
client_metadata: None,
previous_response_id: None,
stream: None,
turn_state: None,
turn_metadata: None,
beta_features: None,
include_timing_metrics: None,
codex_window_id: None,
parent_thread_id: None,
use_websocket: None,
}
}
fn responses_reasoning(
client_reasoning: Option<Value>,
suffix_effort: Option<&str>,
default_effort: Option<&str>,
) -> Option<Value> {
let effort = client_reasoning
.as_ref()
.and_then(|reasoning| reasoning.get("effort"))
.and_then(Value::as_str)
.or(suffix_effort)
.or(default_effort);
let summary = client_reasoning
.as_ref()
.and_then(|reasoning| reasoning.get("summary"))
.and_then(Value::as_str)
.unwrap_or("auto");
if effort.is_none() && client_reasoning.is_none() {
return None;
}
let mut reasoning = json!({"summary": summary});
if let Some(effort) = effort {
reasoning["effort"] = Value::String(effort.to_string());
}
Some(reasoning)
}
fn ensure_reasoning_include(request: &mut CodexResponsesRequest) {
if request.reasoning.is_none() {
return;
}
let include = request.include.get_or_insert_with(Vec::new);
if include
.iter()
.any(|item| item == "reasoning.encrypted_content")
{
return;
}
include.push("reasoning.encrypted_content".to_string());
}
fn header_string(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.filter(|value| !value.trim().is_empty())
.map(ToString::to_string)
}

Some files were not shown because too many files have changed in this diff Show More