]> git.99rst.org Git - sgasser-llm-shield.git/commitdiff
Add Codex CLI support (#87)
authorStefan Gasser <redacted>
Thu, 4 Jun 2026 08:12:52 +0000 (10:12 +0200)
committerGitHub <redacted>
Thu, 4 Jun 2026 08:12:52 +0000 (10:12 +0200)
26 files changed:
AGENTS.md [new file with mode: 0644]
CLAUDE.md
README.md
config.example.yaml
docs/api-reference/codex.mdx [new file with mode: 0644]
docs/api-reference/status.mdx
docs/concepts/mask-mode.mdx
docs/concepts/secrets-detection.mdx
docs/configuration/logging.mdx
docs/configuration/overview.mdx
docs/configuration/providers.mdx
docs/configuration/secrets-detection.mdx
docs/introduction.mdx
docs/mint.json
docs/quickstart.mdx
docs/use-cases/coding-tools.mdx
src/config.test.ts [new file with mode: 0644]
src/config.ts
src/index.ts
src/masking/extractors/codex.ts [new file with mode: 0644]
src/routes/codex.test.ts [new file with mode: 0644]
src/routes/codex.ts [new file with mode: 0644]
src/routes/info.test.ts
src/routes/info.ts
src/routes/utils.ts
src/services/logger.ts

diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644 (file)
index 0000000..a1298da
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,52 @@
+# PasteGuard Agent Instructions
+
+## Project
+
+PasteGuard is a Bun/Hono privacy proxy for LLMs. It masks PII and secrets before forwarding requests to configured providers, then restores placeholders in responses where supported.
+
+Primary endpoints:
+
+- `POST /openai/v1/chat/completions`
+- `POST /anthropic/v1/messages`
+- `POST /codex/responses`
+- `GET /health`
+- `GET /info`
+
+## Stack
+
+- Runtime: Bun
+- Web framework: Hono
+- Validation: Zod
+- Styling: Tailwind CSS v4
+- Database: SQLite at `data/pasteguard.db`
+- PII detection: Microsoft Presidio
+- Formatting/linting: Biome
+
+## Commands
+
+- `bun run dev` - development server
+- `bun run start` - production server
+- `bun run build` - build to `dist/`
+- `bun test` - test suite
+- `bun run typecheck` - TypeScript check
+- `bun run check` - Biome lint and format check
+- `bun run format` - apply Biome formatting for `src/`
+
+## Workflow
+
+- Prefer existing route/provider/extractor patterns over new abstractions.
+- Add or update tests when changing masking, provider forwarding, logging, config parsing, or public endpoints.
+- Run `bun test`, `bun run typecheck`, and `bun run check` before handing off code changes.
+- Update README and docs when public endpoints, provider config, or user setup steps change.
+- Do not commit tracked `config.yaml` changes.
+- Do not create commits or push branches unless the user explicitly asks.
+
+## Architecture Pointers
+
+- `src/index.ts` mounts routes and prints startup provider info.
+- `src/config.ts` owns YAML config loading and defaults.
+- `src/routes/` contains HTTP route handlers.
+- `src/providers/` contains provider clients and stream transformers.
+- `src/masking/extractors/` contains provider-specific text extraction and insertion.
+- `src/services/logger.ts` owns SQLite dashboard logging.
+- `docs/mint.json` registers API reference docs.
index d932884135a392d1c34c49ad4e177f5a44f4f15f..43c994c2d3617f947bcb5adf1933e21dabe46bb5 100644 (file)
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,121 +1 @@
-# PasteGuard
-
-Privacy proxy for LLMs. Masks personal data and secrets before sending prompts to your provider (OpenAI, Anthropic, etc.).
-
-## Tech Stack
-
-- Runtime: Bun
-- Framework: Hono (with JSX for dashboard)
-- Validation: Zod
-- Styling: Tailwind CSS v4
-- Database: SQLite (`data/pasteguard.db`)
-- PII Detection: Microsoft Presidio (Docker)
-- Code Style: Biome (see @biome.json)
-
-## Architecture
-
-```
-src/
-├── index.ts                 # Hono server entry
-├── config.ts                # YAML config + Zod validation
-├── constants/               # Shared constants
-│   ├── languages.ts         # Supported languages
-│   └── timeouts.ts          # HTTP timeout values
-├── routes/
-│   ├── openai.ts            # /openai/v1/* (chat completions + wildcard proxy)
-│   ├── anthropic.ts         # /anthropic/v1/* (messages + wildcard proxy)
-│   ├── dashboard.tsx        # Dashboard routes + API
-│   ├── health.ts            # GET /health
-│   ├── info.ts              # GET /info
-│   └── utils.ts             # Shared route utilities
-├── providers/
-│   ├── errors.ts            # Shared provider errors
-│   ├── local.ts             # Local LLM client (Ollama/OpenAI-compatible)
-│   ├── openai/
-│   │   ├── client.ts        # OpenAI API client
-│   │   ├── stream-transformer.ts  # SSE unmasking for streaming
-│   │   └── types.ts         # OpenAI request/response types
-│   └── anthropic/
-│       ├── client.ts        # Anthropic API client
-│       ├── stream-transformer.ts  # SSE unmasking for streaming
-│       └── types.ts         # Anthropic request/response types
-├── masking/
-│   ├── service.ts           # Masking orchestration
-│   ├── context.ts           # Masking context management
-│   ├── placeholders.ts      # Placeholder generation
-│   ├── conflict-resolver.ts # Overlapping entity resolution
-│   ├── types.ts             # Shared masking types
-│   └── extractors/
-│       ├── openai.ts        # OpenAI text extraction/insertion
-│       └── anthropic.ts     # Anthropic text extraction/insertion
-├── pii/
-│   ├── detect.ts            # Presidio client
-│   └── mask.ts              # PII masking logic
-├── secrets/
-│   ├── detect.ts            # Secret detection
-│   ├── mask.ts              # Secret masking
-│   └── patterns/            # Secret pattern definitions
-├── services/
-│   ├── pii.ts               # PII detection service
-│   ├── secrets.ts           # Secrets processing service
-│   ├── language-detector.ts # Auto language detection
-│   └── logger.ts            # SQLite logging
-├── utils/
-│   └── content.ts           # Content utilities
-└── views/
-    └── dashboard/
-        └── page.tsx         # Dashboard UI
-```
-
-Tests are colocated (`*.test.ts`).
-
-## Modes
-
-Two modes configured in `config.yaml`:
-
-- **Route**: Routes PII-containing requests to local LLM (requires `local` provider config)
-- **Mask**: Masks PII before sending to configured provider, unmasks response (no local provider needed)
-
-See @config.example.yaml for full configuration.
-
-## Commands
-
-- `bun run dev` - Development (hot reload)
-- `bun run start` - Production
-- `bun run build` - Build to dist/
-- `bun test` - Run tests
-- `bun run typecheck` - Type check
-- `bun run lint` - Lint only
-- `bun run check` - Lint + format check
-- `bun run format` - Format code
-
-## Setup
-
-**Production:**
-```bash
-cp config.example.yaml config.yaml
-docker compose up -d
-```
-
-**Development:** Presidio in Docker, Bun locally with hot-reload:
-```bash
-docker compose up presidio -d
-bun run dev
-```
-
-**Multi-language:** Use EU image or build custom:
-```bash
-PASTEGUARD_TAG=eu docker compose up -d
-LANGUAGES=en,de,ja docker compose up -d --build
-```
-
-See @docker/presidio/languages.yaml for 24 available languages.
-
-## Testing
-
-- `GET /health` - Health check
-- `GET /info` - Mode info
-- `POST /openai/v1/chat/completions` - OpenAI endpoint
-- `POST /anthropic/v1/messages` - Anthropic endpoint
-
-Response header `X-PasteGuard-PII-Masked: true` indicates PII was masked.
+@AGENTS.md
index b0e6679ed1ef6c35ca9e50324182db83bcf9a549..7ad2d004aa29deff3bbd148bd5745a1b7db9fc7d 100644 (file)
--- a/README.md
+++ b/README.md
@@ -45,7 +45,7 @@
 
 **[Coding Tools](https://pasteguard.com/docs/use-cases/coding-tools)** — Cursor, Claude Code, Copilot, Windsurf — your codebase context flows to the provider. PasteGuard masks secrets and PII before they leave.
 
-**[API Integration](https://pasteguard.com/docs/use-cases/api-integration)** — Sits between your code and OpenAI or Anthropic. Change one URL, your users' data stays protected.
+**[API Integration](https://pasteguard.com/docs/use-cases/api-integration)** — Sits between your code and OpenAI-compatible or Anthropic APIs. Change one URL, your users' data stays protected.
 
 ## Quick Start
 
@@ -57,10 +57,11 @@ docker run --rm -p 3000:3000 ghcr.io/sgasser/pasteguard:en
 
 Point your tools or app to PasteGuard instead of the provider:
 
-| API | PasteGuard URL | Original URL |
+| Target | PasteGuard URL | Original URL |
 |----------|----------------|--------------|
 | OpenAI | `http://localhost:3000/openai/v1` | `https://api.openai.com/v1` |
 | Anthropic | `http://localhost:3000/anthropic` | `https://api.anthropic.com` |
+| Codex CLI | `http://localhost:3000/codex` | `https://chatgpt.com/backend-api/codex` |
 
 ```python
 # One line to protect your data
@@ -83,7 +84,7 @@ For custom config, persistent logs, or other languages: **[Read the docs →](ht
 <details>
 <summary><strong>Route Mode</strong></summary>
 
-Route Mode sends requests containing sensitive data to a local LLM (Ollama, vLLM, llama.cpp). Everything else goes to OpenAI or Anthropic. Sensitive data stays on your network.
+Route Mode sends requests containing sensitive data to a local LLM (Ollama, vLLM, llama.cpp). Everything else goes to the configured cloud provider. Sensitive data stays on your network.
 
 **[Route Mode docs →](https://pasteguard.com/docs/concepts/route-mode)**
 
@@ -113,6 +114,8 @@ ANTHROPIC_BASE_URL=http://localhost:3000/anthropic claude
 
 **Cursor:** Settings → Models → Enable "Override OpenAI Base URL" → `http://localhost:3000/openai/v1`
 
+**Codex CLI:** Configure a custom provider with `base_url = "http://127.0.0.1:3000/codex"`. See the coding tools docs for the full snippet.
+
 **[Coding Tools docs →](https://pasteguard.com/docs/use-cases/coding-tools)**
 
 ## Dashboard
index 07ebd73cc7f7ad7c04a1b36e8c2c324d31d0245b..a63bf0dc14551382d9d2a518d68649a5cabf4a0b 100644 (file)
@@ -14,7 +14,8 @@ server:
   # request_timeout: 600  # Seconds (0 = no timeout, default: 600)
 
 # Providers - API endpoints
-# Can be cloud (OpenAI, Anthropic, Azure) or self-hosted (vLLM, LiteLLM proxy, etc.)
+# Can be cloud (OpenAI, Anthropic, Azure) or self-hosted (vLLM, LiteLLM proxy, etc.).
+# Codex CLI uses the ChatGPT-login backend below.
 providers:
   # OpenAI-compatible endpoint (required)
   # The proxy forwards your client's Authorization header
@@ -28,6 +29,12 @@ providers:
     base_url: https://api.anthropic.com
     # api_key: ${ANTHROPIC_API_KEY}  # Optional fallback if client doesn't send auth header
 
+  # Codex ChatGPT-login endpoint
+  # /codex/responses receives PII/secrets protection; support endpoints like /codex/models are proxied
+  # The proxy forwards the Codex CLI Authorization header
+  codex:
+    base_url: https://chatgpt.com/backend-api/codex
+
 # Local provider - only used when mode: route
 # Supports: ollama (native), openai (for vLLM, LocalAI, LM Studio, etc.)
 local:
diff --git a/docs/api-reference/codex.mdx b/docs/api-reference/codex.mdx
new file mode 100644 (file)
index 0000000..80fad2a
--- /dev/null
@@ -0,0 +1,60 @@
+---
+title: Codex
+description: Protected proxy for Codex CLI ChatGPT-login traffic
+---
+
+Proxy Codex CLI requests through PasteGuard with masking on Responses calls.
+
+```
+/codex/*
+```
+
+<Note>
+`POST /codex/responses` receives PII and secrets protection and appears in the dashboard. Supporting endpoints such as `/codex/models` are proxied directly.
+</Note>
+
+In route mode, clean Codex Responses requests are forwarded to Codex. Requests containing PII or secrets configured with `route_local` are blocked because PasteGuard does not convert Codex Responses traffic to a local provider format.
+
+## Codex CLI
+
+Add a custom Codex model provider to `~/.codex/config.toml`:
+
+```toml
+model_provider = "pasteguard-codex"
+
+[model_providers.pasteguard-codex]
+name = "PasteGuard Codex"
+base_url = "http://127.0.0.1:3000/codex"
+wire_api = "responses"
+requires_openai_auth = true
+supports_websockets = false
+```
+
+Then run Codex normally:
+
+```bash
+codex exec --skip-git-repo-check 'Reply with exactly: ok'
+```
+
+## Upstream
+
+By default, Codex requests forward to:
+
+```yaml
+providers:
+  codex:
+    base_url: https://chatgpt.com/backend-api/codex
+```
+
+Examples:
+
+| PasteGuard URL | Upstream URL |
+|----------------|--------------|
+| `/codex/responses` | `https://chatgpt.com/backend-api/codex/responses` |
+| `/codex/models?client_version=0.128.0` | `https://chatgpt.com/backend-api/codex/models?client_version=0.128.0` |
+
+`POST /codex/responses` is inspected and logged. Other Codex endpoints are direct pass-through.
+
+## Authentication
+
+PasteGuard forwards the Codex CLI authorization header. This is different from `/openai/v1`, which is for OpenAI API-key traffic against `https://api.openai.com/v1`.
index cb7e8cd420f35ca5cd485a41abc792831d04f1b7..2735c134327cf98076173bb28a4f00ca4e05d196 100644 (file)
@@ -72,6 +72,12 @@ curl http://localhost:3000/info
   "providers": {
     "openai": {
       "base_url": "https://api.openai.com/v1"
+    },
+    "anthropic": {
+      "base_url": "https://api.anthropic.com"
+    },
+    "codex": {
+      "base_url": "https://chatgpt.com/backend-api/codex"
     }
   },
   "pii_detection": {
index d09133139d1fec457ffaad7d2fe4980bf348bd44..79e8a540dd41ae3cd5597978f8fd8c2c137f7653 100644 (file)
@@ -1,9 +1,9 @@
 ---
 title: Mask Mode
-description: Replace PII with placeholders before sending to OpenAI or Anthropic
+description: Replace PII with placeholders before sending to upstream AI services
 ---
 
-Mask mode replaces PII with placeholders before sending to OpenAI or Anthropic. The response is automatically unmasked before returning to you.
+Mask mode replaces PII with placeholders before sending to the upstream AI service. The response is automatically unmasked before returning to you.
 
 ## How It Works
 
@@ -15,10 +15,10 @@ Mask mode replaces PII with placeholders before sending to OpenAI or Anthropic.
     PasteGuard finds: `Dr. Sarah Chen` (PERSON), `sarah.chen@hospital.org` (EMAIL)
   </Step>
   <Step title="Masked request sent">
-    OpenAI/Anthropic receives: `"Write a follow-up email to [[PERSON_1]] ([[EMAIL_ADDRESS_1]])"`
+    The provider receives: `"Write a follow-up email to [[PERSON_1]] ([[EMAIL_ADDRESS_1]])"`
   </Step>
   <Step title="Response masked">
-    OpenAI/Anthropic responds: `"Dear [[PERSON_1]], Following up on our discussion..."`
+    The provider responds: `"Dear [[PERSON_1]], Following up on our discussion..."`
   </Step>
   <Step title="Response unmasked">
     You receive: `"Dear Dr. Sarah Chen, Following up on our discussion..."`
@@ -28,7 +28,7 @@ Mask mode replaces PII with placeholders before sending to OpenAI or Anthropic.
 ## When to Use
 
 - Simple setup without local infrastructure
-- Want to use OpenAI or Anthropic while protecting PII
+- Want to use cloud AI services while protecting PII
 
 ## Configuration
 
index 1592adb76ab05f9679680867862a8bb6dbdecbe2..749ddfc894f5256be2d456ffaf7a71df0beca0f9 100644 (file)
@@ -42,7 +42,7 @@ PasteGuard detects secrets before PII detection and can block, mask, or route re
 | Action | Description |
 |--------|-------------|
 | `mask` | Replace secrets with placeholders, restore in response (default) |
-| `block` | Return HTTP 400, request never reaches OpenAI or Anthropic |
+| `block` | Return HTTP 400, request never reaches the provider |
 | `route_local` | Route to local LLM (requires route mode) |
 
 ### Mask (Default)
@@ -62,7 +62,7 @@ secrets_detection:
   action: block
 ```
 
-Request is rejected with HTTP 400. The secret never reaches OpenAI or Anthropic.
+Request is rejected with HTTP 400. The secret never reaches the provider.
 
 ### Route to Local
 
index b2da33f55f860c67a8d221ca54dee65a88f6576b..a37155a232b982ca57e7e850e443ee1e4636edb8 100644 (file)
@@ -67,7 +67,7 @@ logging:
   log_masked_content: true
 ```
 
-Shows what was actually sent to OpenAI or Anthropic with PII replaced by placeholders.
+Shows what was actually sent upstream with PII replaced by placeholders.
 
 ### No Content
 
index 96130df58f8709a7bc31afc7aedc27c94c0957ce..4fe6a442b2f855c81407615ce4490a1a6f3a76a0 100644 (file)
@@ -19,8 +19,8 @@ mode: mask
 
 | Value | Description |
 |-------|-------------|
-| `mask` | Replace PII with placeholders, send to OpenAI or Anthropic, restore in response |
-| `route` | PII requests stay on your local LLM (Ollama, vLLM, llama.cpp), others go to OpenAI or Anthropic |
+| `mask` | Replace PII with placeholders, send to the upstream AI service, restore in response |
+| `route` | PII requests stay on your local LLM (Ollama, vLLM, llama.cpp), others go to the configured cloud provider |
 
 See [Mask Mode](/concepts/mask-mode) and [Route Mode](/concepts/route-mode) for details.
 
index cad0fca53557146fac749e3e70ff32d3b483e170..c206b223e678352e069cb7f8f7cc617e8eba97d4 100644 (file)
@@ -1,9 +1,9 @@
 ---
 title: Providers
-description: Configure OpenAI, Anthropic, and local LLM endpoints
+description: Configure OpenAI, Anthropic, local LLM, and Codex CLI endpoints
 ---
 
-Configure endpoints for OpenAI, Anthropic, and local LLMs.
+Configure endpoints for OpenAI-compatible APIs, Anthropic, local LLMs, and Codex CLI traffic.
 
 ## OpenAI Provider
 
@@ -74,6 +74,22 @@ providers:
 | `base_url` | Anthropic API endpoint |
 | `api_key` | Optional. Used if client doesn't send `x-api-key` header |
 
+## Codex CLI Backend
+
+Configure the Codex ChatGPT-login endpoint for `/codex/*` requests.
+
+```yaml
+providers:
+  codex:
+    base_url: https://chatgpt.com/backend-api/codex
+```
+
+| Option | Description |
+|--------|-------------|
+| `base_url` | Codex backend endpoint for ChatGPT-login Codex CLI traffic |
+
+PasteGuard forwards the Codex CLI `Authorization` header to the Codex backend. Use `/codex` for Codex users signed in with ChatGPT. Use `/openai/v1` only for OpenAI API-key traffic.
+
 ## Local LLM
 
 Required for route mode only. Your local LLM for PII requests.
@@ -131,7 +147,7 @@ local:
 
 ## API Key Handling
 
-PasteGuard forwards your client's authentication headers to OpenAI or Anthropic. You can optionally set `api_key` in config as a fallback:
+PasteGuard forwards your client's authentication headers to the configured upstream. You can optionally set `api_key` in config as a fallback for OpenAI and Anthropic. Codex CLI traffic uses the CLI's ChatGPT-login authorization header:
 
 ```yaml
 providers:
@@ -142,4 +158,7 @@ providers:
   anthropic:
     base_url: https://api.anthropic.com
     api_key: ${ANTHROPIC_API_KEY}  # Used if client doesn't send x-api-key
+
+  codex:
+    base_url: https://chatgpt.com/backend-api/codex
 ```
index 5525eaa43f47faf547a926a6931900ebc50cc288..acdf45d02f0d59a8382016b96abdc902f9adbc15 100644 (file)
@@ -29,7 +29,7 @@ secrets_detection:
 | Action | Description |
 |--------|-------------|
 | `mask` | Replace secrets with placeholders, restore in response (default) |
-| `block` | Return HTTP 400, request never reaches OpenAI or Anthropic |
+| `block` | Return HTTP 400, request never reaches the provider |
 | `route_local` | Route to local LLM (requires route mode) |
 
 ### Mask (Default)
index df58edcd14988c9ab796bc2c9cc14ed05f9fbabc..017f49def8d5b064e78e77feeac92ab963e237c8 100644 (file)
@@ -33,8 +33,8 @@ Detects 30+ types of sensitive data across 24 languages.
 
 | Mode | How it works |
 |------|--------------|
-| **Mask** | Replace PII with placeholders, send to OpenAI or Anthropic, restore in response |
-| **Route** | PII requests stay on your local LLM (Ollama, vLLM, llama.cpp), others go to OpenAI or Anthropic |
+| **Mask** | Replace PII with placeholders, send to the upstream AI service, restore in response |
+| **Route** | PII requests stay on your local LLM (Ollama, vLLM, llama.cpp), others go to the configured cloud provider |
 
 PasteGuard runs on your servers. Personal data never leaves your infrastructure, and the LLM provider never sees real names, emails, or secrets.
 
index 843125d2f4b55e2455a8bef21f5d988266358155..4ccdb25113c37d471930c8aeab957df349eab89d 100644 (file)
@@ -59,6 +59,7 @@
       "pages": [
         "api-reference/openai",
         "api-reference/anthropic",
+        "api-reference/codex",
         "api-reference/mask",
         "api-reference/status",
         "api-reference/dashboard-api"
index c00eab78be20dd70c6f3b0cd56de5ade1fbb113d..2b0fce3612f4330f500fdbc27a69914b1172ebad 100644 (file)
@@ -19,10 +19,11 @@ For custom configuration, European languages, or persistent logs, see [Installat
 
 Point your tools or SDKs to PasteGuard:
 
-| API | PasteGuard URL |
+| Target | PasteGuard URL |
 |----------|----------------|
 | OpenAI | `http://localhost:3000/openai/v1` |
 | Anthropic | `http://localhost:3000/anthropic` |
+| Codex CLI | `http://localhost:3000/codex` |
 
 ## 3. Verify It Works
 
@@ -49,7 +50,7 @@ Open `http://localhost:3000/dashboard` in your browser to see:
 
 - Request history
 - Detected PII entities
-- Masked content sent to OpenAI or Anthropic
+- Masked content sent to upstream APIs or Codex CLI
 
 <Frame>
   <img src="/images/dashboard.png" alt="PasteGuard Dashboard" />
index fcf10defa2745e3cdffdf15ce158b807ea4c69c2..c85a368221db47c2bb70ece2587d1922dfcdaa55 100644 (file)
@@ -26,6 +26,31 @@ To make it permanent, add to your shell profile:
 export ANTHROPIC_BASE_URL=http://localhost:3000/anthropic
 ```
 
+## Codex CLI
+
+Codex users signed in with ChatGPT should use PasteGuard's Codex endpoint, not the OpenAI API endpoint:
+
+```toml
+model_provider = "pasteguard-codex"
+
+[model_providers.pasteguard-codex]
+name = "PasteGuard Codex"
+base_url = "http://127.0.0.1:3000/codex"
+wire_api = "responses"
+requires_openai_auth = true
+supports_websockets = false
+```
+
+Run a smoke test:
+
+```bash
+codex exec --skip-git-repo-check 'Reply with exactly: ok'
+```
+
+<Note>
+Codex Responses requests are scanned, masked, unmasked, and shown in the dashboard. In route mode, sensitive Codex requests are blocked because PasteGuard does not route Codex Responses traffic to a local provider.
+</Note>
+
 ## Cursor
 
 1. Open **Settings** → **Models**
@@ -48,7 +73,8 @@ export ANTHROPIC_BASE_URL=http://localhost:3000/anthropic
 
 ## API Endpoints
 
-| API | PasteGuard URL |
+| Target | PasteGuard URL |
 |----------|----------------|
 | OpenAI | `http://localhost:3000/openai/v1` |
 | Anthropic | `http://localhost:3000/anthropic` |
+| Codex CLI | `http://localhost:3000/codex` |
diff --git a/src/config.test.ts b/src/config.test.ts
new file mode 100644 (file)
index 0000000..3a627f6
--- /dev/null
@@ -0,0 +1,58 @@
+import { describe, expect, test } from "bun:test";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { loadConfig } from "./config";
+
+function writeConfig(contents: string): string {
+  const dir = mkdtempSync(join(tmpdir(), "pasteguard-config-test-"));
+  const path = join(dir, "config.yaml");
+  writeFileSync(path, contents);
+  return path;
+}
+
+function cleanupConfig(path: string): void {
+  rmSync(path.replace(/\/config\.yaml$/, ""), { recursive: true, force: true });
+}
+
+describe("config", () => {
+  test("uses the default Codex provider base URL", () => {
+    const path = writeConfig(`
+mode: mask
+providers:
+  openai: {}
+  anthropic: {}
+pii_detection:
+  presidio_url: http://localhost:5002
+`);
+
+    try {
+      const config = loadConfig(path);
+
+      expect(config.providers.codex.base_url).toBe("https://chatgpt.com/backend-api/codex");
+    } finally {
+      cleanupConfig(path);
+    }
+  });
+
+  test("accepts a custom Codex provider base URL", () => {
+    const path = writeConfig(`
+mode: mask
+providers:
+  openai: {}
+  anthropic: {}
+  codex:
+    base_url: http://localhost:4000/codex
+pii_detection:
+  presidio_url: http://localhost:5002
+`);
+
+    try {
+      const config = loadConfig(path);
+
+      expect(config.providers.codex.base_url).toBe("http://localhost:4000/codex");
+    } finally {
+      cleanupConfig(path);
+    }
+  });
+});
index 29457c602946828223b838dde234f4877e356472..3f6ceb9cd8d3d0b4379f22d1a14a1e7e9fafb619 100644 (file)
@@ -25,6 +25,11 @@ const AnthropicProviderSchema = z.object({
   api_key: z.string().optional(), // Optional fallback if client doesn't send auth header
 });
 
+// Codex ChatGPT-login backend
+const CodexProviderSchema = z.object({
+  base_url: z.string().url().default("https://chatgpt.com/backend-api/codex"),
+});
+
 const DEFAULT_WHITELIST = ["You are Claude Code, Anthropic's official CLI for Claude."];
 
 const MaskingSchema = z.object({
@@ -123,6 +128,7 @@ const ConfigSchema = z
     providers: z.object({
       openai: OpenAIProviderSchema.default({}),
       anthropic: AnthropicProviderSchema.default({}),
+      codex: CodexProviderSchema.default({}),
     }),
     // Local provider - only for route mode
     local: LocalProviderSchema.optional(),
@@ -161,6 +167,7 @@ const ConfigSchema = z
 export type Config = z.infer<typeof ConfigSchema>;
 export type OpenAIProviderConfig = z.infer<typeof OpenAIProviderSchema>;
 export type AnthropicProviderConfig = z.infer<typeof AnthropicProviderSchema>;
+export type CodexProviderConfig = z.infer<typeof CodexProviderSchema>;
 export type LocalProviderConfig = z.infer<typeof LocalProviderSchema>;
 export type MaskingConfig = z.infer<typeof MaskingSchema>;
 export type SecretsDetectionConfig = z.infer<typeof SecretsDetectionSchema>;
index e0035baa1c00564c32100df7ca1fdc885a0a798d..79cf5fd9b6c01849c6a1789e4e7240cb6bf0d631 100644 (file)
@@ -7,6 +7,7 @@ import { getConfig } from "./config";
 import { getPIIDetector } from "./pii/detect";
 import { anthropicRoutes } from "./routes/anthropic";
 import { apiRoutes } from "./routes/api";
+import { codexRoutes } from "./routes/codex";
 import { dashboardRoutes } from "./routes/dashboard";
 import { healthRoutes } from "./routes/health";
 import { infoRoutes } from "./routes/info";
@@ -46,6 +47,7 @@ app.route("/", healthRoutes);
 app.route("/", infoRoutes);
 app.route("/openai", openaiRoutes);
 app.route("/anthropic", anthropicRoutes);
+app.route("/codex", codexRoutes);
 app.route("/api", apiRoutes);
 
 if (config.dashboard.enabled) {
@@ -159,13 +161,15 @@ Routing:
 
 Providers:
   OpenAI: ${config.providers.openai.base_url}
+  Codex:  ${config.providers.codex.base_url}
   Local:  ${config.local?.type || "not configured"} → ${config.local?.model || "n/a"}`
       : `
 Masking:
   Markers: ${config.masking.show_markers ? "enabled" : "disabled"}
 
-Provider:
-  OpenAI: ${config.providers.openai.base_url}`;
+Providers:
+  OpenAI: ${config.providers.openai.base_url}
+  Codex:  ${config.providers.codex.base_url}`;
 
   console.log(`
 ╔═══════════════════════════════════════════════════════════╗
@@ -176,6 +180,7 @@ Provider:
 Server:     http://${host}:${port}
 OpenAI API: http://${host}:${port}/openai/v1/chat/completions
 Anthropic:  http://${host}:${port}/anthropic/v1/messages
+Codex:      http://${host}:${port}/codex
 Mask API:   http://${host}:${port}/api/mask
 Health:     http://${host}:${port}/health
 Info:       http://${host}:${port}/info
diff --git a/src/masking/extractors/codex.ts b/src/masking/extractors/codex.ts
new file mode 100644 (file)
index 0000000..c6798f7
--- /dev/null
@@ -0,0 +1,120 @@
+import { type PlaceholderContext, restorePlaceholders } from "../../masking/context";
+import type { MaskedSpan, RequestExtractor, TextSpan } from "../types";
+
+export type CodexResponsesRequest = {
+  model?: string;
+  instructions?: string;
+  input?: unknown;
+  stream?: boolean;
+  [key: string]: unknown;
+};
+
+export type CodexResponsesResponse = Record<string, unknown>;
+
+const TEXT_KEYS = new Set([
+  "arguments",
+  "content",
+  "delta",
+  "input",
+  "input_text",
+  "instructions",
+  "output_text",
+  "text",
+]);
+
+interface LocatedString {
+  path: Array<string | number>;
+  value: string;
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function collectText(value: unknown, path: Array<string | number> = []): LocatedString[] {
+  if (typeof value === "string") {
+    const key = path[path.length - 1];
+    if (typeof key === "string" && TEXT_KEYS.has(key)) {
+      return [{ path, value }];
+    }
+    return [];
+  }
+
+  if (Array.isArray(value)) {
+    return value.flatMap((item, index) => collectText(item, [...path, index]));
+  }
+
+  if (isRecord(value)) {
+    return Object.entries(value).flatMap(([key, item]) => collectText(item, [...path, key]));
+  }
+
+  return [];
+}
+
+function setAtPath<T>(value: T, path: Array<string | number>, nextValue: string): T {
+  if (path.length === 0) return nextValue as T;
+
+  const [head, ...tail] = path;
+
+  if (Array.isArray(value)) {
+    const copy = [...value];
+    copy[head as number] = setAtPath(copy[head as number], tail, nextValue);
+    return copy as T;
+  }
+
+  if (isRecord(value)) {
+    return {
+      ...value,
+      [head]: setAtPath(value[head as string], tail, nextValue),
+    } as T;
+  }
+
+  return value;
+}
+
+function pathToString(path: Array<string | number>): string {
+  return path
+    .map((part, index) =>
+      typeof part === "number" ? `[${part}]` : index === 0 ? part : `.${part}`,
+    )
+    .join("");
+}
+
+function pathFromString(path: string): Array<string | number> {
+  const result: Array<string | number> = [];
+  for (const part of path.matchAll(/([^.[\]]+)|\[(\d+)\]/g)) {
+    result.push(part[1] ?? Number(part[2]));
+  }
+  return result;
+}
+
+export const codexExtractor: RequestExtractor<CodexResponsesRequest, CodexResponsesResponse> = {
+  extractTexts(request: CodexResponsesRequest): TextSpan[] {
+    return collectText(request).map((item, index) => ({
+      text: item.value,
+      path: pathToString(item.path),
+      messageIndex: index,
+      partIndex: 0,
+      role: item.path.includes("instructions") ? "system" : "user",
+    }));
+  },
+
+  applyMasked(request: CodexResponsesRequest, maskedSpans: MaskedSpan[]): CodexResponsesRequest {
+    return maskedSpans.reduce(
+      (current, span) => setAtPath(current, pathFromString(span.path), span.maskedText),
+      request,
+    );
+  },
+
+  unmaskResponse(
+    response: CodexResponsesResponse,
+    context: PlaceholderContext,
+    formatValue?: (original: string) => string,
+  ): CodexResponsesResponse {
+    let result = response;
+    for (const item of collectText(response)) {
+      result = setAtPath(result, item.path, restorePlaceholders(item.value, context, formatValue));
+    }
+    return result;
+  },
+};
diff --git a/src/routes/codex.test.ts b/src/routes/codex.test.ts
new file mode 100644 (file)
index 0000000..bc05247
--- /dev/null
@@ -0,0 +1,316 @@
+import { afterEach, describe, expect, mock, test } from "bun:test";
+import { Hono } from "hono";
+import { getConfig } from "../config";
+import { filterWhitelistedEntities, type PIIDetectionResult, PIIDetector } from "../pii/detect";
+
+const mockAnalyzeRequest = mock<() => Promise<PIIDetectionResult>>(() =>
+  Promise.resolve({
+    hasPII: false,
+    spanEntities: [],
+    allEntities: [],
+    scanTimeMs: 0,
+    language: "en",
+    languageFallback: false,
+  }),
+);
+const mockLogRequest = mock(() => {});
+
+mock.module("../pii/detect", () => ({
+  PIIDetector,
+  filterWhitelistedEntities,
+  getPIIDetector: () => ({
+    analyzeRequest: mockAnalyzeRequest,
+    detectPII: mock(() => Promise.resolve([])),
+    healthCheck: mock(() => Promise.resolve(true)),
+    getLanguageValidation: mock(() => undefined),
+  }),
+}));
+
+mock.module("../services/logger", () => ({
+  logRequest: mockLogRequest,
+}));
+
+const { codexRoutes } = await import("./codex");
+
+const app = new Hono();
+app.route("/codex", codexRoutes);
+
+const originalFetch = globalThis.fetch;
+const config = getConfig();
+const originalMode = config.mode;
+const originalSecretsAction = config.secrets_detection.action;
+
+interface CapturedRequest {
+  url: string;
+  method: string;
+  headers: Headers;
+  body: string;
+}
+
+afterEach(() => {
+  globalThis.fetch = originalFetch;
+  config.mode = originalMode;
+  config.secrets_detection.action = originalSecretsAction;
+  mockAnalyzeRequest.mockResolvedValue({
+    hasPII: false,
+    spanEntities: [],
+    allEntities: [],
+    scanTimeMs: 0,
+    language: "en",
+    languageFallback: false,
+  });
+  mockLogRequest.mockClear();
+});
+
+describe("Codex proxy", () => {
+  test("inspects and forwards POST /codex/responses to the configured Codex upstream", async () => {
+    const calls: CapturedRequest[] = [];
+    globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
+      const request = input instanceof Request ? input : new Request(input, init);
+      calls.push({
+        url: request.url,
+        method: request.method,
+        headers: new Headers(request.headers),
+        body: await request.clone().text(),
+      });
+      return Promise.resolve(new Response("ok", { status: 200 }));
+    }) as typeof fetch;
+
+    const res = await app.request("/codex/responses", {
+      method: "POST",
+      body: JSON.stringify({ model: "gpt-5.5", input: "Reply ok", stream: true }),
+      headers: {
+        Authorization: "Bearer chatgpt-token",
+        "Content-Type": "application/json",
+      },
+    });
+
+    expect(res.status).toBe(200);
+    expect(calls).toHaveLength(1);
+    expect(calls[0].url).toBe(
+      `${getConfig().providers.codex.base_url.replace(/\/$/, "")}/responses`,
+    );
+    expect(calls[0].method).toBe("POST");
+    expect(calls[0].headers.get("authorization")).toBe("Bearer chatgpt-token");
+    expect(JSON.parse(calls[0].body)).toEqual({
+      model: "gpt-5.5",
+      input: "Reply ok",
+      stream: true,
+    });
+    expect(mockLogRequest).toHaveBeenCalled();
+  });
+
+  test("masks PII in POST /codex/responses and logs it for the dashboard", async () => {
+    mockAnalyzeRequest.mockResolvedValueOnce({
+      hasPII: true,
+      spanEntities: [[{ entity_type: "EMAIL_ADDRESS", start: 6, end: 22, score: 0.99 }]],
+      allEntities: [{ entity_type: "EMAIL_ADDRESS", start: 6, end: 22, score: 0.99 }],
+      scanTimeMs: 3,
+      language: "en",
+      languageFallback: false,
+    });
+
+    const calls: CapturedRequest[] = [];
+    globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
+      const request = input instanceof Request ? input : new Request(input, init);
+      calls.push({
+        url: request.url,
+        method: request.method,
+        headers: new Headers(request.headers),
+        body: await request.clone().text(),
+      });
+      return Promise.resolve(
+        new Response("data: {}\n\n", {
+          status: 200,
+          headers: { "Content-Type": "text/event-stream" },
+        }),
+      );
+    }) as typeof fetch;
+
+    const res = await app.request("/codex/responses", {
+      method: "POST",
+      body: JSON.stringify({ model: "gpt-5.5", input: "Email john@example.com" }),
+      headers: {
+        Authorization: "Bearer chatgpt-token",
+        "Content-Type": "application/json",
+      },
+    });
+
+    expect(res.status).toBe(200);
+    expect(res.headers.get("X-PasteGuard-Provider")).toBe("codex");
+    expect(res.headers.get("X-PasteGuard-PII-Detected")).toBe("true");
+    expect(res.headers.get("X-PasteGuard-PII-Masked")).toBe("true");
+    expect(JSON.parse(calls[0].body)).toEqual({
+      model: "gpt-5.5",
+      input: "Email [[EMAIL_ADDRESS_1]]",
+    });
+    expect(mockLogRequest).toHaveBeenCalledWith(
+      expect.objectContaining({
+        provider: "codex",
+        statusCode: 200,
+      }),
+      null,
+    );
+  });
+
+  test("blocks sensitive Codex requests in route mode instead of forwarding them", async () => {
+    config.mode = "route";
+    mockAnalyzeRequest.mockResolvedValueOnce({
+      hasPII: true,
+      spanEntities: [[{ entity_type: "EMAIL_ADDRESS", start: 6, end: 22, score: 0.99 }]],
+      allEntities: [{ entity_type: "EMAIL_ADDRESS", start: 6, end: 22, score: 0.99 }],
+      scanTimeMs: 3,
+      language: "en",
+      languageFallback: false,
+    });
+
+    let fetchCalled = false;
+    globalThis.fetch = (async (_input: string | URL | Request, _init?: RequestInit) => {
+      fetchCalled = true;
+      return Promise.resolve(new Response("unexpected"));
+    }) as typeof fetch;
+
+    const res = await app.request("/codex/responses", {
+      method: "POST",
+      body: JSON.stringify({ model: "gpt-5.5", input: "Email john@example.com" }),
+      headers: {
+        Authorization: "Bearer chatgpt-token",
+        "Content-Type": "application/json",
+      },
+    });
+
+    expect(res.status).toBe(400);
+    expect(fetchCalled).toBe(false);
+    expect(res.headers.get("X-PasteGuard-Mode")).toBe("route");
+    expect(res.headers.get("X-PasteGuard-Provider")).toBe("codex");
+    expect(res.headers.get("X-PasteGuard-PII-Detected")).toBe("true");
+    const body = (await res.json()) as { error: { code: string } };
+    expect(body.error.code).toBe("route_mode_not_supported");
+    expect(mockLogRequest).toHaveBeenCalledTimes(1);
+  });
+
+  test("unmasks JSON responses when Codex returns non-streaming output", async () => {
+    mockAnalyzeRequest.mockResolvedValueOnce({
+      hasPII: true,
+      spanEntities: [[{ entity_type: "EMAIL_ADDRESS", start: 6, end: 22, score: 0.99 }]],
+      allEntities: [{ entity_type: "EMAIL_ADDRESS", start: 6, end: 22, score: 0.99 }],
+      scanTimeMs: 3,
+      language: "en",
+      languageFallback: false,
+    });
+
+    globalThis.fetch = (async (_input: string | URL | Request, _init?: RequestInit) =>
+      Promise.resolve(
+        Response.json({
+          output: [
+            {
+              content: [{ type: "output_text", text: "Email [[EMAIL_ADDRESS_1]]" }],
+            },
+          ],
+        }),
+      )) as typeof fetch;
+
+    const res = await app.request("/codex/responses", {
+      method: "POST",
+      body: JSON.stringify({ model: "gpt-5.5", input: "Email john@example.com" }),
+      headers: {
+        Authorization: "Bearer chatgpt-token",
+        "Content-Type": "application/json",
+      },
+    });
+
+    expect(res.status).toBe(200);
+    expect(await res.json()).toEqual({
+      output: [
+        {
+          content: [{ type: "output_text", text: "Email john@example.com" }],
+        },
+      ],
+    });
+  });
+
+  test("unmasks streaming JSON without breaking escaped response text", async () => {
+    const secret =
+      "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAtest\n-----END RSA PRIVATE KEY-----";
+
+    globalThis.fetch = (async (_input: string | URL | Request, _init?: RequestInit) =>
+      Promise.resolve(
+        new Response(
+          'data: {"type":"response.output_text.delta","delta":"Key [[PEM_PRIVATE_KEY_1]]"}\n\n',
+          {
+            status: 200,
+            headers: { "Content-Type": "text/event-stream" },
+          },
+        ),
+      )) as typeof fetch;
+
+    const res = await app.request("/codex/responses", {
+      method: "POST",
+      body: JSON.stringify({ model: "gpt-5.5", input: `Key ${secret}` }),
+      headers: {
+        Authorization: "Bearer chatgpt-token",
+        "Content-Type": "application/json",
+      },
+    });
+
+    expect(res.status).toBe(200);
+    const text = await res.text();
+    const dataLine = text.split("\n").find((line) => line.startsWith("data: "));
+    expect(dataLine).toBeDefined();
+    const parsed = JSON.parse(dataLine!.slice(6)) as { delta: string };
+    expect(parsed.delta).toBe(`Key ${secret}`);
+  });
+
+  test("logs only an error when a non-streaming Codex response is invalid JSON", async () => {
+    const originalConsoleError = console.error;
+    console.error = mock(() => {}) as typeof console.error;
+    globalThis.fetch = (async (_input: string | URL | Request, _init?: RequestInit) =>
+      Promise.resolve(
+        new Response("{", {
+          status: 200,
+          headers: { "Content-Type": "application/json" },
+        }),
+      )) as typeof fetch;
+
+    try {
+      const res = await app.request("/codex/responses", {
+        method: "POST",
+        body: JSON.stringify({ model: "gpt-5.5", input: "Reply ok" }),
+        headers: {
+          Authorization: "Bearer chatgpt-token",
+          "Content-Type": "application/json",
+        },
+      });
+
+      expect(res.status).toBe(502);
+      expect(mockLogRequest).toHaveBeenCalledTimes(1);
+    } finally {
+      console.error = originalConsoleError;
+    }
+  });
+
+  test("preserves query strings for model refresh requests", async () => {
+    const calls: CapturedRequest[] = [];
+    globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
+      const request = input instanceof Request ? input : new Request(input, init);
+      calls.push({
+        url: request.url,
+        method: request.method,
+        headers: new Headers(request.headers),
+        body: await request.clone().text(),
+      });
+      return Promise.resolve(new Response(JSON.stringify({ data: [] }), { status: 200 }));
+    }) as typeof fetch;
+
+    const res = await app.request("/codex/models?client_version=0.128.0", {
+      headers: {
+        Authorization: "Bearer chatgpt-token",
+      },
+    });
+
+    expect(res.status).toBe(200);
+    expect(calls[0].url).toBe(
+      `${getConfig().providers.codex.base_url.replace(/\/$/, "")}/models?client_version=0.128.0`,
+    );
+  });
+});
diff --git a/src/routes/codex.ts b/src/routes/codex.ts
new file mode 100644 (file)
index 0000000..be85074
--- /dev/null
@@ -0,0 +1,532 @@
+import { zValidator } from "@hono/zod-validator";
+import type { Context } from "hono";
+import { Hono } from "hono";
+import { proxy } from "hono/proxy";
+import { z } from "zod";
+import { getConfig, type MaskingConfig } from "../config";
+import type { PlaceholderContext } from "../masking/context";
+import {
+  type CodexResponsesRequest,
+  type CodexResponsesResponse,
+  codexExtractor,
+} from "../masking/extractors/codex";
+import {
+  flushMaskingBuffer,
+  unmaskResponse as unmaskPIIResponse,
+  unmaskStreamChunk,
+} from "../pii/mask";
+import { ProviderError } from "../providers/errors";
+import {
+  flushSecretsMaskingBuffer,
+  unmaskSecretsResponse,
+  unmaskSecretsStreamChunk,
+} from "../secrets/mask";
+import { logRequest } from "../services/logger";
+import { detectPII, maskPII, type PIIDetectResult } from "../services/pii";
+import { processSecretsRequest, type SecretsProcessResult } from "../services/secrets";
+import {
+  createLogData,
+  errorFormats,
+  handleProviderError,
+  setBlockedHeaders,
+  setResponseHeaders,
+  toPIIHeaderData,
+  toPIILogData,
+  toSecretsHeaderData,
+  toSecretsLogData,
+} from "./utils";
+
+export const codexRoutes = new Hono();
+
+const CodexResponsesRequestSchema = z
+  .object({
+    model: z.string().optional(),
+    instructions: z.string().optional(),
+    input: z.unknown().optional(),
+    stream: z.boolean().optional(),
+  })
+  .passthrough();
+
+/**
+ * POST /responses
+ *
+ * Inspected Codex Responses route. This mirrors the OpenAI/Anthropic protected
+ * endpoints: detect secrets/PII, mask before upstream, unmask streamed response,
+ * and log the request in the dashboard.
+ */
+codexRoutes.post(
+  "/responses",
+  zValidator("json", CodexResponsesRequestSchema, (result, c) => {
+    if (!result.success) {
+      return c.json(
+        errorFormats.openai.error(
+          `Invalid request body: ${result.error.message}`,
+          "invalid_request_error",
+        ),
+        400,
+      );
+    }
+  }),
+  async (c) => {
+    const startTime = Date.now();
+    let request = c.req.valid("json") as CodexResponsesRequest;
+    const config = getConfig();
+
+    const secretsResult = processSecretsRequest(request, config.secrets_detection, codexExtractor);
+    if (secretsResult.blocked) {
+      return respondBlocked(c, request, secretsResult, startTime);
+    }
+    if (secretsResult.masked) {
+      request = secretsResult.request;
+    }
+
+    let piiResult: PIIDetectResult;
+    if (!config.pii_detection.enabled) {
+      piiResult = {
+        detection: {
+          hasPII: false,
+          spanEntities: [],
+          allEntities: [],
+          scanTimeMs: 0,
+          language: config.pii_detection.fallback_language,
+          languageFallback: false,
+        },
+        hasPII: false,
+      };
+    } else {
+      try {
+        piiResult = await detectPII(request, codexExtractor);
+      } catch (error) {
+        console.error("PII detection error:", error);
+        return respondDetectionError(c, request, startTime);
+      }
+    }
+
+    const shouldBlockRouteMode =
+      config.mode === "route" &&
+      (piiResult.hasPII ||
+        (secretsResult.detection?.detected && config.secrets_detection.action === "route_local"));
+
+    if (shouldBlockRouteMode) {
+      return respondRouteModeBlocked(c, request, piiResult, secretsResult, startTime);
+    }
+
+    const piiMasked =
+      config.mode === "mask" ? maskPII(request, piiResult.detection, codexExtractor) : undefined;
+
+    return sendToCodex(c, request, {
+      request: piiMasked?.request ?? request,
+      piiResult,
+      piiMaskingContext: piiMasked?.maskingContext,
+      secretsResult,
+      startTime,
+      headers: getForwardHeaders(c),
+    });
+  },
+);
+
+/**
+ * Wildcard pass-through proxy for /models and any future Codex endpoints that do
+ * not carry prompt content.
+ */
+codexRoutes.all("/*", (c) => {
+  const config = getConfig();
+  const normalizedBaseUrl = config.providers.codex.base_url.replace(/\/$/, "");
+  const path = c.req.path.replace(/^\/codex/, "");
+  const query = c.req.url.includes("?") ? c.req.url.slice(c.req.url.indexOf("?")) : "";
+
+  return proxy(`${normalizedBaseUrl}${path}${query}`, {
+    ...c.req,
+    headers: {
+      ...c.req.header(),
+      "X-Forwarded-Host": c.req.header("host"),
+      host: undefined,
+    },
+  });
+});
+
+async function callCodex(
+  request: CodexResponsesRequest,
+  baseUrl: string,
+  headers: Record<string, string>,
+): Promise<Response> {
+  const endpoint = `${baseUrl.replace(/\/$/, "")}/responses`;
+  const timeoutMs = getConfig().server.request_timeout * 1000;
+
+  const response = await fetch(endpoint, {
+    method: "POST",
+    headers: {
+      ...headers,
+      "Content-Type": "application/json",
+    },
+    body: JSON.stringify(request),
+    signal: timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined,
+  });
+
+  if (!response.ok) {
+    throw new ProviderError(response.status, response.statusText, await response.text());
+  }
+
+  return response;
+}
+
+interface CodexOptions {
+  request: CodexResponsesRequest;
+  piiResult: PIIDetectResult;
+  piiMaskingContext?: PlaceholderContext;
+  secretsResult: SecretsProcessResult<CodexResponsesRequest>;
+  startTime: number;
+  headers: Record<string, string>;
+}
+
+function getForwardHeaders(c: Context): Record<string, string> {
+  const headers: Record<string, string> = {};
+  for (const [key, value] of Object.entries(c.req.header())) {
+    const lower = key.toLowerCase();
+    if (lower === "host" || lower === "content-length" || lower === "content-type") continue;
+    headers[key] = value;
+  }
+  headers["X-Forwarded-Host"] = c.req.header("host") || "";
+  return headers;
+}
+
+function formatCodexForLog(request: CodexResponsesRequest): string | undefined {
+  const spans = codexExtractor.extractTexts(request).filter((span) => span.role !== "system");
+  if (spans.length === 0) return undefined;
+
+  return spans
+    .map((span) => `[${span.role || "unknown"} ${span.path}] ${span.text}`)
+    .join("\n")
+    .slice(0, 20000);
+}
+
+function respondBlocked(
+  c: Context,
+  body: CodexResponsesRequest,
+  secretsResult: SecretsProcessResult<CodexResponsesRequest>,
+  startTime: number,
+) {
+  const secretTypes = secretsResult.blockedTypes ?? [];
+
+  setBlockedHeaders(c, secretTypes);
+
+  logRequest(
+    createLogData({
+      provider: "codex",
+      model: body.model || "unknown",
+      startTime,
+      secrets: { detected: true, types: secretTypes, masked: false },
+      statusCode: 400,
+      errorMessage: secretsResult.blockedReason,
+    }),
+    c.req.header("User-Agent") || null,
+  );
+
+  return c.json(
+    errorFormats.openai.error(
+      `Request blocked: detected secret material (${secretTypes.join(",")}). Remove secrets and retry.`,
+      "invalid_request_error",
+      "secrets_detected",
+    ),
+    400,
+  );
+}
+
+function respondDetectionError(c: Context, body: CodexResponsesRequest, startTime: number) {
+  logRequest(
+    createLogData({
+      provider: "codex",
+      model: body.model || "unknown",
+      startTime,
+      statusCode: 503,
+      errorMessage: "Detection service unavailable",
+    }),
+    c.req.header("User-Agent") || null,
+  );
+
+  return c.json(
+    errorFormats.openai.error(
+      "Detection service unavailable",
+      "server_error",
+      "service_unavailable",
+    ),
+    503,
+  );
+}
+
+function respondRouteModeBlocked(
+  c: Context,
+  body: CodexResponsesRequest,
+  piiResult: PIIDetectResult,
+  secretsResult: SecretsProcessResult<CodexResponsesRequest>,
+  startTime: number,
+) {
+  const message =
+    "Codex route mode cannot route sensitive requests to a local provider. Use mask mode or remove sensitive data.";
+
+  setResponseHeaders(
+    c,
+    "route",
+    "codex",
+    toPIIHeaderData(piiResult),
+    toSecretsHeaderData(secretsResult),
+  );
+
+  logRequest(
+    createLogData({
+      provider: "codex",
+      model: body.model || "unknown",
+      startTime,
+      pii: toPIILogData(piiResult),
+      secrets: toSecretsLogData(secretsResult),
+      statusCode: 400,
+      errorMessage: message,
+    }),
+    c.req.header("User-Agent") || null,
+  );
+
+  return c.json(
+    errorFormats.openai.error(message, "invalid_request_error", "route_mode_not_supported"),
+    400,
+  );
+}
+
+async function sendToCodex(c: Context, originalRequest: CodexResponsesRequest, opts: CodexOptions) {
+  const config = getConfig();
+  const { request, piiResult, piiMaskingContext, secretsResult, startTime, headers } = opts;
+  const maskedContent =
+    piiResult.hasPII || secretsResult.masked ? formatCodexForLog(request) : undefined;
+
+  setResponseHeaders(
+    c,
+    config.mode,
+    "codex",
+    toPIIHeaderData(piiResult),
+    toSecretsHeaderData(secretsResult),
+  );
+
+  try {
+    const response = await callCodex(request, config.providers.codex.base_url, headers);
+
+    const contentType = response.headers.get("content-type") || "";
+    if (contentType.includes("text/event-stream") || request.stream === true) {
+      if (!response.body) {
+        throw new Error("No response body for streaming request");
+      }
+      logCodexSuccess(c, originalRequest, startTime, piiResult, secretsResult, maskedContent);
+      return respondStreaming(
+        c,
+        response.body,
+        piiMaskingContext,
+        secretsResult.maskingContext,
+        config.masking,
+      );
+    }
+
+    const responseBody = (await response.json()) as CodexResponsesResponse;
+    logCodexSuccess(c, originalRequest, startTime, piiResult, secretsResult, maskedContent);
+
+    return respondJson(
+      c,
+      responseBody,
+      piiMaskingContext,
+      secretsResult.maskingContext,
+      config.masking,
+    );
+  } catch (error) {
+    return handleProviderError(
+      c,
+      error,
+      {
+        provider: "codex",
+        model: originalRequest.model || "unknown",
+        startTime,
+        pii: toPIILogData(piiResult),
+        secrets: toSecretsLogData(secretsResult),
+        maskedContent,
+        userAgent: c.req.header("User-Agent") || null,
+      },
+      (msg) => errorFormats.openai.error(msg, "server_error", "upstream_error"),
+    );
+  }
+}
+
+function logCodexSuccess(
+  c: Context,
+  originalRequest: CodexResponsesRequest,
+  startTime: number,
+  piiResult: PIIDetectResult,
+  secretsResult: SecretsProcessResult<CodexResponsesRequest>,
+  maskedContent?: string,
+) {
+  logRequest(
+    createLogData({
+      provider: "codex",
+      model: originalRequest.model || "unknown",
+      startTime,
+      pii: toPIILogData(piiResult),
+      secrets: toSecretsLogData(secretsResult),
+      maskedContent,
+      statusCode: 200,
+    }),
+    c.req.header("User-Agent") || null,
+  );
+}
+
+function respondStreaming(
+  c: Context,
+  stream: ReadableStream<Uint8Array>,
+  piiContext?: PlaceholderContext,
+  secretsContext?: PlaceholderContext,
+  maskingConfig = getConfig().masking,
+) {
+  c.header("Content-Type", "text/event-stream");
+  c.header("Cache-Control", "no-cache");
+  c.header("Connection", "keep-alive");
+
+  if (piiContext || secretsContext) {
+    return c.body(createCodexUnmaskingStream(stream, piiContext, maskingConfig, secretsContext));
+  }
+
+  return c.body(stream);
+}
+
+function respondJson(
+  c: Context,
+  response: CodexResponsesResponse,
+  piiContext?: PlaceholderContext,
+  secretsContext?: PlaceholderContext,
+  maskingConfig = getConfig().masking,
+) {
+  let result = response;
+
+  if (piiContext) {
+    result = unmaskPIIResponse(result, piiContext, maskingConfig, codexExtractor);
+  }
+  if (secretsContext) {
+    result = unmaskSecretsResponse(result, secretsContext, codexExtractor);
+  }
+
+  return c.json(result);
+}
+
+function createCodexUnmaskingStream(
+  stream: ReadableStream<Uint8Array>,
+  piiContext: PlaceholderContext | undefined,
+  maskingConfig: MaskingConfig,
+  secretsContext?: PlaceholderContext,
+): ReadableStream<Uint8Array> {
+  const decoder = new TextDecoder();
+  const encoder = new TextEncoder();
+  let piiBuffer = "";
+  let secretsBuffer = "";
+  let lineBuffer = "";
+
+  function unmaskPayload(payload: unknown): unknown {
+    let result = payload as CodexResponsesResponse;
+
+    if (piiContext) {
+      const spans = codexExtractor.extractTexts(result);
+      result = codexExtractor.applyMasked(
+        result,
+        spans.map((span) => {
+          const { output, remainingBuffer } = unmaskStreamChunk(
+            piiBuffer,
+            span.text,
+            piiContext,
+            maskingConfig,
+          );
+          piiBuffer = remainingBuffer;
+          return { ...span, maskedText: output };
+        }),
+      );
+    }
+
+    if (secretsContext) {
+      const spans = codexExtractor.extractTexts(result);
+      result = codexExtractor.applyMasked(
+        result,
+        spans.map((span) => {
+          const { output, remainingBuffer } = unmaskSecretsStreamChunk(
+            secretsBuffer,
+            span.text,
+            secretsContext,
+          );
+          secretsBuffer = remainingBuffer;
+          return { ...span, maskedText: output };
+        }),
+      );
+    }
+
+    return result;
+  }
+
+  function processLine(line: string): string {
+    if (!line.startsWith("data: ")) {
+      return `${line}\n`;
+    }
+
+    const data = line.slice(6);
+    if (data === "[DONE]") {
+      return "data: [DONE]\n";
+    }
+
+    try {
+      return `data: ${JSON.stringify(unmaskPayload(JSON.parse(data)))}\n`;
+    } catch {
+      return `${line}\n`;
+    }
+  }
+
+  return new ReadableStream({
+    async start(controller) {
+      const reader = stream.getReader();
+
+      try {
+        while (true) {
+          const { done, value } = await reader.read();
+          if (done) break;
+
+          lineBuffer += decoder.decode(value, { stream: true });
+          const lines = lineBuffer.split("\n");
+          lineBuffer = lines.pop() ?? "";
+
+          let output = "";
+          for (const line of lines) {
+            output += processLine(line);
+          }
+
+          if (output) {
+            controller.enqueue(encoder.encode(output));
+          }
+        }
+
+        lineBuffer += decoder.decode();
+        let finalOutput = lineBuffer ? processLine(lineBuffer) : "";
+        lineBuffer = "";
+
+        if (piiContext && piiBuffer) {
+          finalOutput += `data: ${JSON.stringify({
+            type: "response.output_text.delta",
+            delta: flushMaskingBuffer(piiBuffer, piiContext, maskingConfig),
+          })}\n\n`;
+        }
+        if (secretsContext && secretsBuffer) {
+          finalOutput += `data: ${JSON.stringify({
+            type: "response.output_text.delta",
+            delta: flushSecretsMaskingBuffer(secretsBuffer, secretsContext),
+          })}\n\n`;
+        }
+        if (finalOutput) {
+          controller.enqueue(encoder.encode(finalOutput));
+        }
+
+        controller.close();
+      } catch (error) {
+        controller.error(error);
+      } finally {
+        reader.releaseLock();
+      }
+    },
+  });
+}
index 121a38002834467f235de0900180ab8245ffdb23..b4aa8f00c2292e3d713752eced6dc99f6075581a 100644 (file)
@@ -16,6 +16,10 @@ describe("GET /info", () => {
     expect(body.version).toMatch(/^\d+\.\d+\.\d+$/);
     expect(body.mode).toBeDefined();
     expect(body.providers).toBeDefined();
+    expect(body.providers).toHaveProperty("codex");
+    expect(
+      ((body.providers as Record<string, { base_url: string }>).codex.base_url as string).length,
+    ).toBeGreaterThan(0);
     expect(body.pii_detection).toBeDefined();
   });
 
index eb01fcb04b2213cb7450b425d22c4d04e5eff761..5f06cd1d795ccfbc464443dac45dbdf0ba5abb20 100644 (file)
@@ -20,6 +20,9 @@ infoRoutes.get("/info", (c) => {
     anthropic: {
       base_url: getAnthropicInfo(config.providers.anthropic).baseUrl,
     },
+    codex: {
+      base_url: config.providers.codex.base_url,
+    },
   };
 
   const info: Record<string, unknown> = {
index 68d51a673951242481da284b8fcdd97260db2549..a68494e824f1d487053c246bb8356f8ea61d6e4d 100644 (file)
@@ -207,7 +207,7 @@ export function toSecretsHeaderData<T>(
 }
 
 export interface CreateLogDataOptions {
-  provider: "openai" | "anthropic" | "local" | "api";
+  provider: "openai" | "anthropic" | "codex" | "local" | "api";
   model: string;
   startTime: number;
   pii?: PIILogData;
@@ -250,7 +250,7 @@ export function createLogData(options: CreateLogDataOptions): RequestLogData {
 // ============================================================================
 
 export interface ProviderErrorContext {
-  provider: "openai" | "anthropic" | "local";
+  provider: "openai" | "anthropic" | "codex" | "local";
   model: string;
   startTime: number;
   pii?: PIILogData;
index ddf2d00f565ec743c8bb42c72907f88364e78a30..c3c7ccca4da17ce4164eef5410ba0dbc1784dc22 100644 (file)
@@ -6,7 +6,7 @@ export interface RequestLog {
   id?: number;
   timestamp: string;
   mode: "route" | "mask";
-  provider: "openai" | "anthropic" | "local" | "api";
+  provider: "openai" | "anthropic" | "codex" | "local" | "api";
   model: string;
   pii_detected: boolean;
   entities: string;
@@ -170,10 +170,10 @@ export class Logger {
       .prepare(`SELECT COUNT(*) as count FROM request_logs WHERE pii_detected = 1`)
       .get() as { count: number };
 
-    // Proxy (OpenAI + Anthropic) vs Local vs API
+    // Proxy (OpenAI + Anthropic + Codex) vs Local vs API
     const proxyResult = this.db
       .prepare(
-        `SELECT COUNT(*) as count FROM request_logs WHERE provider IN ('openai', 'anthropic')`,
+        `SELECT COUNT(*) as count FROM request_logs WHERE provider IN ('openai', 'anthropic', 'codex')`,
       )
       .get() as { count: number };
     const localResult = this.db
@@ -289,7 +289,7 @@ export function getLogger(): Logger {
 export interface RequestLogData {
   timestamp: string;
   mode: "route" | "mask";
-  provider: "openai" | "anthropic" | "local" | "api";
+  provider: "openai" | "anthropic" | "codex" | "local" | "api";
   model: string;
   piiDetected: boolean;
   entities: string[];
git clone https://git.99rst.org/PROJECT