Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,9 @@ curl http://localhost:8080/v1/chat/completions \

| 模型 ID | 别名 | 推理等级 | 说明 |
|---------|------|---------|------|
| `gpt-5.4` | `gpt-5.4-codex` | none / low / medium / high / xhigh | 最新旗舰模型 |
| `gpt-5.3-codex` | — | low / medium / high | agentic 编程模型 |
| `gpt-5.3-codex-spark` | — | minimal / low | 超低延迟编程模型 |
| `gpt-5.2-codex` | `codex` | low / medium / high / xhigh | 前沿 agentic 编程模型(默认) |
| `gpt-5.2` | — | low / medium / high / xhigh | 专业工作 + 长时间代理 |
| `gpt-5.1-codex-max` | — | low / medium / high / xhigh | 扩展上下文 / 深度推理 |
Expand All @@ -265,7 +268,7 @@ curl http://localhost:8080/v1/chat/completions \
> **模型名后缀**:在任意模型名后追加 `-fast` 启用 Fast 模式,追加 `-high`/`-low` 等切换推理等级。
> 例如:`codex-fast`、`gpt-5.2-codex-high-fast`。
>
> **注意**:`gpt-5.4`、`gpt-5.3-codex` 系列已从 free 账号移除,plus 及以上账号仍可使用
> **可用性说明**:`gpt-5.4`、`gpt-5.3-codex`、`gpt-5.3-codex-spark` 属于计划受限模型,是否可用取决于当前账号对应的后端模型目录。`gpt-5.4-codex` 作为兼容别名会映射到 `gpt-5.4`
> 模型列表由后端动态获取,会自动同步最新可用模型。

## 🔗 客户端接入 (Client Setup)
Expand Down Expand Up @@ -404,11 +407,28 @@ server:
- **自动生成**:设为 `null`,代理会根据账号信息自动生成一个 `codex-proxy-` 前缀的哈希密钥
- 当前密钥始终显示在控制面板(`http://localhost:8080`)的 API Configuration 区域

### 管理端 Basic Auth

为除 `/v1/*`、`/v1beta/*` 和 `/health` 之外的管理端页面与接口增加 HTTP Basic Authentication:

```yaml
server:
admin_basic_auth_username: admin
admin_basic_auth_password: change-me
```

- 启用后,控制面板页面、`/auth/*`、`/admin/*`、`/api/*`、`/debug/*` 等管理端接口会要求 Basic Auth
- `/v1/*` 与 `/v1beta/*` 客户端协议接口不受影响,便于 OpenAI / Anthropic / Gemini 客户端继续直接接入
- `/health` 保持免认证,便于反向代理和容器健康检查
- 也可通过环境变量覆盖:`ADMIN_BASIC_AUTH_USERNAME`、`ADMIN_BASIC_AUTH_PASSWORD`

### 环境变量覆盖

| 环境变量 | 覆盖配置 |
|---------|---------|
| `PORT` | `server.port` |
| `ADMIN_BASIC_AUTH_USERNAME` | `server.admin_basic_auth_username` |
| `ADMIN_BASIC_AUTH_PASSWORD` | `server.admin_basic_auth_password` |
| `CODEX_PLATFORM` | `client.platform` |
| `CODEX_ARCH` | `client.arch` |
| `HTTPS_PROXY` | `tls.proxy_url` |
Expand Down
30 changes: 25 additions & 5 deletions README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,9 @@ curl http://localhost:8080/v1/chat/completions \

| Model ID | Alias | Reasoning Efforts | Description |
|----------|-------|-------------------|-------------|
| `gpt-5.4` | `gpt-5.4-codex` | none / low / medium / high / xhigh | Latest flagship model |
| `gpt-5.3-codex` | — | low / medium / high | Agentic coding model |
| `gpt-5.3-codex-spark` | — | minimal / low | Ultra-fast coding model |
| `gpt-5.2-codex` | `codex` | low / medium / high / xhigh | Frontier agentic coding model (default) |
| `gpt-5.2` | — | low / medium / high / xhigh | Professional work & long-running agents |
| `gpt-5.1-codex-max` | — | low / medium / high / xhigh | Extended context / deepest reasoning |
Expand All @@ -185,7 +188,7 @@ curl http://localhost:8080/v1/chat/completions \
> **Model name suffixes**: Append `-fast` to any model name to enable Fast mode, or `-high`/`-low` etc. to change reasoning effort.
> Examples: `codex-fast`, `gpt-5.2-codex-high-fast`.
>
> **Note**: `gpt-5.4` and `gpt-5.3-codex` families have been removed for free accounts. Plus and above accounts retain access.
> **Availability**: `gpt-5.4`, `gpt-5.3-codex`, and `gpt-5.3-codex-spark` are plan-restricted and depend on the backend catalog for the current account. `gpt-5.4-codex` is accepted as a compatibility alias of `gpt-5.4`.
> Models are dynamically fetched from the backend and will automatically sync the latest available catalog.

