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=qwen"
                - "--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 endpoint’s public address from the ModelService status:

bash
ADDRESS=$(kubectl get ms qwen3-8b -n ml-team -o jsonpath='{.status.address}')

Post to /v1/messages in the Messages API shape. The model field is the engine’s --served-model-name (qwen); max_tokens is required:

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

status.address is the in-cluster gateway address, so run the request from inside the cluster if your shell can’t reach it directly:

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

Point Claude Code at it

Claude Code appends /v1/messages to ANTHROPIC_BASE_URL, so point it at the service address and map its model tiers onto the served name. vLLM doesn’t check the auth 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"
export ANTHROPIC_AUTH_TOKEN=dummy
export ANTHROPIC_DEFAULT_OPUS_MODEL=qwen
export ANTHROPIC_DEFAULT_SONNET_MODEL=qwen
export ANTHROPIC_DEFAULT_HAIKU_MODEL=qwen
export CLAUDE_CODE_MAX_OUTPUT_TOKENS=8192
claude

The gateway must be reachable from wherever claude runs. If $ADDRESS is in-cluster only, forward the Traefik gateway service to a local port:

bash
kubectl -n traefik-system port-forward svc/traefik 8080:80

Then point ANTHROPIC_BASE_URL at the local port, keeping the service’s /<namespace>/<service> path prefix so Claude Code’s /v1/messages still routes:

bash
export ANTHROPIC_BASE_URL="http://localhost:8080/ml-team/qwen3-8b"