Modelplane Modelplane docs

Anthropic Messages API

A vLLM server registers the Anthropic Messages API at /v1/messages alongside its OpenAI routes, with no extra flag. Modelplane’s route matches the /<namespace>/<service>/ prefix and preserves the path below it, so the same service URL answers both /v1/chat/completions and /v1/messages. A client that speaks the Messages API, including Claude Code via ANTHROPIC_BASE_URL, talks to the deployment directly. See Alternate APIs for the routing detail.

This recipe serves Qwen3-8B on a single NVIDIA H100 on Nebius, with tool calling on: --enable-auto-tool-choice and --tool-call-parser=hermes are what let Claude Code’s tool use work. An 8B model needs a fraction of an H100, so the GPU has ample headroom. Apply the platform side first, then the ML side.

Platform

inference-class.yaml
# InferenceClass for a single-H100 Nebius shape, serving Qwen3-8B.
#
# Nebius sizes nodes by platform + preset rather than an instance type; the
# preset determines the GPU, vCPU, and memory shape of each node. The devices
# block describes what a node of this class has, DRA-style - used by the
# scheduler to match models to clusters, and to form DRA ResourceClaims for
# claim: DRA devices. An 8B model needs a fraction of an H100, so this shape has
# ample headroom; a single L40S (gpu-l40s-a) is the economical alternative.
apiVersion: modelplane.ai/v1alpha1
kind: InferenceClass
metadata:
  name: h100-1x
spec:
  description: "Nebius gpu-h100-sxm, 1x NVIDIA H100 80GB"
  provisioning:
    provider: Nebius
    nebius:
      platform: gpu-h100-sxm
      preset: 1gpu-16vcpu-200gb
      diskSizeGb: 200
      driversPreset: cuda13.0
      accelerator:
        type: nvidia-h100
        count: 1
  devices:
  - name: gpu
    claim: DRA
    driver: gpu.nvidia.com
    deviceClassName: gpu.nvidia.com
    count: 1
    attributes:
      architecture: { string: Hopper }
      cudaComputeCapability: { version: "9.0.0" }
    capacity:
      # The H100 80GB's real usable VRAM, what the NVIDIA DRA driver reports,
      # not its nominal 80GB. A nodeSelector asking for >= 80Gi would never bind.
      memory: { value: "81559Mi" }
inference-cluster.yaml
# An InferenceCluster backed by a Nebius mk8s cluster. Modelplane provisions the
# full mk8s cluster and installs the inference stack; only the GPU node pool -
# referencing the InferenceClass above - is declared here. It authenticates to
# the cluster with the credentials of the Nebius ClusterProviderConfig named
# default, the same identity that provisions it.
#
# Delete with foreground cascading deletion for a clean teardown, so the
# inference stack uninstalls before the cluster's API server goes away:
#   kubectl delete inferencecluster nebius-eu-north --cascade=foreground
apiVersion: modelplane.ai/v1alpha1
kind: InferenceCluster
metadata:
  name: nebius-eu-north
  labels:
    modelplane.ai/region: eu-north
spec:
  cluster:
    source: Nebius
    nebius: {}
  nodePools:
  - name: gpu-h100
    className: h100-1x
    nodeCount: 1
    minNodeCount: 1
    maxNodeCount: 1

Deployment

model-deployment.yaml
# Qwen3-8B served on a single NVIDIA H100 (Nebius) by vLLM, exposed on the
# Anthropic Messages API. vLLM's server registers /v1/messages (and
# /v1/messages/count_tokens) alongside its OpenAI routes with no extra flag, so
# this is an ordinary serve: the Anthropic surface comes for free, and
# Modelplane preserves the path below the service prefix to reach it.
#
# The tool-calling flags are what make it usable from Claude Code, not decoration:
#
#   --enable-auto-tool-choice with
#   --tool-call-parser=hermes        parse the model's tool calls so Claude Code's
#                                    tool use works (qwen3_xml is for Qwen3-Coder,
#                                    not this dense model). Qwen3's tool-use
#                                    template ships in the tokenizer, so no
#                                    --chat-template is needed.
#   --reasoning-parser=qwen3 with
#   --default-chat-template-kwargs   turns thinking off. Qwen3 thinks by default,
#                                    burying a one-line answer under a <think>
#                                    block and forbidding greedy decode.
#   --max-model-len=40960            Qwen3-8B's native context (past it needs YaRN).
#                                    Claude Code reserves 32000 output tokens, so a
#                                    smaller window 500s ("max_completion_tokens
#                                    cannot be greater than max_model_len", then
#                                    input+output overflow). The client must also
#                                    cap output with CLAUDE_CODE_MAX_OUTPUT_TOKENS
#                                    so input+output fit here. H100 has KV room.
#   --gpu-memory-utilization         headroom, not correctness.
#
# The v0.23.0 image is >0.17.1, so vLLM handles Claude Code's attribution header
# without breaking prefix caching. No --port or --host: Modelplane's routing
# expects the engine on its default :8000 with a /health probe, and passes args
# through verbatim.
apiVersion: modelplane.ai/v1alpha1
kind: ModelDeployment
metadata:
  name: qwen3-8b
  namespace: ml-team
