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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -166,5 +166,9 @@ ccproxy/static/dashboard/
.lazy.lua
.ccproxy.toml

# P124: deployment config (not tracked in git)
config.toml

make
run
.ccproxy.toml
100 changes: 100 additions & 0 deletions FORK-README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# CCProxy Codex Fork — P124 Module 1: Fork & Reduction

Forked from: `CaddyGlow/ccproxy-api` at tag `v0.2.6`
Fork target: `sterling-prog/ccproxy-codex`
Branch: `build/P124-ccproxy-codex`
Date: 2026-03-26

## Purpose

Stripped-down proxy for OpenAI Codex API access via claude-code OAuth.
Runs on port 3462. Phase 1 has only the Codex provider active;
Claude Code adapter is present but disabled for Phase 2 enablement.

---

## Plugins Removed

| Plugin | Reason |
|---|---|
| `copilot` | GitHub Copilot provider — not needed for this deployment |
| `credential_balancer` | Multi-credential rotation — single-account setup |
| `analytics` | DuckDB-backed request analytics — operational overhead not needed |
| `dashboard` | Web UI dashboard — not needed |
| `duckdb_storage` | DuckDB storage backend — removed with analytics/dashboard |
| `pricing` | Token pricing calculator — not needed |
| `docker` | Docker container routing for Claude — not needed |

---

## Plugins Retained

### Primary Provider
- `codex` — OpenAI Codex API proxy (Phase 1 active)
- `oauth_codex` — OAuth token management for Codex

### Claude Code Adapter (Phase 2, disabled in config)
- `claude_api` — Claude API provider
- `claude_sdk` — Claude SDK provider
- `claude_shared` — Shared Claude utilities
- `oauth_claude` — OAuth token management for Claude

### Operational
- `access_log` — Structured HTTP access logging
- `request_tracer` — JSON request/response traces for debugging
- `max_tokens` — Token limit enforcement
- `permissions` — Permission/scope enforcement
- `metrics` — Prometheus metrics endpoint
- `command_replay` — Generates curl replay commands for debugging

---

## Code Changes Made During Reduction

1. **`ccproxy/plugins/access_log/plugin.py`** — Moved hard import of
`ccproxy.plugins.analytics.ingest.AnalyticsIngestService` to a lazy
import inside a `try/except ImportError` block. The analytics integration
was already optional at runtime; now the import is also optional.

2. **`ccproxy/testing/endpoints/config.py`** — Removed hard import of
`ccproxy.plugins.copilot` and the `copilot` entries in `PROVIDER_CONFIGS`
and `PROVIDER_TOOL_ACCUMULATORS`. This file is only used by the endpoint
testing harness.

3. **`ccproxy/config/settings.py`** — Removed `"copilot"` from
`DEFAULT_ENABLED_PLUGINS` (the fallback list used when no config file exists).

4. **`pyproject.toml`** — Removed entry points for the 7 deleted plugins.

All `pricing` imports in `codex/hooks.py`, `codex/plugin.py`,
`claude_api/hooks.py`, and `claude_api/plugin.py` were already lazy
(inside `try/except` blocks) — no changes needed there.

---

## Deployment Config

`config.toml` (not tracked in git) is the deployment config for port 3462.
Key settings:

- Port: 3462, host: 127.0.0.1
- `enabled_plugins`: codex, oauth_codex, access_log, request_tracer,
max_tokens, permissions, metrics, command_replay
- Claude Code plugins explicitly disabled: `enabled = false`
- Scheduler pricing updates disabled
- HTTP: timeout=900s, max_concurrent=5, queue_depth=20, queue_timeout=120
- Rate: scheduler max_concurrent_tasks=5

To start (dev only, do not start in production without PM2 setup):
```
uv run ccproxy serve --config config.toml
```

---

## Upstream Compatibility

The upstream plugin registry uses filesystem discovery — it finds plugins
by scanning `ccproxy/plugins/*/plugin.py`. Removing the directories is
sufficient; no central registry file needed updating beyond `pyproject.toml`
entry points (which are used for installed-package mode).
89 changes: 89 additions & 0 deletions ccproxy/api/middleware/rate_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Rate limiting middleware for ccproxy.

Implements a simple sliding-window rate limiter. Default: 60 requests/minute
as required by P124 spec (matches current codex-proxy behavior).
"""

import asyncio
import time
from collections import deque

from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse, Response

from ccproxy.core.logging import get_logger

logger = get_logger(__name__)


class RateLimitMiddleware(BaseHTTPMiddleware):
"""Sliding-window rate limiter.

Applies a global rate limit across all incoming requests.
Health/metrics endpoints are excluded to avoid interfering with monitoring.

