Skip to content

feat: Add a default /v1/messages (Anthropic Messages) route to the base Gym…#1627

Merged
ffrujeri merged 7 commits into
mainfrom
ffrujeri/anthropic-messages-proxy
Jun 24, 2026
Merged

feat: Add a default /v1/messages (Anthropic Messages) route to the base Gym…#1627
ffrujeri merged 7 commits into
mainfrom
ffrujeri/anthropic-messages-proxy

Conversation

@ffrujeri

@ffrujeri ffrujeri commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Design note

Makes every NeMo Gym model server speak the Anthropic Messages API by adding a
POST /v1/messages route to the base SimpleResponsesAPIModel, with a default implementation
that maps Anthropic Messages ↔ Responses around the server's own responses(). External
harnesses that require an Anthropic endpoint — notably claude_code_agent (the Claude Code CLI
only talks /v1/messages) — can now run against any Gym backend (vLLM, OpenAI, an inference
provider), not just Anthropic's API.

The Messages ↔ Responses mapping lives in one shared, transport-free, SDK-free converter,
nemo_gym/anthropic_converter.py, used in both directions and shared with the egress
anthropic_model provider (#1146/#1546).

Why

Gym standardizes on the Responses API, but real blackbox agents speak other protocols. Rather
than a bespoke per-agent bridge or a separate proxy server, the Messages spoke becomes a
base-class capability: implement responses() once and get /v1/messages for free. This is
the model-proxy layer the CustomAgent EPIC (#1042) assumes — its sidecar "path-preserving
forwards" /v1/messages to these routes.

Key design points

  • Route on the base class, not a separate proxy. Every model server inherits /v1/messages;
    no extra process or network hop. (An earlier anthropic_messages_proxy + SimpleMessagesAPIModel
    iteration was removed in favor of this.)
  • Delegates to the server's own responses() — reuses whatever backend the server has; a
    small inspect-based dispatch handles both existing responses() signatures.
  • Synthesized SSE. Backends are non-streaming via Responses; the converter fabricates the
    Anthropic SSE event stream from the complete response (the Claude CLI requires streaming).
  • Shared bidirectional converter, transport-free and no anthropic SDK (local shapes
    over Gym's aiohttp). Fail-loud (NotImplementedError) on unsupported content.
  • Eval-first. Token-ids live only at the Responses hop and cannot ride the Anthropic SSE
    response; an RL path would side-channel them, not thread them through Messages.

Issues

Closes #1620 ("Add a default /v1/messages (Anthropic Messages) route to the
base Gym model server"
).

Closes #1566

Relationship to adjacent work:

Usage

Every model server now answers Anthropic Messages directly — this is config-independent and is
the core of the change. Against any running Gym model server:

curl -s http://127.0.0.1:<model_server_port>/v1/messages -H 'content-type: application/json' -d '{
  "model": "x", "max_tokens": 64, "messages": [{"role": "user", "content": "hi"}]
}'

claude_code_agent reaches any Gym model server by setting its model_server ref — the harness
resolves ANTHROPIC_BASE_URL from it and the CLI appends /v1/messages (see
responses_api_agents/claude_code_agent/app.py:_resolve_base_url). The end-to-end run in
Testing below used this path against a local vLLM. The verified reasoning_gym Claude Code
config keeps its direct anthropic_base_url default; targeting a Gym model server is an opt-in
model_server ref.

Additional information

  • Changed: nemo_gym/base_responses_api_model.py (new /v1/messages route + default
    messages()); added nemo_gym/anthropic_converter.py (shared bidirectional converter);
    responses_api_agents/claude_code_agent/README.md (documents the model-server path). No
    change to the verified reasoning_gym_claude_code_agent.yaml — targeting a Gym model server is
    an opt-in model_server ref on the agent.
  • Converter surface: responses_to_anthropic / anthropic_to_responses (egress, shared
    with feat: Add anthropic messages api support as a responses_api_model. #1546); anthropic_request_to_responses / responses_to_anthropic_response /
    anthropic_response_to_sse (ingress). Supports text, base64 images, function tools,
    tool_use/tool_result, thinking, stop-reason. Anthropic-API egress policy stays in the
    anthropic_model server, not the converter.

Testing

  • tests/unit_tests/test_anthropic_converter.py (ingress + round-trip) and …_egress.py
    (egress) — 100% converter coverage.
  • tests/unit_tests/test_responses_api_model_messages.py — base-class /v1/messages for both
    responses() signatures + SSE framing.
  • Full unit suite green (284); openai_model tests pass unchanged.
  • Live e2e: claude_code_agent → policy_model (vLLM) /v1/messages on reasoning_gym —
    finished_naturally=1, 1 turn, ~5.5k tokens, 0 failures (reward reflects the small model's
    answer, not the plumbing).
  • Verified: ruff check, ruff format --check, pre-commit.

Open questions

  • Versioning/regression suite for the converters (OpenAI/Anthropic bump frequently).
  • Audit/align the existing Chat-Completions ↔ Responses tests.
  • Confirm fail-loud vs drop policy for items absent in a target schema (e.g. computer-use).

… model server.

Signed-off-by: Felipe Vieira Frujeri <ffrujeri@nvidia.com>
@copy-pr-bot

copy-pr-bot Bot commented Jun 16, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

Adopt the official anthropic SDK types for the /v1/messages <-> Responses
mapping instead of hand-rolled dicts: validate the egress Anthropic response
via Message.model_validate and hint requests with MessageCreateParams. Types
only — the SDK client is never used (it uses httpx, whose O(n^2) connection
pooling hangs at high concurrency); all transport stays on aiohttp. Pin
anthropic<=0.109.2, mirroring the openai pin. Add converter test coverage
for empty output, empty-text system lists, and system-role messages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Lin Jia <linj@nvidia.com>
@linj-glitch linj-glitch requested a review from a team as a code owner June 17, 2026 23:11
… note

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Lin Jia <linj@nvidia.com>
@linj-glitch

Copy link
Copy Markdown
Collaborator

Manual test — Tier 1 → Tier 2 (live)

Validated the default /v1/messages proxy end-to-end against a live model. No code changes — config only, on this branch.

Setup: ng_run a vllm_model pointed at NVIDIA build (integrate.api.nvidia.com/v1, meta/llama-3.1-8b-instruct) — a remote endpoint, so no local GPU.

Tier 1 — proxy /v1/messages:

  • Non-streaming → valid Anthropic Message (stop_reason: end_turn, "The answer is 4.", real usage). ✅
  • Streaming → correct Anthropic SSE: message_start → content_block_start → content_block_delta → content_block_stop → message_delta → message_stop. ✅

Tier 2 — real claude CLI against the proxy (the seam mocks can't cover):

  • ANTHROPIC_BASE_URL → the model server; the CLI consumed our synthesized SSE and returned result: subtype=success, is_error=False, clean exit. ✅

Gotcha (now documented in the agent README): use vllm_model (forwards to /chat/completions) for OpenAI-compatible chat endpoints; openai_model forwards to the OpenAI Responses API (/responses) and 404s on chat-only providers like NVIDIA build — not a bug, different upstream protocols.

Smoke-test commands added in c16946c.

@linj-glitch linj-glitch self-requested a review June 18, 2026 23:26
linj-glitch
linj-glitch previously approved these changes Jun 18, 2026
…ssages-proxy

Signed-off-by: Lin Jia <linj@nvidia.com>
linj-glitch and others added 2 commits June 22, 2026 11:40
…ssages-proxy

Signed-off-by: Lin Jia <linj@nvidia.com>
Add nemo_gym/anthropic_utils.py with the two boundary types for the
Anthropic Messages API, mirroring openai_utils.py's wrapping strategy
but deliberately minimal (no *ForTraining hierarchy, no async client):

- NeMoGymAnthropicMessageCreateParamsNonStreaming: request validator,
  a BaseModel copy of the SDK's MessageCreateParams TypedDict with
  extra="forbid" and Iterable fields overridden to List for strict
  server-side validation at the ingress proxy.
- NeMoGymAnthropicMessage: response validator, a thin subclass of the
  SDK's Message used to validate what we emit.

Wire NeMoGymAnthropicMessage into AnthropicConverter's response
validation path and add unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Lin Jia <linj@nvidia.com>
@linj-glitch

Copy link
Copy Markdown
Collaborator

✅ End-to-end smoke test with the real Claude Code CLI

Verified the default /v1/messages ingress path end-to-end against the real claude CLI (2.1.186), backed by a NeMo Gym model server (no mocks at the proxy layer).

Setup

  • Gym server: vllm_model run as a plain chat-completions proxy (is_responses_native=false, uses_reasoning_parser=false) — needs only an OpenAI-compatible /v1/chat/completions backend, no local GPU.
  • Backend: NVIDIA-hosted inference (integrate.api.nvidia.com/v1), model meta/llama-3.3-70b-instruct.
  • Claude Code: ANTHROPIC_BASE_URL=http://127.0.0.1:<port> + dummy auth token.

Results

Test Path exercised Result
curl non-stream Anthropic req → converter → Responses → backend → converter → Anthropic JSON ✅ valid Message
curl stream + anthropic_response_to_sse ✅ well-formed SSE (message_startcontent_block_deltamessage_stop)
Claude Code — text CLI → /v1/messages → … → CLI render ✅ answered "42" to "7 × 6"
Claude Code — tool use + tool_use/tool_resultfunction_call/function_call_output ✅ model called Bash, CLI ran echo hello-from-tool, result fed back, final answer correct

The tool-use case round-trips the full bidirectional converter, including tool-call mapping in both directions.

Reproduce

RAY_TMPDIR=/tmp ng_run "+config_paths=[responses_api_models/vllm_model/configs/vllm_model.yaml]" \
  ++policy_base_url=https://integrate.api.nvidia.com/v1 \
  ++policy_model_name=meta/llama-3.3-70b-instruct ++policy_api_key="$NVAPI_KEY" \
  ++policy_model.responses_api_models.vllm_model.uses_reasoning_parser=false \
  ++policy_model.responses_api_models.vllm_model.uses_interleaved_reasoning=false &
# discover port via `ng_status`, then:
ANTHROPIC_BASE_URL=http://127.0.0.1:<port> ANTHROPIC_AUTH_TOKEN=dummy \
  claude -p "Use the Bash tool to run: echo hello-from-tool — then tell me what it printed." \
  --dangerously-skip-permissions

Note: a direct local-vLLM backend was not usable in this environment (vLLM's Triton JIT-compiles cuda_utils.c, which needs CPython dev headers / Python.h that weren't installed). The NVIDIA-backed chat-completions proxy avoids that entirely.

🤖 Generated with Claude Code

@ffrujeri ffrujeri merged commit e530ca7 into main Jun 24, 2026
16 checks passed
@anwithk anwithk linked an issue Jun 25, 2026 that may be closed by this pull request
9 tasks
ffrujeri added a commit that referenced this pull request Jun 25, 2026
Resolve add/add conflicts in anthropic_converter and its tests by
keeping main (#1627) as the source of truth.
ritaneves pushed a commit that referenced this pull request Jun 25, 2026
…se Gym… (#1627)

# What does this PR do?
[Design
note](#1620 (comment))

Makes **every NeMo Gym model server speak the Anthropic Messages API**
by adding a
`POST /v1/messages` route to the base `SimpleResponsesAPIModel`, with a
default implementation
that maps Anthropic Messages ↔ Responses around the server's own
`responses()`. External
harnesses that require an Anthropic endpoint — notably
`claude_code_agent` (the Claude Code CLI
only talks `/v1/messages`) — can now run against **any** Gym backend
(vLLM, OpenAI, an inference
provider), not just Anthropic's API.

The Messages ↔ Responses mapping lives in one shared, transport-free,
SDK-free converter,
`nemo_gym/anthropic_converter.py`, used in both directions and shared
with the egress
`anthropic_model` provider (#1146/#1546).

See `DESIGN-NOTE.md` for rationale.

# Why

Gym standardizes on the Responses API, but real blackbox agents speak
other protocols. Rather
than a bespoke per-agent bridge or a separate proxy server, the Messages
spoke becomes a
base-class capability: implement `responses()` once and get
`/v1/messages` for free. This is
the model-proxy layer the CustomAgent EPIC (#1042) assumes — its sidecar
"path-preserving
forwards" `/v1/messages` to these routes.

# Key design points

- **Route on the base class, not a separate proxy.** Every model server
inherits `/v1/messages`;
no extra process or network hop. (An earlier `anthropic_messages_proxy`
+ `SimpleMessagesAPIModel`
  iteration was removed in favor of this.)
- **Delegates to the server's own `responses()`** — reuses whatever
backend the server has; a
small `inspect`-based dispatch handles both existing `responses()`
signatures.
- **Synthesized SSE.** Backends are non-streaming via Responses; the
converter fabricates the
Anthropic SSE event stream from the complete response (the Claude CLI
requires streaming).
- **Shared bidirectional converter**, transport-free and **no
`anthropic` SDK** (local shapes
over Gym's aiohttp). Fail-loud (`NotImplementedError`) on unsupported
content.
- **Eval-first.** Token-ids live only at the Responses hop and cannot
ride the Anthropic SSE
response; an RL path would side-channel them, not thread them through
Messages.

# Issues

Closes #1620 (*"Add a default `/v1/messages` (Anthropic
Messages) route to the
base Gym model server"*).

Closes #1566

Relationship to adjacent work:
- **#1620** — this PR is its implementation: the `/v1/messages` route +
default Messages ↔
Responses mapping on `SimpleResponsesAPIModel`, the shared converter,
and the
  `claude_code_agent` end-to-end run satisfy its acceptance criteria.
- **#1146 / #1546** (egress `anthropic_model`) — inverse direction;
shares the same converter.
- **#1286** (`responses_converter`) — sibling Chat-Completions ↔
Responses converter; same pattern.
- **EPIC #1042** (CustomAgent) — its sidecar model proxy
"path-preserving forwards" `/v1/messages`
  to the routes this PR adds.

# Usage

Every model server now answers Anthropic Messages directly — this is
config-independent and is
the core of the change. Against any running Gym model server:

```bash
curl -s http://127.0.0.1:<model_server_port>/v1/messages -H 'content-type: application/json' -d '{
  "model": "x", "max_tokens": 64, "messages": [{"role": "user", "content": "hi"}]
}'
```

`claude_code_agent` reaches any Gym model server by setting its
`model_server` ref — the harness
resolves `ANTHROPIC_BASE_URL` from it and the CLI appends `/v1/messages`
(see
`responses_api_agents/claude_code_agent/app.py:_resolve_base_url`). The
end-to-end run in
**Testing** below used this path against a local vLLM. The verified
`reasoning_gym` Claude Code
config keeps its direct `anthropic_base_url` default; targeting a Gym
model server is an opt-in
`model_server` ref.

# Additional information

- **Changed:** `nemo_gym/base_responses_api_model.py` (new
`/v1/messages` route + default
`messages()`); **added** `nemo_gym/anthropic_converter.py` (shared
bidirectional converter);
`responses_api_agents/claude_code_agent/README.md` (documents the
model-server path). No
change to the verified `reasoning_gym_claude_code_agent.yaml` —
targeting a Gym model server is
  an opt-in `model_server` ref on the agent.
- **Converter surface:** `responses_to_anthropic` /
`anthropic_to_responses` (egress, shared
with #1546); `anthropic_request_to_responses` /
`responses_to_anthropic_response` /
`anthropic_response_to_sse` (ingress). Supports text, base64 images,
function tools,
tool_use/tool_result, thinking, stop-reason. Anthropic-API egress
*policy* stays in the
  `anthropic_model` server, not the converter.

# Testing

- `tests/unit_tests/test_anthropic_converter.py` (ingress + round-trip)
and `…_egress.py`
  (egress) — **100%** converter coverage.
- `tests/unit_tests/test_responses_api_model_messages.py` — base-class
`/v1/messages` for both
  `responses()` signatures + SSE framing.
- Full unit suite green (284); `openai_model` tests pass unchanged.
- **Live e2e:** `claude_code_agent → policy_model (vLLM) /v1/messages`
on reasoning_gym —
`finished_naturally=1`, 1 turn, ~5.5k tokens, 0 failures (reward
reflects the small model's
  answer, not the plumbing).
- Verified: `ruff check`, `ruff format --check`, `pre-commit`.

# Open questions

- Versioning/regression suite for the converters (OpenAI/Anthropic bump
frequently).
- Audit/align the existing Chat-Completions ↔ Responses tests.
- Confirm fail-loud vs drop policy for items absent in a target schema
(e.g. computer-use).

---------

Signed-off-by: Felipe Vieira Frujeri <ffrujeri@nvidia.com>
Signed-off-by: Lin Jia <linj@nvidia.com>
Co-authored-by: Lin Jia <linj@nvidia.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Rita Fernandes Neves <rfernandesne@nvidia.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants