--- /dev/null
+# 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.
-# 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
**[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
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
<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)**
**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
# 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
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:
--- /dev/null
+---
+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`.
"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": {
---
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
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..."`
## 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
| 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)
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
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
| 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.
---
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
| `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.
## 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:
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
```
| 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)
| 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.
"pages": [
"api-reference/openai",
"api-reference/anthropic",
+ "api-reference/codex",
"api-reference/mask",
"api-reference/status",
"api-reference/dashboard-api"
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
- 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" />
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**
## 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` |
--- /dev/null
+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);
+ }
+ });
+});
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({
providers: z.object({
openai: OpenAIProviderSchema.default({}),
anthropic: AnthropicProviderSchema.default({}),
+ codex: CodexProviderSchema.default({}),
}),
// Local provider - only for route mode
local: LocalProviderSchema.optional(),
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>;
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";
app.route("/", infoRoutes);
app.route("/openai", openaiRoutes);
app.route("/anthropic", anthropicRoutes);
+app.route("/codex", codexRoutes);
app.route("/api", apiRoutes);
if (config.dashboard.enabled) {
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(`
╔═══════════════════════════════════════════════════════════╗
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
--- /dev/null
+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;
+ },
+};
--- /dev/null
+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`,
+ );
+ });
+});
--- /dev/null
+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();
+ }
+ },
+ });
+}
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();
});
anthropic: {
base_url: getAnthropicInfo(config.providers.anthropic).baseUrl,
},
+ codex: {
+ base_url: config.providers.codex.base_url,
+ },
};
const info: Record<string, unknown> = {
}
export interface CreateLogDataOptions {
- provider: "openai" | "anthropic" | "local" | "api";
+ provider: "openai" | "anthropic" | "codex" | "local" | "api";
model: string;
startTime: number;
pii?: PIILogData;
// ============================================================================
export interface ProviderErrorContext {
- provider: "openai" | "anthropic" | "local";
+ provider: "openai" | "anthropic" | "codex" | "local";
model: string;
startTime: number;
pii?: PIILogData;
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;
.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
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[];