Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
83 changes: 83 additions & 0 deletions docs-site/src/content/docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids.
| `websockets?` | `boolean` | `false` | Advertise `supports_websockets` so Codex uses the Responses WebSocket path. Omit or set `false` to keep HTTP/SSE. |
| `storageCleanupPolicy?` | `StorageCleanupPolicy` | unset / `enabled: false` | **Opt-in** archived auto-cleanup (issue #42 Phase 3). Default **OFF** — never enabled implicitly. When enabled, runs on `schedule` (`startup` / `daily` / `weekly` / `manual`) once archived bytes exceed `trigger.archivedBytesOver`, selecting oldest archives toward `target` (`reduceToBytes` or `removeOldestPercent`) in `mode` (`quarantine` default, or explicit `permanent`). Persists `lastRun` / `nextRun`. Configure via Storage page or `GET`/`PUT /api/storage/cleanup-policy`; `POST /api/storage/cleanup-policy/run` for manual. |
| `apiKeys?` | `OcxApiKey[]` | `[]` | Additional generated `ocx_…` credentials accepted by management and data-plane auth on non-loopback binds. Managed by the dashboard; entry fields are listed below. |
| `rateLimit?` | `OcxRateLimitConfig` | unset / `enabled: false` | **Opt-in** admission-aware rate limiting per API surface. Default **OFF** — omitting it keeps behavior exactly as before. See [Rate limiting](#rate-limiting) below. |
| `codexAutoStart?` | `boolean` | `true` | Let the Codex shim run `ocx ensure` before launching Codex. `false` makes `ocx ensure` a no-op. |
| `codexShimAutoRestore?` | `boolean` | `true` | Restore a previously installed Codex shim when a completed external Codex update replaces it. Set `false`, or set `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` for a process-level opt-out. |
| `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility mode. opencodex backs up original Codex thread metadata, remaps old OpenAI interactive rows to `opencodex`, and temporarily promotes opencodex-created `exec` rows to an app-visible source. `ocx stop` / `ocx restore` restore backed-up OpenAI rows and eject remaining opencodex user threads to OpenAI so native Codex can resume them after the proxy is removed from `config.toml`. Set `false` to opt out. |
Expand Down Expand Up @@ -391,6 +392,88 @@ scrape_configs:
The credentials file must contain the admin credential; data-plane `ocx_` keys and
`OPENCODEX_API_AUTH_TOKEN` do not authorize management endpoints.

## Rate limiting

`rateLimit` adds optional, opt-in admission rate limiting in front of the proxy's API surfaces.
It is disabled by default: configs without the key (and configs with `enabled: false`) behave
exactly as before, with no limiter running and no extra headers.

```json
{
"rateLimit": {
"enabled": true,
"loopbackBypass": false,
"maxBuckets": 10000,
"staleAfterMs": 600000,
"surfaces": {
"responses-http": { "requestsPerMinute": 300, "burst": 60 },
"management": { "requestsPerMinute": 240, "burst": 60 }
},
"websocket": { "perPrincipal": 16, "global": 128 }
}
}
```

| Field | Type | Default | Meaning |
| --- | --- | --- | --- |
| `enabled` | `boolean` | — (required) | Master switch. `false` keeps the feature fully off. |
| `loopbackBypass?` | `boolean` | `false` | Explicitly exempt loopback callers. Judged only by the trusted socket address or a loopback bind — never by `Origin` or forwarded headers, which any client can spoof. Only valid with `enabled: true`. |
| `maxBuckets?` | `number` | `10000` | Hard cap on tracked per-principal buckets. At the cap, new identities share a bounded per-surface overflow bucket (fail-closed) instead of allocating more state. |
| `staleAfterMs?` | `number` | `600000` | Idle time before a bucket may be evicted to make room at the cap. |
| `surfaces?` | per-surface `{requestsPerMinute, burst}` | built-in defaults | Fixed policy overrides per surface. Unknown surface names, non-positive rates, and non-integer bursts are rejected by config validation. |
| `websocket?` | `{perPrincipal?, global?}` | `16` / `128` | Concurrent open WebSocket connections (Responses transport and live sideband relays) per principal and in total. `perPrincipal` must not exceed `global`; an omitted value counts as its default for this check. |

### Surfaces and defaults

Each API surface has its own token bucket per caller. The surface names accepted under
`surfaces` and their built-in defaults:

| Surface | Endpoints | Default rpm / burst |
| --- | --- | --- |
| `management` | `/api/*` | 240 / 60 |
| `responses-http` | `POST /v1/responses`, `POST /v1/responses/compact` | 300 / 60 |
| `responses-websocket` | `/v1/responses` WebSocket handshakes | 60 / 20 |
| `chat-completions` | `POST /v1/chat/completions` | 300 / 60 |
| `claude-messages` | `POST /v1/messages`, `POST /v1/messages/count_tokens` | 300 / 60 |
| `images` | `POST /v1/images/generations`, `POST /v1/images/edits` | 60 / 10 |
| `search` | `POST /v1/alpha/search` | 120 / 30 |
| `live` | `POST /v1/live`, `POST /v1/realtime/calls`, live sideband WebSockets | 60 / 10 |
| `model-discovery` | `GET /v1/models` | 120 / 30 |

### How callers are identified

Buckets are keyed by an opaque, HMAC-derived principal computed inside the auth layer — never
by the raw credential. The identity used, in order of preference:

1. a **validated** data-plane admission credential (`OPENCODEX_API_AUTH_TOKEN` or a configured
`apiKeys` entry), so each admitted key gets its own budget;
2. a **validated** management admin token or GUI session (management surface);
3. the remote socket address, when Bun exposes a trustworthy one;
4. a shared anonymous bucket.

An invalid or made-up key never earns its own bucket: it is rejected with `401` by the existing
auth layer before any limiter state is allocated or consumed. The remote-address and anonymous
identities apply only to callers the server actually admits without a credential. `Origin`,
forwarded-IP headers, emails, model ids, and request bodies are never used as identity, and the
HMAC secret is process-local — it is not part of `config.json` and is never logged or exported.

### 429 responses

Denied requests return `429` with integer `Retry-After`, `RateLimit-Limit`,
`RateLimit-Remaining`, and `RateLimit-Reset` headers, using the error envelope native to the
surface: the OpenAI error shape on OpenAI-compatible routes, the Anthropic error shape on
`/v1/messages*`, and the management JSON shape on `/api/*`.

Authentication and origin checks always keep their existing precedence: an invalid key still
gets `401` and a rejected cross-origin request still gets `403`, and neither allocates or
consumes any limiter budget. A request is charged only after it passes those checks.

WebSocket handshakes are charged against their surface's request rate at upgrade time
(`responses-websocket` for the Responses transport, `live` for voice sideband joins), and
long-lived connections additionally hold a concurrency slot that is reserved before the
handshake completes, rolled back if the upgrade fails, and released when the socket closes for
any reason.

## Providers (`OcxProviderConfig`)

| Field | Type | Meaning |
Expand Down
74 changes: 74 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers";
import { parseDesktopProfile } from "./claude/desktop-profile";
import { isCodexReasoningEffort, modelRecordValue } from "./reasoning-effort";
import { DEFAULT_RATE_LIMIT_WEBSOCKET_CONCURRENCY } from "./ratelimit";

let _atomicSeq = 0;

Expand Down Expand Up @@ -666,6 +667,77 @@ const codexAccountNamespacesSchema = z.custom<Record<string, unknown>>(
}
}).pipe(z.record(z.string(), z.string()));

const RATE_LIMIT_SURFACE_NAMES = [
"management",
"responses-http",
"responses-websocket",
"chat-completions",
"claude-messages",
"images",
"search",
"live",
"model-discovery",
] as const;

const rateLimitSurfacePolicySchema = z.object({
requestsPerMinute: z.number().positive().finite(),
burst: z.number().int().min(1),
}).strict();

/**
* Canonical rate-limit settings (structure/plugin-metrics-ratelimit-benchmarks.md §3).
* Strict on purpose: an unknown surface name or field is a hand-edit mistake that would
* otherwise silently leave a surface unprotected, so it fails validation instead of passing
* through. The HMAC principal secret is process-local and is deliberately NOT a config field.
*/
const rateLimitConfigSchema = z.object({
enabled: z.boolean(),
loopbackBypass: z.boolean().optional(),
maxBuckets: z.number().int().min(1).max(1_000_000).optional(),
staleAfterMs: z.number().int().min(1).optional(),
surfaces: z.partialRecord(z.enum(RATE_LIMIT_SURFACE_NAMES), rateLimitSurfacePolicySchema).optional(),
websocket: z.object({
perPrincipal: z.number().int().min(1).optional(),
global: z.number().int().min(1).optional(),
}).strict().optional(),
}).strict().superRefine((rateLimit, ctx) => {
if (rateLimit.enabled !== true && rateLimit.loopbackBypass === true) {
ctx.addIssue({
code: "custom",
path: ["loopbackBypass"],
message: "loopbackBypass requires enabled: true",
});
}
// Compare EFFECTIVE limits: an omitted value is resolved from the runtime default before the
// check, so a partial override like `{ global: 1 }` cannot pass validation and then contradict
// the default `perPrincipal` at runtime (the per-principal cap would never be reachable).
const websocket = rateLimit.websocket;
const effectivePerPrincipal = websocket?.perPrincipal ?? DEFAULT_RATE_LIMIT_WEBSOCKET_CONCURRENCY.perPrincipal;
const effectiveGlobal = websocket?.global ?? DEFAULT_RATE_LIMIT_WEBSOCKET_CONCURRENCY.global;
if (effectivePerPrincipal > effectiveGlobal) {
const perPrincipalLabel = websocket?.perPrincipal !== undefined
? String(websocket.perPrincipal)
: `${DEFAULT_RATE_LIMIT_WEBSOCKET_CONCURRENCY.perPrincipal} (default)`;
const globalLabel = websocket?.global !== undefined
? String(websocket.global)
: `${DEFAULT_RATE_LIMIT_WEBSOCKET_CONCURRENCY.global} (default)`;
ctx.addIssue({
code: "custom",
path: ["websocket", websocket?.perPrincipal !== undefined ? "perPrincipal" : "global"],
message: `websocket.perPrincipal must not exceed websocket.global (effective perPrincipal ${perPrincipalLabel}, global ${globalLabel})`,
});
}
for (const [surface, policy] of Object.entries(rateLimit.surfaces ?? {})) {
if (policy && policy.burst > 1_000_000) {
ctx.addIssue({
code: "custom",
path: ["surfaces", surface, "burst"],
message: "burst must not exceed 1000000",
});
}
}
});

const configSchema = z.object({
port: z.number().int().min(0).max(65535).default(10100),
// A blank hostname degrades to undefined rather than failing the parse. `getDefaultConfig()`
Expand Down Expand Up @@ -701,6 +773,8 @@ const configSchema = z.object({
hideUnavailableModels: z.boolean().optional(),
/** Consecutive discovery fails before client hide. Default 3 when hide is enabled. */
hideUnavailableAfterDiscoveryFails: z.number().int().min(1).optional(),
/** Optional admission-aware rate limiting. Absent = disabled (backward compatible). */
rateLimit: rateLimitConfigSchema.optional(),
}).passthrough().superRefine((config, ctx) => {
const claudeCode = (config as { claudeCode?: unknown }).claudeCode;
if (claudeCode !== undefined && (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode))) {
Expand Down
10 changes: 10 additions & 0 deletions src/ratelimit/concurrency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ export interface ConcurrencyLimits {
global: number;
}

/**
* Default Responses WebSocket concurrency caps. Overridable via `config.rateLimit.websocket`.
* Lives with the primitives so config validation can compare effective (default-resolved)
* limits without importing server modules.
*/
export const DEFAULT_RATE_LIMIT_WEBSOCKET_CONCURRENCY: Readonly<ConcurrencyLimits> = Object.freeze({
perPrincipal: 16,
global: 128,
});

export interface ConcurrencyLimiterOptions {
maxTrackedPrincipals?: number;
}
Expand Down
40 changes: 40 additions & 0 deletions src/ratelimit/config-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { RateLimitSurface } from "./index";

/**
* Rate-limit configuration augments the canonical config module without forcing the large shared
* types file to own runtime-admission implementation details.
*/
declare module "../types" {
/** Fixed request-rate policy for one admission surface. */
export interface OcxRateLimitSurfacePolicy {
/** Sustained refill rate. Must be a positive finite number. */
requestsPerMinute: number;
/** Bucket capacity (max burst). Must be a positive integer. */
burst: number;
}

/** Optional admission-aware rate limiting. Absent or disabled preserves historical behavior. */
export interface OcxRateLimitConfig {
enabled: boolean;
/** Trusted socket/bind based loopback exemption. Origin and forwarded headers never qualify. */
loopbackBypass?: boolean;
/** Hard cap on tracked principal buckets before bounded overflow accounting. */
maxBuckets?: number;
/** Idle milliseconds before a principal bucket may be evicted at the hard cap. */
staleAfterMs?: number;
/** Per-surface policy overrides; omitted surfaces use built-in defaults. */
surfaces?: Partial<Record<RateLimitSurface, OcxRateLimitSurfacePolicy>>;
/** Responses WebSocket concurrent-connection limits. */
websocket?: {
perPrincipal?: number;
global?: number;
};
}

export interface OcxConfig {
/** Optional admission-aware rate limiting. Default disabled. */
rateLimit?: OcxRateLimitConfig;
}
}

export {};
1 change: 1 addition & 0 deletions src/ratelimit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export {
type TokenBucketLimiterOptions,
} from "./token-bucket";
export {
DEFAULT_RATE_LIMIT_WEBSOCKET_CONCURRENCY,
WebSocketConcurrencyLimiter,
type ConcurrencyDenyReason,
type ConcurrencyLimiterOptions,
Expand Down
Loading
Loading