An asyncio.Lock guards the check-and-append so concurrent coroutines
cannot both pass the capacity check before either records its timestamp.
"""

EXCLUDED_PREFIXES = ("/health", "/ready", "/metrics")

def __init__(self, app, max_requests: int = 60, window_seconds: int = 60):
super().__init__(app)
self.max_requests = max_requests
self.window_seconds = window_seconds
self._timestamps: deque[float] = deque()
self._lock = asyncio.Lock()

async def dispatch(self, request: Request, call_next) -> Response:
# Skip rate limiting for health/metrics endpoints
path = request.url.path
if any(path.startswith(p) for p in self.EXCLUDED_PREFIXES):
return await call_next(request)

async with self._lock:
now = time.monotonic()

# Evict expired entries
cutoff = now - self.window_seconds
while self._timestamps and self._timestamps[0] < cutoff:
self._timestamps.popleft()

if len(self._timestamps) >= self.max_requests:
retry_after = int(self._timestamps[0] + self.window_seconds - now) + 1
request_id = getattr(getattr(request, "state", None), "request_id", "unknown")
logger.warning(
"rate_limit_exceeded",
request_id=request_id,
method=request.method,
path=path,
current_count=len(self._timestamps),
max_requests=self.max_requests,
window_seconds=self.window_seconds,
)
return JSONResponse(
status_code=429,
content={
"error": {
"message": f"Rate limit exceeded: {self.max_requests} requests per {self.window_seconds}s",
"type": "rate_limit_error",
"code": "rate_limit_exceeded",
}
},
headers={"Retry-After": str(retry_after)},
)

self._timestamps.append(now)

response = await call_next(request)

# Add rate limit headers (snapshot remaining under lock to avoid tearing)
async with self._lock:
remaining = self.max_requests - len(self._timestamps)

response.headers["X-RateLimit-Limit"] = str(self.max_requests)
response.headers["X-RateLimit-Remaining"] = str(max(0, remaining))
response.headers["X-RateLimit-Reset"] = str(int(now + self.window_seconds))

return response
10 changes: 10 additions & 0 deletions ccproxy/api/routes/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,16 @@ async def readiness_probe(response: Response) -> dict[str, Any]:
}


@router.get(
"/ready",
response_class=HealthJSONResponse,
responses=_health_responses("Readiness probe (alias)"),
)
async def ready_alias(response: Response) -> dict[str, Any]:
"""Readiness probe alias at /ready for convenience."""
return await readiness_probe(response)


@router.get(
"/health",
response_class=HealthJSONResponse,
Expand Down
1 change: 0 additions & 1 deletion ccproxy/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
# Default plugins enabled when no config file exists
DEFAULT_ENABLED_PLUGINS = [
"codex",
"copilot",
"claude_api",
"claude_sdk",
"oauth_codex",
Expand Down
33 changes: 26 additions & 7 deletions ccproxy/core/plugins/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,16 @@
"""

from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from typing import Any

from fastapi import FastAPI
from starlette.middleware.base import BaseHTTPMiddleware

from ccproxy.core.logging import TraceBoundLogger, get_logger

from .declaration import MiddlewareLayer, MiddlewareSpec


if TYPE_CHECKING:
from starlette.middleware.base import BaseHTTPMiddleware
else:
from starlette.middleware.base import BaseHTTPMiddleware


logger: TraceBoundLogger = get_logger()


Expand Down Expand Up @@ -140,6 +135,20 @@ def apply_to_app(self, app: FastAPI) -> None:
exc_info=e,
category="middleware",
)
# Security-layer middleware failing to register is fatal — a
# missing rate limiter or auth middleware would leave the proxy
# unprotected. Re-raise so startup fails loudly rather than
# silently serving unguarded traffic.
if spec.priority <= MiddlewareLayer.SECURITY:
# Suppress the exception chain (from None) so that kwargs
# passed to add_middleware() — which may include secrets or
# tokens — are not propagated up the call stack via
# __cause__. The original exception is already captured in
# the structured log above with exc_info.
raise RuntimeError(
f"Security middleware {spec.middleware_class.__name__!r} "
"failed to register (see startup log for details)"
) from None

# Log aggregated success
if applied_middleware:
Expand Down Expand Up @@ -224,6 +233,16 @@ def setup_default_middleware(manager: MiddlewareManager) -> None:
# AccessLogMiddleware, priority=MiddlewareLayer.OBSERVABILITY
# )
#
# Rate limiting at security layer (60 req/min per P124 spec)
from ccproxy.api.middleware.rate_limit import RateLimitMiddleware

manager.add_core_middleware(
RateLimitMiddleware,
priority=MiddlewareLayer.SECURITY,
max_requests=60,
window_seconds=60,
)

# Normalize headers: strip unsafe and ensure server header
manager.add_core_middleware(
NormalizeHeadersMiddleware, # type: ignore[arg-type]
Expand Down
7 changes: 5 additions & 2 deletions ccproxy/plugins/access_log/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
SystemPluginRuntime,
)
from ccproxy.core.plugins.hooks import HookRegistry
from ccproxy.plugins.analytics.ingest import AnalyticsIngestService
from ccproxy.services.container import ServiceContainer

from .config import AccessLogConfig
Expand Down Expand Up @@ -51,13 +50,17 @@ async def _on_initialize(self) -> None:

hook_registry.register(self.hook)

# Try to wire analytics ingest service if available
# Try to wire analytics ingest service if available (optional dependency)
try:
from ccproxy.plugins.analytics.ingest import AnalyticsIngestService # noqa: PLC0415

registry = self.context.get(ServiceContainer)
self.hook.ingest_service = registry.get_service(AnalyticsIngestService)
if not self.hook.ingest_service:
# optional service
logger.debug("access_log_analytics_service_not_found")
except (ImportError, ModuleNotFoundError):
logger.debug("access_log_analytics_plugin_not_available")
except Exception as e:
logger.warning(
"access_log_ingest_service_connect_failed", error=str(e), exc_info=e
Expand Down
24 changes: 0 additions & 24 deletions ccproxy/plugins/analytics/README.md

This file was deleted.

1 change: 0 additions & 1 deletion ccproxy/plugins/analytics/__init__.py

This file was deleted.

5 changes: 0 additions & 5 deletions ccproxy/plugins/analytics/config.py

This file was deleted.

Loading