Skip to content
Open
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
1 change: 0 additions & 1 deletion .claude

This file was deleted.

3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ CODEX_LB_UPSTREAM_BASE_URL=https://chatgpt.com/backend-api
CODEX_LB_UPSTREAM_CONNECT_TIMEOUT_SECONDS=30
CODEX_LB_STREAM_IDLE_TIMEOUT_SECONDS=300

# Anthropic-compatible default reasoning effort (optional)
# CODEX_LB_ANTHROPIC_DEFAULT_REASONING_EFFORT=xhigh

# OAuth / token refresh
CODEX_LB_AUTH_BASE_URL=https://auth.openai.com
CODEX_LB_OAUTH_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann
Expand Down
43 changes: 42 additions & 1 deletion app/core/auth/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import logging

from fastapi import Request, Security
from fastapi import HTTPException, Request, Security
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

from app.core.clients.usage import UsageFetchError, fetch_usage
Expand All @@ -26,6 +26,10 @@ def set_openai_error_format(request: Request) -> None:
request.state.error_format = "openai"


def set_anthropic_error_format(request: Request) -> None:
request.state.error_format = "anthropic"


def set_dashboard_error_format(request: Request) -> None:
request.state.error_format = "dashboard"

Expand All @@ -52,6 +56,26 @@ async def validate_proxy_api_key(
raise ProxyAuthError(str(exc)) from exc


async def validate_anthropic_api_key(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Security(_bearer),
) -> ApiKeyData | None:
settings = await get_settings_cache().get()
if not settings.api_key_auth_enabled:
return None

token = _extract_anthropic_api_key(request, credentials)
if token is None:
raise HTTPException(status_code=401, detail="Missing API key")

async with get_background_session() as session:
service = ApiKeysService(ApiKeysRepository(session))
try:
return await service.validate_key(token)
except ApiKeyInvalidError as exc:
raise HTTPException(status_code=401, detail=str(exc)) from exc


# --- Dashboard session auth ---


Expand Down Expand Up @@ -119,3 +143,20 @@ def _extract_bearer_token(authorization: str | None) -> str | None:
if not token:
return None
return token


def _extract_anthropic_api_key(
request: Request,
credentials: HTTPAuthorizationCredentials | None,
) -> str | None:
x_api_key = request.headers.get("x-api-key")
if x_api_key is not None:
token = x_api_key.strip()
if token:
return token
if credentials is None:
return None
token = credentials.credentials.strip()
if not token:
return None
return token
1 change: 1 addition & 0 deletions app/core/clients/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"content-length",
"host",
"forwarded",
"x-api-key",
"x-real-ip",
"true-client-ip",
}
Expand Down
11 changes: 11 additions & 0 deletions app/core/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ class Settings(BaseSettings):
log_proxy_request_shape: bool = False
log_proxy_request_shape_raw_cache_key: bool = False
log_proxy_request_payload: bool = False
anthropic_default_reasoning_effort: str | None = None
max_decompressed_body_bytes: int = Field(default=32 * 1024 * 1024, gt=0)
image_inline_fetch_enabled: bool = True
image_inline_allowed_hosts: Annotated[list[str], NoDecode] = Field(default_factory=list)
Expand Down Expand Up @@ -115,6 +116,16 @@ def _normalize_image_inline_allowed_hosts(cls, value: object) -> list[str]:
return normalized
raise TypeError("image_inline_allowed_hosts must be a list or comma-separated string")

