mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
chore: scaffold codex-proxy-rs
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
+19
@@ -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
File diff suppressed because it is too large
Load Diff
+57
@@ -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 }
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 | | | |
|
||||
@@ -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
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
components = ["rustfmt", "clippy"]
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod error;
|
||||
@@ -0,0 +1,5 @@
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
println!("codex-proxy-rs bootstrap");
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user