## 🔗 Client Setup
Expand Down Expand Up @@ -287,19 +290,36 @@ All configuration is in `config/default.yaml`:

| Section | Key Settings | Description |
|---------|-------------|-------------|
| `server` | `host`, `port`, `proxy_api_key` | Listen address and API key |
| `server` | `host`, `port`, `proxy_api_key`, `admin_basic_auth_*` | Listen address, API key, and management Basic Auth |
| `api` | `base_url`, `timeout_seconds` | Upstream API URL and timeout |
| `client_identity` | `app_version`, `build_number` | Codex Desktop version to impersonate |
| `client` | `originator`, `app_version`, `build_number`, `platform`, `arch`, `chromium_version` | Codex Desktop / Chromium identity to impersonate |
| `model` | `default`, `default_reasoning_effort`, `default_service_tier` | Default model, reasoning effort and speed mode |
| `auth` | `rotation_strategy`, `rate_limit_backoff_seconds` | Rotation strategy and rate limit backoff |

### Management Basic Auth

Add HTTP Basic Authentication for all management pages and endpoints except `/v1/*`, `/v1beta/*`, and `/health`:

```yaml
server:
admin_basic_auth_username: admin
admin_basic_auth_password: change-me
```

- When enabled, the dashboard UI and management endpoints such as `/auth/*`, `/admin/*`, `/api/*`, and `/debug/*` require Basic Auth
- `/v1/*` and `/v1beta/*` remain unchanged so OpenAI / Anthropic / Gemini clients can continue using the proxy directly
- `/health` remains unauthenticated for reverse proxy and container health checks
- You can also override these values with `ADMIN_BASIC_AUTH_USERNAME` and `ADMIN_BASIC_AUTH_PASSWORD`

### Environment Variable Overrides

| Variable | Overrides |
|----------|-----------|
| `PORT` | `server.port` |
| `CODEX_PLATFORM` | `client_identity.platform` |
| `CODEX_ARCH` | `client_identity.arch` |
| `ADMIN_BASIC_AUTH_USERNAME` | `server.admin_basic_auth_username` |
| `ADMIN_BASIC_AUTH_PASSWORD` | `server.admin_basic_auth_password` |
| `CODEX_PLATFORM` | `client.platform` |
| `CODEX_ARCH` | `client.arch` |

## 📡 API Endpoints

Expand Down
12 changes: 7 additions & 5 deletions config/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ client:
arch: arm64
chromium_version: "144"
model:
default: gpt-5.2-codex
default_reasoning_effort: medium
default: gpt-5.4
default_reasoning_effort: xhigh
default_service_tier: null
suppress_desktop_directives: true
auth:
Expand All @@ -23,9 +23,11 @@ auth:
oauth_auth_endpoint: https://auth.openai.com/oauth/authorize
oauth_token_endpoint: https://auth.openai.com/oauth/token
server:
host: "::"
port: 8080
proxy_api_key: pwd
host: "0.0.0.0"
port: 18080
proxy_api_key: "sk-2bf2321a5c234a9b3e5acec615fb53081e41f86e861373cf4c1eca4237c8825e"
admin_basic_auth_username: "admin"
admin_basic_auth_password: "f4c1eca4237c8825e"
session:
ttl_minutes: 60
cleanup_interval_minutes: 5
Expand Down
45 changes: 44 additions & 1 deletion config/models.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,51 @@
# Dynamic fetch merges with static; backend entries win for shared IDs.
# Models endpoint now requires ?client_version= query parameter.
#
# Last updated: 2026-03-10 (backend removed gpt-5.4, gpt-5.3-codex family)
# Last updated: 2026-03-15 (restored plan-restricted gpt-5.4 / gpt-5.3-codex entries for compatibility)

