chore: scaffold codex-proxy-rs

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-11 20:52:47 +08:00
co-authored by Codex
commit fc47967ff4
13 changed files with 6907 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
/target/
/data/
/logs/
/.env
/.env.*
!/.env.example
/config/local.yaml
/config/local.yml
/.worktrees/
/worktrees/
*.sqlite
*.sqlite3
*.db
*.db-shm
*.db-wal
*.log
*.tmp
*.swp
*~
Generated
+3577
View File
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
[package]
name = "codex-proxy-rs"
version = "0.1.0"
edition = "2021"
license = "MIT"
publish = false
description = "A lean Rust Codex proxy backed only by ChatGPT/Codex accounts."
repository = "https://example.invalid/codex-proxy-rs"
rust-version = "1.95"
[dependencies]
aes-gcm = "0.10.3"
anyhow = "1.0.102"
argon2 = "0.5.3"
async-trait = "0.1.89"
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"
reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "cookies", "rustls-tls-native-roots"] }
rustls = { version = "=0.23.36", default-features = false, features = ["ring", "std", "tls12"] }
secrecy = "0.10.3"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
serde_yaml = "0.9.34"
sha2 = "0.11.0"
sqlx = { version = "0.9.0", features = ["runtime-tokio", "tls-rustls", "sqlite", "chrono", "uuid", "migrate"] }
thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "signal", "fs", "time", "sync"] }
tower = "0.5.3"
tower-http = { version = "0.6.11", features = ["trace", "cors", "request-id", "timeout"] }
tracing = "0.1.44"
tracing-appender = "0.2.5"
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
uuid = { version = "1.23.3", features = ["v4", "v7", "serde"] }
zeroize = "1.8.2"
[dev-dependencies]
insta = "1.47.2"
pretty_assertions = "1.4.1"
tempfile = "3.27.0"
wiremock = "0.6.5"
[lints.rust]
future_incompatible = { level = "warn", priority = -1 }
nonstandard_style = { level = "deny", priority = -1 }
unsafe_code = "forbid"
[lints.clippy]
all = { level = "deny", priority = 10 }
redundant_clone = { level = "deny", priority = 9 }
large_enum_variant = { level = "warn", priority = 8 }
needless_collect = { level = "deny", priority = 7 }
+13
View File
@@ -0,0 +1,13 @@
# codex-proxy-rs
`codex-proxy-rs` is a lean Rust rewrite of `codex-proxy`.
The service exposes OpenAI-compatible local endpoints backed only by ChatGPT/Codex
accounts and the official Codex backend:
- Keep `/v1/chat/completions`, `/v1/responses`, and `/v1/models`.
- Remove OpenAI official API key direct upstream.
- Remove Anthropic, Gemini, custom provider routing, Ollama, Electron, and per-account proxy assignment.
- Keep Codex Desktop TLS/header parity, Cloudflare Cookie persistence, and fingerprint auto-update as first-class subsystems.
Current implementation status is tracked in `docs/implementation-status.md`.
+87
View File
@@ -0,0 +1,87 @@
# API Contract
`codex-proxy-rs` exposes two API families.
## `/v1/*`
These endpoints are OpenAI-compatible and are authenticated only by client API keys using `Authorization: Bearer cpr_...`.
Responses include an `X-Request-Id` header for tracing, but the body stays OpenAI-compatible and does not include a custom `requestId` field.
Error body:
```json
{
"error": {
"message": "Invalid client API key",
"type": "invalid_request_error",
"param": null,
"code": "invalid_api_key"
}
}
```
Streaming errors after SSE has started use:
```text
event: error
data: {"error":{"message":"Upstream failed","type":"server_error","param":null,"code":"upstream_error"}}
```
## `/admin/*`
Admin endpoints are authenticated only by HttpOnly admin session cookies.
Admin JSON uses lower camelCase field names. Every admin response includes an `X-Request-Id` header, and every JSON body includes `requestId`.
Use real HTTP status codes and body-level frontend codes together. Do not return HTTP `200` for failed requests. The body `code` exists for frontend branching; the HTTP status remains the transport truth.
Success body:
```json
{
"code": 200,
"message": "OK",
"data": {},
"requestId": "req_01"
}
```
List body:
```json
{
"code": 200,
"message": "OK",
"data": [],
"page": {
"limit": 50,
"nextCursor": null
},
"requestId": "req_01"
}
```
Error body:
```json
{
"code": 40101,
"message": "Admin login required",
"data": null,
"requestId": "req_01"
}
```
Pagination uses cursor ordering by `(created_at desc, id desc)`, default `limit=50`, max `limit=200`.
Rust structs use `PascalCase` type names and `snake_case` fields internally, then expose lower camelCase through serde:
```rust
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ApiEnvelope<T> {
pub code: u32,
pub message: String,
pub data: T,
pub request_id: String,
}
```
+14
View File
@@ -0,0 +1,14 @@
# Dependency Policy
- Use current stable crate releases for normal Rust dependencies.
- Commit `Cargo.lock` because this is an application.
- Run `cargo update` in dedicated dependency-refresh changes.
- Keep `reqwest = 0.12.28` and `rustls = 0.23.36` pinned until a TLS fingerprint review proves a newer pair matches real Codex Desktop behavior.
- Do not add unmaintained crates when a maintained community-standard crate exists.
- Every dependency change must run:
```bash
cargo fmt --check
cargo clippy --all-targets --all-features --locked -- -D warnings
cargo test
```
+21
View File
@@ -0,0 +1,21 @@
# Implementation Status
Update this file before each feature commit.
| Area | Status | Commit | Verification | Notes |
| --- | --- | --- | --- | --- |
| Scaffold | Completed | initial scaffold commit | `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 | initial scaffold commit | `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 | Planned | | | |
| SQLite storage | Planned | | | |
| Admin auth and client API keys | Planned | | | |
| Secret encryption | Planned | | | |
| Logging and pagination | Planned | | | |
| TLS headers and fingerprint | Planned | | | |
| Cookie persistence | Planned | | | |
| Account pool and refresh | Planned | | | |
| Translation | Planned | | | |
| HTTP routes | Planned | | | |
| Upstream lifecycle | Planned | | | |
| Fingerprint updates | Planned | | | |
| Runtime docs and packaging | Planned | | | |
+55
View File
@@ -0,0 +1,55 @@
# Status Codes
HTTP status codes are not replaced by body codes. Admin/frontend APIs return both an accurate HTTP status and a JSON body `code` unless the status is `204 No Content`.
## Health
| Status | Meaning |
| --- | --- |
| `200` | Service is running. |
## Admin and Frontend APIs
| HTTP status | Body code range | Meaning |
| --- | --- | --- |
| `200` | `200` | Successful read, login, or mutation with response body. |
| `201` | `201` | Resource created. |
| `204` | `204` | Successful mutation with no response body. No JSON envelope is sent. |
| `400` | `40000`-`40099` | Malformed JSON, invalid parameters, or invalid cursor. |
| `401` | `40100`-`40199` | Missing/expired admin session or bad admin password. |
| `403` | `40300`-`40399` | Authenticated admin lacks permission for a local-only/bootstrap action. |
| `404` | `40400`-`40499` | Resource not found. |
| `409` | `40900`-`40999` | Duplicate resource or stale state transition. |
| `422` | `42200`-`42299` | Well-formed request failed domain validation. |
| `429` | `42900`-`42999` | Login or operation rate limit. |
| `500` | `50000`-`50099` | Internal service error. |
Recommended initial body codes:
| Body code | HTTP status | Meaning |
| --- | --- | --- |
| `40001` | `400` | Validation failed. |
| `40002` | `400` | Invalid cursor. |
| `40101` | `401` | Admin session required. |
| `40102` | `401` | Admin password invalid. |
| `40301` | `403` | Bootstrap action denied. |
| `40401` | `404` | Resource not found. |
| `40901` | `409` | Duplicate resource. |
| `42201` | `422` | Domain validation failed. |
| `42901` | `429` | Login rate limited. |
| `50001` | `500` | Internal service error. |
## OpenAI-Compatible `/v1/*`
| Status | Meaning |
| --- | --- |
| `200` | Successful response or SSE stream accepted. |
| `400` | Invalid OpenAI/Responses request body. |
| `401` | Missing or invalid client API key. |
| `404` | Requested model is not a supported Codex model. |
| `413` | Request payload too large for safe replay or forwarding. |
| `429` | Codex quota or rate limit surfaced to client. |
| `499` | Client aborted; log internally only. |
| `502` | Upstream Codex transport or protocol failure. |
| `503` | No usable Codex account is available. |
| `504` | Upstream timeout. |
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "stable"
components = ["rustfmt", "clippy"]
+70
View File
@@ -0,0 +1,70 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
use thiserror::Error;
pub type AppResult<T> = Result<T, AppError>;
#[derive(Debug, Error)]
pub enum AppError {
#[error("configuration error: {0}")]
Config(String),
#[error("database error: {0}")]
Database(#[from] sqlx::Error),
#[error("upstream error: {0}")]
Upstream(String),
#[error("unauthorized")]
Unauthorized,
#[error("forbidden")]
Forbidden,
#[error("bad request: {0}")]
BadRequest(String),
}
#[derive(Serialize)]
struct ErrorBody<'a> {
error: ErrorMessage<'a>,
}
#[derive(Serialize)]
struct ErrorMessage<'a> {
message: &'a str,
#[serde(rename = "type")]
kind: &'a str,
code: &'a str,
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code, kind) = match &self {
Self::BadRequest(_) | Self::Config(_) => (
StatusCode::BAD_REQUEST,
"bad_request",
"invalid_request_error",
),
Self::Unauthorized => (
StatusCode::UNAUTHORIZED,
"unauthorized",
"invalid_request_error",
),
Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden", "invalid_request_error"),
Self::Database(_) | Self::Upstream(_) => {
(StatusCode::BAD_GATEWAY, "upstream_error", "server_error")
}
};
let message = self.to_string();
let body = ErrorBody {
error: ErrorMessage {
message: &message,
kind,
code,
},
};
(status, Json(body)).into_response()
}
}
+1
View File
@@ -0,0 +1 @@
pub mod error;
+5
View File
@@ -0,0 +1,5 @@
#[tokio::main]
async fn main() -> anyhow::Result<()> {
println!("codex-proxy-rs bootstrap");
Ok(())
}