IL

impure0xntk/litellm-guardrail-codex-mcp

Developer tools
0 stars 0 forks Quality 69 Trend 20Apache-2.0

A LiteLLM that lets OpenAI Codex CLI work with model providers that do not understand Codex's MCP tool format.

Overview

A LiteLLM that lets OpenAI Codex CLI work with model providers that do not understand Codex's MCP tool format. Codex CLI sends its MCP (Model Context Protocol) servers to the model using a non-standard type: "namespace" tool object. OpenAI's /v1/responses endpoint understands it. Providers such as OpenRouter do not. The tools are silently dropped and never reach the model, so the assistant behaves as though it has no tools at all. This guardrail flattens each namespace into ordinary type: "function" tools named mcp__ before the request leaves the proxy: The model sees a standard tool it can call, and its tool call comes back under that same flattened name. Because a LiteLLM custom guardrail can rewrite the raw /v1/responses request body from inside the proxy you already run. Mutating data in async_pre_call_hook propagates all the way to the upstream request. This was verified empirically against litellm 1.105.

README

litellm-guardrail-codex-mcp

A LiteLLM Custom Guardrail that lets OpenAI Codex CLI work with model providers that do not understand Codex’s MCP tool format.

The problem

Codex CLI sends its MCP (Model Context Protocol) servers to the model using a non-standard type: "namespace" tool object. OpenAI’s /v1/responses endpoint understands it. Providers such as OpenRouter do not. The tools are silently dropped and never reach the model, so the assistant behaves as though it has no tools at all.

A Codex request looks like this:

{
  "model": "claude-sonnet-4.5",
  "tools": [
    {
      "type": "namespace",
      "name": "mcp__filesystem",
      "tools": [
        { "name": "read_file", "description": "Read a file", "parameters": { "...": "..." } }
      ]
    }
  ]
}

The fix

This guardrail flattens each namespace into ordinary type: "function" tools named mcp____ before the request leaves the proxy:

{
  "tools": [
    {
      "type": "function",
      "name": "mcp__filesystem__read_file",
      "description": "Read a file",
      "parameters": { "...": "..." }
    }
  ]
}

The model sees a standard tool it can call, and its tool call comes back under that same flattened name.

Why a guardrail and not a second proxy? Because a LiteLLM custom guardrail can rewrite the raw /v1/responses request body from inside the proxy you already run. Mutating data in async_pre_call_hook propagates all the way to the upstream request. This was verified empirically against litellm 1.105.1 for both streaming and non-streaming requests: the mock upstream received mcp__filesystem__read_file as a type: "function" tool. No extra hop, no extra process, and /chat/completions traffic stays untouched.

Installation

uv pip install litellm-guardrail-codex-mcp

The guardrail module path is litellm_guardrail_codex_mcp.CodexMCPNamespaceGuardrail.

Configuration

Merge this into your LiteLLM proxy config (see examples/config.example.yaml):

guardrails:
  - guardrail_name: codex-mcp-namespace-compat
    litellm_params:
      guardrail: litellm_guardrail_codex_mcp.CodexMCPNamespaceGuardrail
      mode: [pre_call, post_call]
      default_on: true

Two settings here are mandatory:

Setting Why
default_on: true Without it LiteLLM’s should_run_guardrail() returns False and the hook never runs. The guardrail silently does nothing.
mode includes post_call The streaming iterator hook only fires when post_call is present. With mode: pre_call alone, has_iterator_override stays false and SSE responses are never observed.

Optional settings

Field Default Meaning
enabled true Set false to install without rewriting anything.
target_models null Restrict to matching models, e.g. ["openrouter/*"]. Null applies to every attached route.
strict_validation true Raise HTTP 400 on an unconvertible namespace instead of forwarding it to a provider that will silently drop it.
streaming true Process Responses API SSE events.
logging false Emit one concise INFO line per conversion.
restore_namespace_names false See below.