models:
# ── GPT-5.4 ──────────────────────────────────────────────────────────
- id: gpt-5.4
displayName: GPT-5.4
description: Latest flagship model for agentic, coding, and professional workflows
isDefault: false
supportedReasoningEfforts:
- { reasoningEffort: none, description: "No reasoning" }
- { reasoningEffort: low, description: "Fastest responses" }
- { reasoningEffort: medium, description: "Balanced speed and quality" }
- { reasoningEffort: high, description: "Deepest reasoning" }
- { reasoningEffort: xhigh, description: "Extended deep reasoning" }
defaultReasoningEffort: medium
inputModalities: [text, image]
supportsPersonality: true
upgrade: null

# ── GPT-5.3 Codex family ──────────────────────────────────────────
- id: gpt-5.3-codex
displayName: GPT-5.3 Codex
description: Agentic coding model for long-running tasks
isDefault: false
supportedReasoningEfforts:
- { reasoningEffort: low, description: "Fastest responses" }
- { reasoningEffort: medium, description: "Balanced speed and quality" }
- { reasoningEffort: high, description: "Deepest reasoning" }
defaultReasoningEffort: medium
inputModalities: [text]
supportsPersonality: false
upgrade: null

- id: gpt-5.3-codex-spark
displayName: GPT-5.3 Codex Spark
description: Ultra-fast real-time coding model
isDefault: false
supportedReasoningEfforts:
- { reasoningEffort: minimal, description: "Minimal reasoning" }
- { reasoningEffort: low, description: "Fastest responses" }
defaultReasoningEffort: low
inputModalities: [text]
supportsPersonality: false
upgrade: null

# ── GPT-5.2 Codex (current flagship) ────────────────────────────────
- id: gpt-5.2-codex
displayName: GPT-5.2 Codex
Expand Down Expand Up @@ -162,3 +204,4 @@ models:

aliases:
codex: "gpt-5.2-codex"
gpt-5.4-codex: "gpt-5.4"
6 changes: 3 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
services:
codex-proxy:
image: ghcr.io/icebear0828/codex-proxy:latest
image: yuwei/codex-proxy:latest
# To build from source instead: comment out 'image' above, uncomment 'build' below
# build: .
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "${PORT:-8080}:8080"
- "${PORT:-18080}:18080"
- "1455:1455"
volumes:
- ./data:/app/data
Expand All @@ -16,7 +16,7 @@ services:
- .env
environment:
- NODE_ENV=production
- PORT=8080
- PORT=18080

