From: Stefan Gasser Date: Thu, 4 Jun 2026 08:12:52 +0000 (+0200) Subject: Add Codex CLI support (#87) X-Git-Tag: v0.4.0~1 X-Git-Url: http://git.99rst.org/?a=commitdiff_plain;h=c4d34d5ac7747a3951ab0377d0dd6325823c54fb;p=sgasser-llm-shield.git Add Codex CLI support (#87) --- diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 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. diff --git a/CLAUDE.md b/CLAUDE.md index d932884..43c994c 100644 --- 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 diff --git a/README.md b/README.md index b0e6679..7ad2d00 100644 --- 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
Route Mode -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 diff --git a/config.example.yaml b/config.example.yaml index 07ebd73..a63bf0d 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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 index 0000000..80fad2a --- /dev/null +++ b/docs/api-reference/codex.mdx @@ -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/* +``` + + +`POST /codex/responses` receives PII and secrets protection and appears in the dashboard. Supporting endpoints such as `/codex/models` are proxied directly. + + +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`. diff --git a/docs/api-reference/status.mdx b/docs/api-reference/status.mdx index cb7e8cd..2735c13 100644 --- a/docs/api-reference/status.mdx +++ b/docs/api-reference/status.mdx @@ -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": { diff --git a/docs/concepts/mask-mode.mdx b/docs/concepts/mask-mode.mdx index d091331..79e8a54 100644 --- a/docs/concepts/mask-mode.mdx +++ b/docs/concepts/mask-mode.mdx @@ -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) - 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]])"` - OpenAI/Anthropic responds: `"Dear [[PERSON_1]], Following up on our discussion..."` + The provider responds: `"Dear [[PERSON_1]], Following up on our discussion..."` 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 diff --git a/docs/concepts/secrets-detection.mdx b/docs/concepts/secrets-detection.mdx index 1592adb..749ddfc 100644 --- a/docs/concepts/secrets-detection.mdx +++ b/docs/concepts/secrets-detection.mdx @@ -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 diff --git a/docs/configuration/logging.mdx b/docs/configuration/logging.mdx index b2da33f..a37155a 100644 --- a/docs/configuration/logging.mdx +++ b/docs/configuration/logging.mdx @@ -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 diff --git a/docs/configuration/overview.mdx b/docs/configuration/overview.mdx index 96130df..4fe6a44 100644 --- a/docs/configuration/overview.mdx +++ b/docs/configuration/overview.mdx @@ -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. diff --git a/docs/configuration/providers.mdx b/docs/configuration/providers.mdx index cad0fca..c206b22 100644 --- a/docs/configuration/providers.mdx +++ b/docs/configuration/providers.mdx @@ -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 ``` diff --git a/docs/configuration/secrets-detection.mdx b/docs/configuration/secrets-detection.mdx index 5525eaa..acdf45d 100644 --- a/docs/configuration/secrets-detection.mdx +++ b/docs/configuration/secrets-detection.mdx @@ -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) diff --git a/docs/introduction.mdx b/docs/introduction.mdx index df58edc..017f49d 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -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. diff --git a/docs/mint.json b/docs/mint.json index 843125d..4ccdb25 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -59,6 +59,7 @@ "pages": [ "api-reference/openai", "api-reference/anthropic", + "api-reference/codex", "api-reference/mask", "api-reference/status", "api-reference/dashboard-api" diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index c00eab7..2b0fce3 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -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 PasteGuard Dashboard diff --git a/docs/use-cases/coding-tools.mdx b/docs/use-cases/coding-tools.mdx index fcf10de..c85a368 100644 --- a/docs/use-cases/coding-tools.mdx +++ b/docs/use-cases/coding-tools.mdx @@ -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' +``` + + +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. + + ## 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 index 0000000..3a627f6 --- /dev/null +++ b/src/config.test.ts @@ -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); + } + }); +}); diff --git a/src/config.ts b/src/config.ts index 29457c6..3f6ceb9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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; export type OpenAIProviderConfig = z.infer; export type AnthropicProviderConfig = z.infer; +export type CodexProviderConfig = z.infer; export type LocalProviderConfig = z.infer; export type MaskingConfig = z.infer; export type SecretsDetectionConfig = z.infer; diff --git a/src/index.ts b/src/index.ts index e0035ba..79cf5fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 index 0000000..c6798f7 --- /dev/null +++ b/src/masking/extractors/codex.ts @@ -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; + +const TEXT_KEYS = new Set([ + "arguments", + "content", + "delta", + "input", + "input_text", + "instructions", + "output_text", + "text", +]); + +interface LocatedString { + path: Array; + value: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function collectText(value: unknown, path: Array = []): 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(value: T, path: Array, 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 { + return path + .map((part, index) => + typeof part === "number" ? `[${part}]` : index === 0 ? part : `.${part}`, + ) + .join(""); +} + +function pathFromString(path: string): Array { + const result: Array = []; + for (const part of path.matchAll(/([^.[\]]+)|\[(\d+)\]/g)) { + result.push(part[1] ?? Number(part[2])); + } + return result; +} + +export const codexExtractor: RequestExtractor = { + 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 index 0000000..bc05247 --- /dev/null +++ b/src/routes/codex.test.ts @@ -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>(() => + 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 index 0000000..be85074 --- /dev/null +++ b/src/routes/codex.ts @@ -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, +): Promise { + 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; + startTime: number; + headers: Record; +} + +function getForwardHeaders(c: Context): Record { + const headers: Record = {}; + 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, + 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, + 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, + 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, + 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, + piiContext: PlaceholderContext | undefined, + maskingConfig: MaskingConfig, + secretsContext?: PlaceholderContext, +): ReadableStream { + 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(); + } + }, + }); +} diff --git a/src/routes/info.test.ts b/src/routes/info.test.ts index 121a380..b4aa8f0 100644 --- a/src/routes/info.test.ts +++ b/src/routes/info.test.ts @@ -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).codex.base_url as string).length, + ).toBeGreaterThan(0); expect(body.pii_detection).toBeDefined(); }); diff --git a/src/routes/info.ts b/src/routes/info.ts index eb01fcb..5f06cd1 100644 --- a/src/routes/info.ts +++ b/src/routes/info.ts @@ -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 = { diff --git a/src/routes/utils.ts b/src/routes/utils.ts index 68d51a6..a68494e 100644 --- a/src/routes/utils.ts +++ b/src/routes/utils.ts @@ -207,7 +207,7 @@ export function toSecretsHeaderData( } 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; diff --git a/src/services/logger.ts b/src/services/logger.ts index ddf2d00..c3c7ccc 100644 --- a/src/services/logger.ts +++ b/src/services/logger.ts @@ -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[];