Booleans are read from YAML, so unquoted tokens such as no, off, yes and on arrive as strings. Every boolean field is normalized: "false", "no", "off" and "0" all mean false, and anything unrecognized falls back to the documented default. Without this, restore_namespace_names: "false" is Python-truthy and would silently enable restoration.

Why restore_namespace_names is off by default

The model returns tool calls named mcp__filesystem__read_file. It is tempting to rewrite that back to the bare MCP name read_file, but you should not: current Codex resolves the flattened mcp____ name against its own tool registry. Rewriting it to read_file breaks tool execution, because Codex can no longer tell which MCP server to dispatch to (see openai/codex#26234).

So by default the guardrail leaves the flattened name alone. Set restore_namespace_names: true only if you know your client expects bare names.

Guarantees

These are enforced by tests, not just intent:

  • Request-scoped mapping. Each request builds its own flat-name to {namespace, tool_name} table, stashed per request. No global mutable state, so concurrent requests cannot see each other’s mappings. The table is keyed internally and never forwarded to the provider: upstream sees only input, model, stream and tools.
  • Never guessed. A tool name that was not mapped during this request is left exactly as-is. The guardrail never invents a mapping.
  • call_id and arguments are preserved verbatim. Only the name field is ever touched.
  • SSE is handled structurally, never as string surgery. Argument delta events are passed through by object. Deltas are never reordered, dropped, duplicated, or pattern-replaced, so previous_response_id history stays intact.
  • No secret logging. Logs carry counts and metadata (namespaces_flattened, tools_flattened) only. Never prompts, arguments, or API keys.
  • Diagnostics carry exactly the fields the spec allows: whether namespace conversion happened, tool counts before and after, conversion elapsed time (conversion_ms), unsupported shapes, and a per-request correlation_id that ties the pre-call and post-call lines together. None of these keys reach upstream; the integration tests assert that.
  • Failure is loud. With strict_validation: true, a malformed namespace yields HTTP 400 rather than a silent forward to a provider that would drop it.

Development

This project is developed with Nix.

nix develop        # dev shell with python 3.13, uv, pytest, ruff
nix run .#lint     # ruff check src tests
nix run .#format   # ruff format src tests

Without Nix:

uv venv && uv pip install -e ".[dev]"
pytest tests/

Tests

128 tests across six concerns, all in tests/. tests/test_integration.py boots the real LiteLLM proxy ASGI app pointed at a recording mock upstream and asserts on the exact JSON the upstream receives. That is what proves the guardrail genuinely rewrites the request, rather than only proving the helper functions are correct in isolation.

Request isolation (requirement 7) is covered by test_concurrent_requests_do_not_share_mappings, which interleaves 24 requests across two namespaces while the upstream returns each request a tool call belonging to the other namespace. A correct per-request table leaves those foreign names untouched; a shared table would rewrite them.

The fixes in this repository were validated by mutation testing, not by green checkmarks: each fix is reverted in a scratch harness to confirm its test actually fails. Guardrail hooks silently do nothing when misconfigured, so a test that cannot fail is worthless here.

Module Covers
test_transforms.py Flattening rules for all input shapes, including malformed ones
test_request_hooks.py Request rewriting, field preservation, config gating
test_response_mapping.py Per-request mapping, no-guessing, isolation
test_streaming.py SSE events, typed pydantic events, pass-through integrity
test_integration.py Real litellm proxy + mock upstream, streaming and non-streaming

Verified against

litellm 1.105.1, Python 3.13.

Two implementation details worth knowing if you maintain this:

  • There is no litellm.__version__ attribute. Read the installed version with importlib.metadata.version("litellm").
  • Guardrail status metadata uses add_standard_logging_guardrail_information_to_request_data(...), not the set_guardrail_status that some docs reference. The latter does not exist in 1.105.1.

License

Apache License 2.0. See LICENSE.

View this README on GitHub

Install

This server does not publish a one-line install command.

Open the repository installation guide