# -- Automatic updates (uncomment to enable) --
# watchtower:
Expand Down
10 changes: 10 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ const ConfigSchema = z.object({
host: z.string().default("0.0.0.0"),
port: z.number().min(1).max(65535).default(8080),
proxy_api_key: z.string().nullable().default(null),
admin_basic_auth_username: z.string().nullable().default(null),
admin_basic_auth_password: z.string().nullable().default(null),
}),
session: z.object({
ttl_minutes: z.number().min(1).default(60),
Expand Down Expand Up @@ -89,6 +91,14 @@ function applyEnvOverrides(raw: Record<string, unknown>): Record<string, unknown
(raw.server as Record<string, unknown>).port = parsed;
}
}
if (process.env.ADMIN_BASIC_AUTH_USERNAME !== undefined) {
(raw.server as Record<string, unknown>).admin_basic_auth_username =
process.env.ADMIN_BASIC_AUTH_USERNAME;
}
if (process.env.ADMIN_BASIC_AUTH_PASSWORD !== undefined) {
(raw.server as Record<string, unknown>).admin_basic_auth_password =
process.env.ADMIN_BASIC_AUTH_PASSWORD;
}
const proxyEnv = process.env.HTTPS_PROXY || process.env.https_proxy;
if (proxyEnv) {
if (!raw.tls) raw.tls = {};
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { RefreshScheduler } from "./auth/refresh-scheduler.js";
import { requestId } from "./middleware/request-id.js";
import { logger } from "./middleware/logger.js";
import { errorHandler } from "./middleware/error-handler.js";
import { adminBasicAuth } from "./middleware/admin-basic-auth.js";
import { createAuthRoutes } from "./routes/auth.js";
import { createAccountRoutes } from "./routes/accounts.js";
import { createChatRoutes } from "./routes/chat.js";
Expand Down Expand Up @@ -67,6 +68,7 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
app.use("*", requestId);
app.use("*", logger);
app.use("*", errorHandler);
app.use("*", adminBasicAuth);

// Mount routes
const authRoutes = createAuthRoutes(accountPool, refreshScheduler);
Expand Down
55 changes: 55 additions & 0 deletions src/middleware/admin-basic-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import {
isProtectedManagementPath,
parseBasicAuthHeader,
} from "./admin-basic-auth.js";

describe("isProtectedManagementPath", () => {
it("bypasses OpenAI-compatible API routes", () => {
expect(isProtectedManagementPath("/v1/chat/completions")).toBe(false);
expect(isProtectedManagementPath("/v1/models")).toBe(false);
});

it("bypasses Gemini-compatible API routes", () => {
expect(isProtectedManagementPath("/v1beta/models")).toBe(false);
expect(isProtectedManagementPath("/v1beta/models/gpt-5.4:generateContent")).toBe(false);
});

it("keeps health checks public", () => {
expect(isProtectedManagementPath("/health")).toBe(false);
});

it("protects dashboard and management endpoints", () => {
expect(isProtectedManagementPath("/")).toBe(true);
expect(isProtectedManagementPath("/auth/status")).toBe(true);
expect(isProtectedManagementPath("/admin/settings")).toBe(true);
expect(isProtectedManagementPath("/api/proxies")).toBe(true);
expect(isProtectedManagementPath("/debug/models")).toBe(true);
});
});

describe("parseBasicAuthHeader", () => {
it("parses valid Basic credentials", () => {
const header = `Basic ${Buffer.from("admin:secret").toString("base64")}`;

expect(parseBasicAuthHeader(header)).toEqual({
username: "admin",
password: "secret",
});
});

it("handles scheme case-insensitively", () => {
const header = `basic ${Buffer.from("admin:secret").toString("base64")}`;

expect(parseBasicAuthHeader(header)).toEqual({
username: "admin",
password: "secret",
});
});

it("rejects malformed values", () => {
expect(parseBasicAuthHeader(undefined)).toBeNull();
expect(parseBasicAuthHeader("Bearer token")).toBeNull();
expect(parseBasicAuthHeader("Basic invalid")).toBeNull();
});
});
74 changes: 74 additions & 0 deletions src/middleware/admin-basic-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { timingSafeEqual } from "crypto";
import type { Context, Next } from "hono";
import { getConfig } from "../config.js";

interface BasicAuthCredentials {
username: string;
password: string;
}

function secureEquals(left: string, right: string): boolean {
const leftBuf = Buffer.from(left, "utf8");
const rightBuf = Buffer.from(right, "utf8");
if (leftBuf.length !== rightBuf.length) return false;
return timingSafeEqual(leftBuf, rightBuf);
}

export function isProtectedManagementPath(path: string): boolean {
if (path === "/health") return false;
if (path === "/v1" || path.startsWith("/v1/")) return false;
if (path === "/v1beta" || path.startsWith("/v1beta/")) return false;
return true;
}

export function parseBasicAuthHeader(
authorization: string | undefined,
): BasicAuthCredentials | null {
const raw = authorization?.trim();
if (!raw) return null;

const match = /^Basic\s+([A-Za-z0-9+/=]+)$/i.exec(raw);
if (!match) return null;

let decoded = "";
try {
decoded = Buffer.from(match[1], "base64").toString("utf8");
} catch {
return null;
}

const separator = decoded.indexOf(":");
if (separator < 0) return null;

return {
username: decoded.slice(0, separator),
password: decoded.slice(separator + 1),
};
}

export async function adminBasicAuth(c: Context, next: Next): Promise<void | Response> {
if (!isProtectedManagementPath(c.req.path)) {
await next();
return;
}

const { admin_basic_auth_username, admin_basic_auth_password } = getConfig().server;
if (admin_basic_auth_username === null || admin_basic_auth_password === null) {
await next();
return;
}

const credentials = parseBasicAuthHeader(c.req.header("Authorization"));
const authenticated =
credentials !== null &&
secureEquals(credentials.username, admin_basic_auth_username) &&
secureEquals(credentials.password, admin_basic_auth_password);

if (authenticated) {
await next();
return;
}

c.header("WWW-Authenticate", 'Basic realm="Codex Proxy Admin", charset="UTF-8"');
return c.text("Unauthorized", 401);
}
Loading