spec:
  replicas: 1
  template:
    spec:
      # No clusterSelector: the single Nebius cluster is matched on device
      # capacity alone.
      engines:
      - name: qwen3-8b
        members:
        - role: Standalone
          nodeSelector:
            devices:
            - name: gpu
              count: 1
              selectors:
              - cel: |
                  device.capacity["gpu.nvidia.com"].memory.compareTo(quantity("20Gi")) >= 0
          template:
            spec:
              containers:
              - name: engine
                image: vllm/vllm-openai:v0.23.0
                args:
                - "--model=Qwen/Qwen3-8B"
                - "--served-model-name=$(MODELPLANE_SERVED_MODEL_NAME)"
                - "--max-model-len=40960"
                - "--gpu-memory-utilization=0.92"
                - "--reasoning-parser=qwen3"
                - "--default-chat-template-kwargs={\"enable_thinking\": false}"
                - "--enable-auto-tool-choice"
                - "--tool-call-parser=hermes"
model-service.yaml
# Exposes the qwen3-8b deployment as a single URL. The route matches the
# /<namespace>/<service>/ prefix and preserves the path below it, so the engine's
# Anthropic Messages API at .../v1/messages rides the same address as its
# OpenAI-compatible .../v1/chat/completions. Read the public address from
# status.address:
#   kubectl get ms qwen3-8b -n ml-team -o jsonpath='{.status.address}'
apiVersion: modelplane.ai/v1alpha1
kind: ModelService
metadata:
  name: qwen3-8b
  namespace: ml-team
spec:
  endpoints:
  - selector:
      matchLabels:
        modelplane.ai/deployment: qwen3-8b

Send a request

Read the Messages API base URL from the gateway serving the service. The gateway publishes one per API it speaks:

bash
ADDRESS=$(kubectl get ig local -o jsonpath='{.status.endpoints.anthropic}')

Post to /messages under it. The model field is the ModelService, as <namespace>/<service>, and the gateway rewrites it to whatever the engine was started as; max_tokens is required:

bash
curl "$ADDRESS/messages" \
  -H "Content-Type: application/json" \
  -H "anthropic-version: 2023-06-01" \
  -d '{
    "model": "ml-team/qwen3-8b",
    "max_tokens": 1024,
    "messages": [{"role": "user", "content": "Hello!"}]
  }'

A gateway with no hostname answers on its load balancer address, which may be in-cluster only, so run the request from inside the cluster if your shell can’t reach it:

bash
kubectl run -i --rm curl-test \
  --image=curlimages/curl \
  --restart=Never \
  --env="ADDRESS=$ADDRESS" \
  -- sh -c 'curl -s "$ADDRESS/messages" \
  -H "Content-Type: application/json" \
  -H "anthropic-version: 2023-06-01" \
  -d "{\"model\":\"ml-team/qwen3-8b\",\"max_tokens\":1024,\"messages\":[{\"role\":\"user\",\"content\":\"Hello!\"}]}"'

Point Claude Code at it

Claude Code appends /v1/messages to ANTHROPIC_BASE_URL. The gateway’s Anthropic base URL already ends in /anthropic/v1, so strip that suffix and let Claude Code add its own. Map every model tier onto the ModelService, since one model serves them all here. This gateway authenticates nobody and vLLM ignores the token, so any non-empty value works. Claude Code reserves 32000 output tokens by default, which alone leaves little context room on a small model; cap it with CLAUDE_CODE_MAX_OUTPUT_TOKENS so the input and output fit under the engine’s --max-model-len (40960 here):

bash
export ANTHROPIC_BASE_URL="${ADDRESS%/anthropic/v1}/anthropic"
export ANTHROPIC_AUTH_TOKEN=dummy
export ANTHROPIC_DEFAULT_OPUS_MODEL=ml-team/qwen3-8b
export ANTHROPIC_DEFAULT_SONNET_MODEL=ml-team/qwen3-8b
export ANTHROPIC_DEFAULT_HAIKU_MODEL=ml-team/qwen3-8b
export CLAUDE_CODE_MAX_OUTPUT_TOKENS=8192
claude

The gateway must be reachable from wherever claude runs. If its address is in-cluster only, forward the gateway’s service to a local port. Envoy Gateway names that service after the Gateway it belongs to and appends a hash, so select it by label rather than by name, against the cluster the gateway runs on:

bash
kubectl -n envoy-gateway-system port-forward 8080:80 \
  "$(kubectl -n envoy-gateway-system get svc -o name \
     -l gateway.envoyproxy.io/owning-gateway-name=fleet-gateway)"

Then point ANTHROPIC_BASE_URL at the local port:

bash
export ANTHROPIC_BASE_URL="http://localhost:8080/anthropic"