@field_validator("anthropic_default_reasoning_effort")
@classmethod
def _normalize_anthropic_default_reasoning_effort(cls, value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip()
if not normalized:
raise ValueError("anthropic_default_reasoning_effort must be a non-empty string")
return normalized


@lru_cache(maxsize=1)
def get_settings() -> Settings:
Expand Down
14 changes: 14 additions & 0 deletions app/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ class OpenAIErrorEnvelope(TypedDict):
error: OpenAIErrorDetail


class AnthropicErrorDetail(TypedDict):
type: str
message: str


class AnthropicErrorEnvelope(TypedDict):
type: Literal["error"]
error: AnthropicErrorDetail


class DashboardErrorDetail(TypedDict):
code: str
message: str
Expand Down Expand Up @@ -45,6 +55,10 @@ def openai_error(code: str, message: str, error_type: str = "server_error") -> O
return {"error": {"message": message, "type": error_type, "code": code}}


def anthropic_error(error_type: str, message: str) -> AnthropicErrorEnvelope:
return {"type": "error", "error": {"type": error_type, "message": message}}


def dashboard_error(code: str, message: str) -> DashboardErrorEnvelope:
return {"error": {"code": code, "message": message}}

Expand Down
39 changes: 38 additions & 1 deletion app/core/handlers/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from fastapi.responses import JSONResponse, Response
from starlette.exceptions import HTTPException as StarletteHTTPException

from app.core.errors import dashboard_error, openai_error
from app.core.errors import anthropic_error, dashboard_error, openai_error
from app.core.exceptions import (
AppError,
DashboardAuthError,
Expand Down Expand Up @@ -44,6 +44,18 @@
DashboardRateLimitError,
)

_ANTHROPIC_V1_PATH_PREFIXES: tuple[str, ...] = (
"/v1/messages",
"/v1/messages/count_tokens",
)


def _is_anthropic_v1_path(path: str) -> bool:
for prefix in _ANTHROPIC_V1_PATH_PREFIXES:
if path == prefix or path.startswith(f"{prefix}/"):
return True
return False


def _error_format(request: Request) -> str | None:
fmt = getattr(request.state, "error_format", None)
Expand All @@ -53,6 +65,10 @@ def _error_format(request: Request) -> str | None:
path = request.url.path
if path.startswith("/api/"):
return "dashboard"
if path.startswith("/anthropic/"):
return "anthropic"
if _is_anthropic_v1_path(path):
return "anthropic"
if path.startswith("/v1/") or path.startswith("/backend-api/"):
return "openai"
Comment on lines 72 to 73
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route /v1/messages validation failures to Anthropic envelope

The fallback format detection still treats every /v1/* path as OpenAI, so requests like malformed JSON to /v1/messages (or /v1/messages/count_tokens) can bypass set_anthropic_error_format and return the OpenAI error shape instead of Anthropic's {"type":"error",...} envelope. Because JSON/body parsing errors are raised before route dependencies run, Anthropic clients can receive an incompatible error contract on exactly the endpoints this change adds.

Useful? React with 👍 / 👎.

return None
Expand Down Expand Up @@ -99,6 +115,11 @@ async def validation_error_handler(
status_code=422,
content=dashboard_error("validation_error", "Invalid request payload"),
)
if fmt == "anthropic":
return JSONResponse(
status_code=400,
content=anthropic_error("invalid_request_error", "Invalid request payload"),
)
if fmt == "openai":
error = openai_error("invalid_request_error", "Invalid request payload", error_type="invalid_request_error")
if exc.errors():
Expand All @@ -123,6 +144,17 @@ async def http_error_handler(
status_code=exc.status_code,
content=dashboard_error(f"http_{exc.status_code}", detail),
)
if fmt == "anthropic":
error_type = "invalid_request_error"
if exc.status_code == 401:
error_type = "authentication_error"
elif exc.status_code == 403:
error_type = "permission_error"
elif exc.status_code == 429:
error_type = "rate_limit_error"
elif exc.status_code >= 500:
error_type = "api_error"
return JSONResponse(status_code=exc.status_code, content=anthropic_error(error_type, detail))
if fmt == "openai":
error_type = "invalid_request_error"
code = "invalid_request_error"
Expand Down Expand Up @@ -155,6 +187,11 @@ async def unhandled_error_handler(request: Request, exc: Exception) -> JSONRespo
status_code=500,
content=dashboard_error("internal_error", "Unexpected error"),
)
if fmt == "anthropic":
return JSONResponse(
status_code=500,
content=anthropic_error("api_error", "Internal server error"),
)
if fmt == "openai":
return JSONResponse(
status_code=500,
Expand Down
7 changes: 6 additions & 1 deletion app/core/openai/message_coercion.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,12 @@ def _normalize_content_part(part: dict[str, JsonValue], role: str = "user") -> J
if part_type in ("text", "input_text", "output_text"):
text = part.get("text")
if isinstance(text, str):
return {"type": text_type, "text": text}
# Preserve cache-related and metadata fields while normalizing
# text part type for the target role.
normalized = dict(part)
normalized["type"] = text_type
normalized["text"] = text
return normalized
return part
if role == "assistant":
return part
Expand Down
1 change: 1 addition & 0 deletions app/core/openai/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ class ResponsesRequest(BaseModel):
previous_response_id: str | None = None
truncation: str | None = None
prompt_cache_key: str | None = None
prompt_cache_retention: str | None = None
text: ResponsesTextControls | None = None

@field_validator("input")
Expand Down
1 change: 1 addition & 0 deletions app/core/openai/v1_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class V1ResponsesRequest(BaseModel):
previous_response_id: str | None = None
truncation: str | None = None
prompt_cache_key: str | None = None
prompt_cache_retention: str | None = None
text: ResponsesTextControls | None = None

@field_validator("input")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""add codex session/conversation hashes to request_logs

Revision ID: 013_add_request_logs_codex_session_hashes
Revises: 012_add_import_without_overwrite_and_drop_accounts_email_unique
Create Date: 2026-02-22
"""

from __future__ import annotations

import sqlalchemy as sa
from alembic import op
from sqlalchemy.engine import Connection

# revision identifiers, used by Alembic.
revision = "013_add_request_logs_codex_session_hashes"
down_revision = "012_add_import_without_overwrite_and_drop_accounts_email_unique"
branch_labels = None
depends_on = None


def _columns(connection: Connection, table_name: str) -> set[str]:
inspector = sa.inspect(connection)
if not inspector.has_table(table_name):
return set()
return {str(column["name"]) for column in inspector.get_columns(table_name) if column.get("name") is not None}


def upgrade() -> None:
bind = op.get_bind()
columns = _columns(bind, "request_logs")
if not columns:
return

with op.batch_alter_table("request_logs") as batch_op:
if "codex_session_hash" not in columns:
batch_op.add_column(sa.Column("codex_session_hash", sa.String(), nullable=True))
if "codex_conversation_hash" not in columns:
batch_op.add_column(sa.Column("codex_conversation_hash", sa.String(), nullable=True))


def downgrade() -> None:
bind = op.get_bind()
columns = _columns(bind, "request_logs")
if not columns:
return

with op.batch_alter_table("request_logs") as batch_op:
if "codex_conversation_hash" in columns:
batch_op.drop_column("codex_conversation_hash")
if "codex_session_hash" in columns:
batch_op.drop_column("codex_session_hash")
2 changes: 2 additions & 0 deletions app/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ class RequestLog(Base):
account_id: Mapped[str] = mapped_column(String, ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False)
api_key_id: Mapped[str | None] = mapped_column(String, nullable=True)
request_id: Mapped[str] = mapped_column(String, nullable=False)
codex_session_hash: Mapped[str | None] = mapped_column(String, nullable=True)
codex_conversation_hash: Mapped[str | None] = mapped_column(String, nullable=True)
requested_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
model: Mapped[str] = mapped_column(String, nullable=False)
input_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
Expand Down
12 changes: 12 additions & 0 deletions app/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from app.db.session import get_background_session, get_session
from app.modules.accounts.repository import AccountsRepository
from app.modules.accounts.service import AccountsService
from app.modules.anthropic_compat.service import AnthropicCompatService
from app.modules.api_keys.repository import ApiKeysRepository
from app.modules.api_keys.service import ApiKeysService
from app.modules.dashboard.repository import DashboardRepository
Expand Down Expand Up @@ -66,6 +67,11 @@ class ApiKeysContext:
service: ApiKeysService


@dataclass(slots=True)
class AnthropicCompatContext:
service: AnthropicCompatService


@dataclass(slots=True)
class RequestLogsContext:
session: AsyncSession
Expand Down Expand Up @@ -164,6 +170,12 @@ def get_api_keys_context(
return ApiKeysContext(session=session, repository=repository, service=service)


def get_anthropic_compat_context() -> AnthropicCompatContext:
proxy_service = ProxyService(repo_factory=_proxy_repo_context)
service = AnthropicCompatService(proxy_service)
return AnthropicCompatContext(service=service)


def get_request_logs_context(
session: AsyncSession = Depends(get_session),
) -> RequestLogsContext:
Expand Down
5 changes: 4 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from app.core.usage.refresh_scheduler import build_usage_refresh_scheduler
from app.db.session import close_db, init_db
from app.modules.accounts import api as accounts_api
from app.modules.anthropic_compat import api as anthropic_compat_api
from app.modules.api_keys import api as api_keys_api
from app.modules.dashboard import api as dashboard_api
from app.modules.dashboard_auth import api as dashboard_auth_api
Expand Down Expand Up @@ -66,6 +67,8 @@ def create_app() -> FastAPI:
app.include_router(proxy_api.router)
app.include_router(proxy_api.v1_router)
app.include_router(proxy_api.usage_router)
app.include_router(anthropic_compat_api.router)
app.include_router(anthropic_compat_api.anthropic_router)
app.include_router(accounts_api.router)
app.include_router(dashboard_api.router)
app.include_router(usage_api.router)
Expand All @@ -80,7 +83,7 @@ def create_app() -> FastAPI:
index_html = static_dir / "index.html"
static_root = static_dir.resolve()
frontend_build_hint = "Frontend assets are missing. Run `cd frontend && bun run build`."
excluded_prefixes = ("api/", "v1/", "backend-api/", "health")
excluded_prefixes = ("api/", "v1/", "backend-api/", "anthropic/", "health")

def _is_static_asset_path(path: str) -> bool:
if path.startswith("assets/"):
Expand Down
1 change: 1 addition & 0 deletions app/modules/anthropic_compat/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Loading
Loading