Run Codex, Claude Code, or OpenCode through Praxis and vLLM
On this page
This guide runs a real coding client through a local Praxis gateway while vLLM provides inference. The supported paths are:
Codex -> Praxis /v1/responses -> vLLM /v1/responses (native)
Codex -> Praxis /v1/responses -> vLLM /v1/chat/completions (translated)
Claude Code -> Praxis /v1/messages -> vLLM /v1/messages (native)
Claude Code -> Praxis /v1/messages -> vLLM /v1/chat/completions (translated)
OpenCode -> Praxis /v1/chat/completions -> vLLM /v1/chat/completions (native)
One gateway can serve all three clients over their native paths at once; see Serving all three clients from one gateway.
Use the native paths when vLLM exposes the corresponding Responses or
Anthropic-compatible API. For Codex, Praxis keeps /v1/responses end to end
and lowers only client-owned tool types that vLLM does not understand. Use a
translated path for a backend that only exposes OpenAI Chat Completions.
1. Start vLLM
On-demand GPU endpoint
In GitHub Actions, open vLLM Dev Endpoint, choose Run workflow, and keep
the defaults for Qwen3-8B. The workflow provisions a temporary GPU, publishes a
Cloudflare URL in the run summary, and tears everything down at the requested
deadline or when the run is cancelled. Its defaults already serve Qwen3-8B with
--reasoning-parser qwen3 and thinking off, which is what the coding clients
below expect; change the reasoning_parser and enable_thinking inputs only
for a different model or when you specifically want visible reasoning.
Record the two summary values:
export VLLM_URL=https://example.trycloudflare.com
export VLLM_MODEL=qwen3-8b
The Quick Tunnel is an unauthenticated, best-effort development endpoint. Treat its random URL as temporary sensitive data, do not use it for production, and cancel the workflow as soon as testing is complete.
Existing or local vLLM
If vLLM is already reachable, set the same variables to its URL and exact served model name. A keyed local server can be started with:
export VLLM_API_KEY="$(openssl rand -hex 32)"
vllm serve Qwen/Qwen3-8B \
--served-model-name qwen3-8b \
--max-model-len 32768 \
--enable-auto-tool-choice \
--tool-call-parser hermes \
--reasoning-parser qwen3 \
--default-chat-template-kwargs '{"enable_thinking":false}' \
--gpu-memory-utilization 0.97 \
--enforce-eager \
--api-key "$VLLM_API_KEY"
The same server can be run from the official
vllm/vllm-openai image. Its
entrypoint already starts the server, so the arguments are the vllm serve
flags used above, and vLLM reads the backend key from VLLM_API_KEY in the
container environment instead of the command line:
export VLLM_API_KEY="$(openssl rand -hex 32)"
docker run --rm --name vllm \
--gpus all \
--ipc=host \
-p 8000:8000 \
-v "$HOME/.cache/huggingface:/root/.cache/huggingface" \
-e VLLM_API_KEY \
docker.io/vllm/vllm-openai:latest \
--model Qwen/Qwen3-8B \
--served-model-name qwen3-8b \
--max-model-len 32768 \
--enable-auto-tool-choice \
--tool-call-parser hermes \
--reasoning-parser qwen3 \
--default-chat-template-kwargs '{"enable_thinking":false}' \
--gpu-memory-utilization 0.97 \
--enforce-eager
With Podman, replace --gpus all with --device nvidia.com/gpu=all and leave
the rest unchanged. Pin a released tag rather than latest for reproducible
behavior; the on-demand GPU endpoint workflow uses v0.29.0-cu129. The first
run downloads a multi-gigabyte image plus the model weights, and the Hugging
Face cache mount keeps the weights for later runs.
Use VLLM_URL=http://127.0.0.1:8000 for either local server.
Reasoning flags matter for Claude Code
The two reasoning-related flags above are deliberate for this setup and are the main difference from the flags CI serves:
--reasoning-parser qwen3matches the served model family. The parser name selects how vLLM splits a<think>block out of the completion intoreasoning_content; a parser written for a different family can leave that text in the assistant message instead.--default-chat-template-kwargs '{"enable_thinking":false}'turns Qwen3’s thinking off at the chat-template level, so the model does not emit a<think>block for the agentic turns Claude Code drives in the first place.
Serving Qwen3-8B with --reasoning-parser deepseek_r1 and thinking left on
made Claude Code’s plan mode loop endlessly, emitting reasoning text into the
rendered plan instead of a finished one. Switching to the two flags above
resolved it. They were verified together, so change them as a pair; if you need
visible reasoning for other work, expect agentic clients to degrade.
A nightly GPU regression guards this pairing. The
vllm-gpu-claude-acceptance job drives a read-only planning turn through the
native Anthropic path and fails if the run exhausts its turn budget instead of
answering, or if a <think> delimiter reaches the user-visible text. The
scenario is pinned in [claude_code.launch.planning] of
tests/integration/fixtures/claude-code-cli/pin.toml; reverting either flag
above is expected to turn it red. It is a headless approximation of the turn
that broke, not interactive plan mode, which the CLI does not expose to
claude -p.
The 32,768-token window is intentional for Claude Code auto mode. Its client-initiated safety classifier reserves 2,112 output tokens independently of the main Claude Code output-token setting and includes a large client-owned prompt; 16K and 18K servers reject later classifier turns before inference. On an A10G, eager execution reclaims CUDA-graph memory and the 0.97 utilization is reserved for this single-user development workload. If you lower the window or share the GPU, do not use auto mode unless the classifier request still fits.
The window is small enough that Claude Code’s default output budget does not fit
beside a working prompt, so section 4 also sets CLAUDE_CODE_MAX_OUTPUT_TOKENS.
2. Point a Praxis example at vLLM
Choose one example for your client and copy it outside examples/:
# Codex, native Responses API (preferred for vLLM)
cp examples/configs/openai/responses/client-tool-compat.yaml praxis-vllm.yaml
# Codex, translated to Chat Completions
cp examples/configs/openai/responses/codex-http-chat-translation.yaml praxis-vllm.yaml
# Claude Code, native Anthropic API (preferred for vLLM), or OpenCode
cp examples/configs/anthropic/messages-native-vllm.yaml praxis-vllm.yaml
# Claude Code, translated to Chat Completions
cp examples/configs/anthropic/messages-to-openai-vllm.yaml praxis-vllm.yaml
OpenCode was tested with the
native Claude configuration.
It declares an explicit /v1/chat/completions route that forwards to vLLM
without body translation, and Anthropic validation is scoped to /v1/messages.
The same gateway can serve both Claude Code and OpenCode, provided vLLM
supports their respective endpoints.
Serving all three clients from one gateway
One proxy process can serve all three CLIs over their native paths at once.
There is no example config for this, because it is the two native examples
above placed in one file: the Claude Code and OpenCode chain from
anthropic/messages-native-vllm.yaml and the Codex chain from
openai/responses/client-tool-compat.yaml, behind two listeners, pointed at
the same vLLM server and sharing one GATEWAY_AUTH_PASSWORD.
listeners:
# Claude Code (/v1/messages) and OpenCode (/v1/chat/completions).
- name: messages-chat-gateway
address: "127.0.0.1:8080"
filter_chains: [claude-and-opencode]
# Codex (/v1/responses).
- name: responses-gateway
address: "127.0.0.1:8081"
filter_chains: [codex-responses]
filter_chains:
- name: claude-and-opencode
filters: # ... from anthropic/messages-native-vllm.yaml
- name: codex-responses
# Prefix with the same basic_auth filter the chain above uses, so one
# shared credential gates both listeners. Give this chain's cluster its
# own name; the endpoint is the same vLLM server.
filters: # ... from openai/responses/client-tool-compat.yaml
Keep Codex on a listener of its own. Its rich client tools need
openai_client_tool_compat, which needs openai_stream_events, which fails
closed outside an iterative_request_router step. The IRR is a terminal filter
that cannot share a chain with a top-level load_balancer, so everything on
that listener would have to route through it — and the IRR buffers a
sub-request response unless a filter selects streaming, which the native
/v1/messages and /v1/chat/completions paths do not. Routing them through it
would hold each turn’s SSE until the turn finished, so Claude Code and OpenCode
would render nothing until completion. Because every CLI configures its own
base URL, the second port costs one line of client config.
Point Claude Code and OpenCode at http://127.0.0.1:8080 and Codex at
http://127.0.0.1:8081/v1, then follow sections 3 to 5 unchanged with two
exceptions: Codex now sits behind basic_auth and must present the shared
Basic credential rather than a Bearer key — see
Codex behind the gateway’s Basic auth —
and from the container image the store needs the same absolute database_url
as the native Codex example, see
the writable database path note.
The native Codex example uses 127.0.0.1:3001 as its fixture backend; change
that endpoint to 127.0.0.1:8000 for the local vLLM server. The other examples
already target port 8000. For the on-demand HTTPS endpoint, remove https://
from VLLM_URL and replace the selected example’s vLLM backend endpoint with:
endpoints:
- "example.trycloudflare.com:443"
tls:
sni: "example.trycloudflare.com"
For the translated Codex example, replace the fixed provider credential with the same environment-backed form used by the Claude examples:
- filter: credential_injection
clusters:
- name: codex-chat-provider
header: Authorization
env_var: VLLM_API_KEY
header_prefix: "Bearer "
strip_client_credential: true
Set a backend key. It must match --api-key for a keyed vLLM server; any random
value is sufficient for the unauthenticated dev endpoint:
export VLLM_API_KEY="${VLLM_API_KEY:-$(openssl rand -hex 32)}"
export GATEWAY_AUTH_PASSWORD="$(openssl rand -hex 24)"
Run Praxis from source
Build and start Praxis. Running it in the background keeps the two generated values in the shell that will launch the client. The SQLite feature enables the local response store used by the native Codex example and is harmless for the other paths:
cargo build -p praxis-ai-proxy --no-default-features \
--features standard,openai-all,store-sqlite
./target/debug/praxis-ai -c praxis-vllm.yaml > /tmp/praxis-vllm.log 2>&1 &
export PRAXIS_PID=$!
Praxis listens at http://127.0.0.1:8080.
Run Praxis from a container image
The published image leaves experimental Responses and the agentic loop out. Build an image with an explicit opt-in for the examples in this guide:
docker build -f Containerfile -t praxis-ai-responses \
--build-arg PRAXIS_AI_FEATURES=full,openai-all,store-sqlite .
The image entrypoint is praxis-ai, its working directory is /etc/praxis,
and it runs as the unprivileged praxis user (UID 100). Mounting the config at
/etc/praxis/praxis.yaml is enough — the binary falls back to praxis.yaml in
the working directory — but passing -c is clearer and works for any mount
path. GATEWAY_AUTH_PASSWORD and VLLM_API_KEY are read from the proxy
process environment at pipeline build time, so they must be forwarded into the
container, not merely exported on the host. The z mount suffix relabels the
config for SELinux hosts such as Fedora and RHEL; drop it elsewhere.
Host networking is the closest match to the source build and needs no config
edits at all, because 127.0.0.1 inside the container is the host loopback for
both the listener and the vLLM backend:
docker run -d --rm --name praxis-vllm \
--network host \
-e VLLM_API_KEY -e GATEWAY_AUTH_PASSWORD \
-v "$PWD/praxis-vllm.yaml:/etc/praxis/praxis.yaml:ro,z" \
praxis-ai-responses -c /etc/praxis/praxis.yaml
Praxis listens at http://127.0.0.1:8080, and sections 3 through 5 apply
unchanged. Docker’s --network host is Linux-only.
To publish a port instead of sharing the host network stack, make three edits to the copied config:
- Change the listener to
address: "0.0.0.0:8080". A listener bound to127.0.0.1inside the container’s own namespace is unreachable through a published port. - Change the local vLLM endpoint from
127.0.0.1:8000tohost.docker.internal:8000, and pass--add-host=host.docker.internal:host-gatewayon the command line. - Add
allow_private_upstreams: truebeside the existingallow_private_endpoints: true. The existing option covers literal private endpoints written in the config; the new one is needed because that hostname resolves to a private address, and without it the request fails withupstream hostname resolved to private/reserved IP address.
docker run -d --rm --name praxis-vllm \
-p 8080:8080 \
--add-host=host.docker.internal:host-gateway \
-e VLLM_API_KEY -e GATEWAY_AUTH_PASSWORD \
-v "$PWD/praxis-vllm.yaml:/etc/praxis/praxis.yaml:ro,z" \
praxis-ai-responses -c /etc/praxis/praxis.yaml
With Podman, both commands work unchanged. Podman also resolves
host.containers.internal on its own, so the published-port config can use
that name and drop --add-host.
The on-demand GPU endpoint needs no backend-specific container handling: the
tunnel hostname is public, so the endpoints and tls.sni edits above are the
only vLLM-side change with either networking mode.
Follow startup with docker logs -f praxis-vllm. For an explicit readiness
probe, add an admin listener to the copied config:
admin:
address: "127.0.0.1:9901"
The admin endpoint must bind loopback unless
insecure_options.allow_public_admin: true is set, so query it from inside the
container with
docker exec praxis-vllm wget -qO- http://127.0.0.1:9901/healthy (or directly
from the host under --network host). When finished, replace the kill in
the cleanup section with docker rm -f praxis-vllm.
The native Codex example needs a writable database path
The custom image above includes openai-all,store-sqlite, so
client-tool-compat.yaml — the preferred native Codex example — is available. The other three
configurations in this section have no store filter and need no change at all.
The one edit the store example does need is an absolute database_url. The
image’s /etc/praxis working directory is root-owned while the process runs as
praxis (UID 100), so the example’s relative sqlite://responses.db?mode=rwc
cannot be created: the proxy starts, and the first /v1/responses request
fails with HTTP 500 and unable to open database file. Point database_url at
/var/lib/praxis, the writable state directory owned by the container user:
- filter: openai_responses_store
backend: sqlite
database_url: "sqlite:///var/lib/praxis/responses.db?mode=rwc"
docker run -d --rm --name praxis-vllm \
--network host \
-e VLLM_API_KEY -e GATEWAY_AUTH_PASSWORD \
-v "$PWD/praxis-vllm.yaml:/etc/praxis/praxis.yaml:ro,z" \
praxis-ai-responses -c /etc/praxis/praxis.yaml
The database then lives in the container’s writable layer and disappears with
--rm, which is usually what a CLI test loop wants. To keep responses across
restarts, mount a volume at /var/lib/praxis: -v praxis-state:/var/lib/praxis
with Podman adds :U to chown it to the container user, and a host directory
needs chown 100:100 (or -m 0777) before the first run.
The stock image rejects these experimental filters at startup. A custom image
can also serve this example with PostgreSQL by selecting store-postgres
instead of store-sqlite; see
examples/configs/openai/responses/response-store-postgres-mtls.yaml.
3. Connect Codex
Use an isolated Codex home so this test does not replace normal settings:
export CODEX_HOME="$(mktemp -d)"
# Native /v1/responses passes this credential through to keyed vLLM.
export PRAXIS_API_KEY="$VLLM_API_KEY"
cat > "$CODEX_HOME/config.toml" <<EOF
model = "$VLLM_MODEL"
model_provider = "praxis"
web_search = "disabled"
[model_providers.praxis]
name = "Local Praxis"
base_url = "http://127.0.0.1:8080/v1"
wire_api = "responses"
env_key = "PRAXIS_API_KEY"
EOF
codex exec --skip-git-repo-check \
"Inspect this directory, create praxis-vllm-check.txt, then summarize the change."
With the preferred native example, Codex sends Responses API traffic to Praxis,
which forwards it to vLLM’s /v1/responses endpoint. Praxis lowers and restores
Codex-specific client tool types but does not translate the inference request to
Chat Completions.
If you selected the translated Codex example instead, set
PRAXIS_API_KEY=local-codex-client-key. Praxis then translates Responses to
Chat Completions, injects VLLM_API_KEY, and streams the translated response
back.
Codex behind the gateway’s Basic auth
The native Codex example has no basic_auth filter, so the client credential
above is the backend credential. Put Codex behind a gateway that
authenticates clients — as the Claude examples do, and as the
all-in-one gateway does — and it
must present the Basic credential instead. Codex builds its provider headers
from http_headers and env_http_headers independently of env_key, and
applies the env_key bearer by appending Authorization rather than
replacing it, so setting both sends two Authorization headers. Omit env_key
entirely and carry the credential in env_http_headers:
export PRAXIS_GATEWAY_AUTH="Basic $(printf 'gateway:%s' "$GATEWAY_AUTH_PASSWORD" | base64)"
[model_providers.praxis]
name = "Local Praxis"
base_url = "http://127.0.0.1:8081/v1"
wire_api = "responses"
env_http_headers = { Authorization = "PRAXIS_GATEWAY_AUTH" }
With no env_key, no experimental_bearer_token, and requires_openai_auth
left at its default, Codex resolves an unauthenticated provider and contributes
no auth headers of its own. The isolated CODEX_HOME is load-bearing rather
than merely tidy here: a logged-in Codex home would supply ambient credentials
that this provider would otherwise inherit.
basic_auth then verifies the client credential and strips it, and
credential_injection supplies VLLM_API_KEY toward vLLM — the same
three-credential separation the Claude examples use.
4. Connect Claude Code
The Claude examples authenticate the client with Basic auth and independently
replace its Anthropic key with VLLM_API_KEY toward vLLM:
export ANTHROPIC_BASE_URL=http://127.0.0.1:8080
export ANTHROPIC_API_KEY=local-claude-client-key
export ANTHROPIC_CUSTOM_HEADERS="Authorization: Basic $(printf 'gateway:%s' "$GATEWAY_AUTH_PASSWORD" | base64)"
export ANTHROPIC_MODEL="$VLLM_MODEL"
export ANTHROPIC_DEFAULT_MODEL="$VLLM_MODEL"
export ANTHROPIC_DEFAULT_OPUS_MODEL="$VLLM_MODEL"
export ANTHROPIC_DEFAULT_SONNET_MODEL="$VLLM_MODEL"
export ANTHROPIC_DEFAULT_HAIKU_MODEL="$VLLM_MODEL"
export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
# Keep the requested output budget inside the 32,768-token vLLM window.
export CLAUDE_CODE_MAX_OUTPUT_TOKENS=8192
# Required if you select Claude Code's auto permission mode with this backend.
export CLAUDE_CODE_AUTO_MODE_SERVER=0
claude --model "$VLLM_MODEL"
# To exercise client-classified auto mode explicitly:
# claude --permission-mode auto --model "$VLLM_MODEL"
GATEWAY_AUTH_PASSWORD and VLLM_API_KEY must be present in the environment of
the Praxis process. The other variables configure Claude Code.
The translated messages-to-openai-vllm.yaml route enables
allow_lossy_features: [prompt_caching, extended_thinking], so you do not
need to disable Anthropic thinking or prompt caching in Claude Code. Chat
Completions cannot represent either feature, so Praxis strips the wire markers an
unmodified client sends and reports the loss (a WARN log, the
praxis_anthropic_messages_to_chat_completions_degraded_total counter, and an
x-degraded-features response header) instead of rejecting the request.
To see the strict behavior instead — a 400 when either feature appears — use the
messages-to-openai.yaml route (empty allowlist) and disable both features in
Claude Code:
export CLAUDE_CODE_DISABLE_THINKING=1
export DISABLE_PROMPT_CACHING=1
CLAUDE_CODE_MAX_OUTPUT_TOKENS is required for a 32,768-token server. Claude
Code does not know the served model’s real window, so it sends the default
max_tokens for the Anthropic model name it believes it is calling — around
21,000 tokens. vLLM reserves max_tokens against --max-model-len before
inference, so that budget plus a modest prompt exceeds the window and the
request fails with HTTP 400 even though the prompt itself is small. Capping the
budget at 8192 leaves roughly 24K for input. Claude Code still sizes its own
auto-compaction against the window it assumes, not the real one, so run
/compact by hand if a long session creeps back into the limit. A larger window
is the alternative, but Qwen3-8B is natively 32,768 tokens and anything beyond
it needs --rope-scaling on vllm serve.
Praxis and vLLM do not implement Anthropic’s server-side auto-mode classifier
protocol. CLAUDE_CODE_AUTO_MODE_SERVER=0 makes Claude Code initiate the
classifier model requests through Praxis instead. This setting only affects
Claude Code’s auto permission mode; the classifier still consumes model
inference and is not an on-device check. Do not set it to 1 for this setup.
See Anthropic’s auto-mode classifier documentation.
5. Connect OpenCode
This example targets OpenCode 1.x. Check your version with opencode --version.
OpenCode 2.x requires a different plugin implementation; see the
OpenCode migration guide.
Use the native Claude configuration from section 2. It forwards OpenCode’s
/v1/chat/completions requests to vLLM and authenticates clients with Basic
auth using username gateway and password from GATEWAY_AUTH_PASSWORD.
Add this configuration to
~/.config/opencode/opencode.jsonc, merging the plugin and provider
entries with any existing settings:
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["./praxis-auth.ts"],
"provider": {
"praxis": {
"npm": "@ai-sdk/openai-compatible",
"name": "Praxis",
"options": { "baseURL": "http://127.0.0.1:8080/v1" },
"models": {
"qwen3-8b": {
"name": "Qwen3 8B",
"limit": { "context": 32768, "output": 8192 }
}
}
}
}
}
Replace qwen3-8b with the exact served name in VLLM_MODEL and adjust the
limits to match your vLLM server. For a remote Praxis gateway, replace the
baseURL with its HTTPS address, keeping the /v1 suffix. Use HTTP only for
a loopback gateway. Export the same URL from your shell so the plugin can
verify the destination independently of project configuration:
export PRAXIS_BASE_URL="https://gateway.example.com/v1"
For the local http://127.0.0.1:8080/v1 example, no URL export is needed.
Create ~/.config/opencode/praxis-auth.ts:
export default async () => ({
config: async (config) => {
const options = config.provider.praxis.options
const trustedURL = process.env.PRAXIS_BASE_URL ?? "http://127.0.0.1:8080/v1"
if (options.baseURL !== trustedURL) {
throw new Error("Praxis baseURL does not match the trusted gateway")
}
const password = process.env.GATEWAY_AUTH_PASSWORD
if (!password) throw new Error("GATEWAY_AUTH_PASSWORD is required for Praxis")
const token = Buffer.from(`gateway:${password}`).toString("base64")
options.headers = { ...options.headers, Authorization: `Basic ${token}` }
},
})
The plugin checks the merged provider URL before attaching Basic auth with
username gateway. Set PRAXIS_BASE_URL only to a gateway you trust; it must
exactly match baseURL, including any trailing slash.
The guard withholds the credential; it does not stop the request. Verified against OpenCode 1.18.35: when the
confighook throws — abaseURLmismatch, or an unsetGATEWAY_AUTH_PASSWORD— OpenCode logsplugin config hook failedand continues, sending the turn with noAuthorizationheader. It exits0and the event stream ends instep_finish, so the run looks successful. The error is only visible if you passed--print-logs.Against a real Praxis gateway the unauthenticated request is rejected with
401, so the turn does fail — but it fails as a gateway error rather than as a plugin error. Treat the destination check as defence in depth, not as an enforcing control, and read a sudden401as a possible plugin failure.
Launch OpenCode from the shell where GATEWAY_AUTH_PASSWORD was exported in
section 2. It must match the password in the Praxis process environment;
OpenCode does not need VLLM_API_KEY:
opencode --model "praxis/$VLLM_MODEL"
Quit and restart OpenCode after changing its configuration or plugin. You can
also select the configured model with /models.
Headless invocation
opencode run drives a single turn non-interactively, which is what CI and
scripts want:
opencode run --model "praxis/$VLLM_MODEL" --format json -- "Summarize README.md"
The prompt is a positional argument. There is no -p prompt flag: on run,
-p/--password is the OpenCode server Basic-auth password, so copying the
claude -p "..." habit silently passes your prompt as a password. Use -- to
separate the prompt from the flags.
--format json emits raw JSON events, one per line, instead of formatted
output. A successful turn emits step_start, one or more text events
(assistant text is at part.text), then step_finish; a failed turn emits an
error event carrying statusCode and the resolved request URL. --print-logs
and --log-level DEBUG are global flags (not run flags) and send diagnostics
to stderr — use them to confirm which baseURL the provider actually resolved,
and to surface plugin failures, which are otherwise silent.
Redirect stdin from /dev/null. If stdin stays open, opencode run blocks
indefinitely after logging init — before it issues a single request — even
though the prompt was supplied as an argument. In a script or CI job that
presents as a hang with no output and no network activity.
Alternative: per-model headers without a plugin
The plugin above exists because provider.<id>.options has no headers key —
it accepts only apiKey, baseURL, enterpriseUrl, setCacheKey, timeout,
headerTimeout, and chunkTimeout. But a per-model headers map is part
of the published schema, so the gateway credential can be attached declaratively
and the plugin skipped entirely:
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"praxis": {
"npm": "@ai-sdk/openai-compatible",
"name": "Praxis",
"options": { "baseURL": "http://127.0.0.1:8080/v1" },
"models": {
"qwen3-8b": {
"name": "Qwen3 8B",
"limit": { "context": 32768, "output": 8192 },
"headers": { "Authorization": "Basic <base64 of gateway:password>" }
}
}
}
}
}
Pair it with opencode --pure, which runs without external plugins. The model
object rejects unknown keys, so a misspelling is a hard startup error rather
than a silently ignored setting.
Prefer the plugin for interactive use. It reads the password from the
environment at runtime and refuses to attach the credential if a project-level
config has repointed baseURL at another host. The headers map does neither:
it bakes a base64 credential into a file on disk and will send it wherever
baseURL happens to point. Use it for CI and ephemeral credentials, not for a
developer’s ~/.config.
Cleanup
When finished, stop local Praxis and cancel the on-demand endpoint workflow:
kill "$PRAXIS_PID"
Troubleshooting
401from Praxis on the Claude or OpenCode path: verify the Basic authorization header and that the client’sGATEWAY_AUTH_PASSWORDmatches the gateway’s password.opencode runhangs after logginginitand nothing reaches Praxis: stdin is still open. Redirect it from/dev/null. This is not a network problem — OpenCode needs no egress beyond Praxis itself, and completes normally in a loopback-only network namespace.- OpenCode sends requests with no
Authorizationheader and the gateway answers401, with no plugin error in the default output: thepraxis-auth.tsconfighook threw and was swallowed, so no auth was attached. The turn fails, but the401reads like an ordinary gateway rejection rather than a plugin failure. Re-run with--print-logsand look forplugin config hook failed. Usual causes are an unsetGATEWAY_AUTH_PASSWORDor aPRAXIS_BASE_URLthat does not exactly match the mergedbaseURL. - OpenCode reaches api.openai.com instead of Praxis, or asks for
OPENAI_API_KEY: the provideroptionswere not applied, which upstream has reported for both the bundled adapter and sub-sessions. Confirm the resolvedbaseURLwith--print-logs --log-level DEBUGand pin a known-good version. 401from vLLM:VLLM_API_KEYdoes not match the key passed to vLLM.401from Praxis on a Codex path gated bybasic_auth:env_keyis probably still set alongsideenv_http_headers. Codex appends theenv_keybearer rather than replacing the configured header, so the request carries twoAuthorizationheaders andbasic_authdoes not see the Basic one it expects. Removeenv_keyfrom the provider block. A stale logged-inCODEX_HOMEcan do the same thing; use themktemp -dhome from section 3.- Model not found: use the exact slash-free served name, normally
qwen3-8b. 400withrequest body is not JSONon a request that has no body, such asGET /v1/models, logged asrequest body rejected by filter filter="openai_responses_format": a Responses classifier is running unconditioned on every request the listener accepts, and an empty body classifies as non-JSON, whichon_invalid: rejectturns into a 400 before routing. Both Codex examples in section 2 lead with the head-drivenopenai_responses_requestinstead, which releases any operation it does not recognize without reading a body. A config derived from an older copy needs the same swap: drop the leadingopenai_responses_formatand letopenai_responses_requestcarry theon_invalidandheadersthat classifier used to carry. Do not reach foron_invalid: continueinstead: it clears the probe but also forwards genuinely malformed Responses bodies to vLLM rather than rejecting them at the gateway.- RFC 9457
application/problem+jsonwhere an OpenAI client expects{"error": {...}}: the chain is missingai_operation. The body classifiers each resolve one protocol —openai_responses_requestcovers Responses, andanthropic_messages_requestinstalls its formatter only on the Anthropic Messages surface — so neither owns the protocol decision for the Chat Completions traffic a coding client also sends, and the request reaches core’s error path with no formatter installed.ai_operationclassifies from the request head and installs the matching formatter for every OpenAI and Anthropic protocol; put it directly afterbasic_auth, as the Codex examples and the native Claude Code and OpenCode example do. This bites OpenCode hardest: it is the one client whose traffic (/v1/chat/completions) shares a listener with another protocol, so a config derived from an older copy sends it problem details its SDK cannot parse. 400withmaximum context length is 32768 tokensand a requested output count near 21,000: Claude Code’s default output budget does not fit the window. SetCLAUDE_CODE_MAX_OUTPUT_TOKENS=8192as shown in section 4.400withmaximum context length is 32768 tokenson the first turn, with a reported input count near 28,000 and a modest output count: the output cap from the previous entry is already in effect and the startup prompt itself is the problem. Claude Code loads MCP tool definitions, plugin skills, andCLAUDE.mdbefore the session begins, so an empty conversation can consume most of the window. Run/contextfor the breakdown; it is a local command and still works while every request is failing. Start Claude Code with--strict-mcp-configto drop globally configured MCP servers, disable unneeded plugins with/plugin, and prefer a working directory whoseCLAUDE.mdis small or absent.- Claude Code never finishes a turn — plan mode keeps looping, or reasoning
text appears in the answer or the rendered plan: the server may be emitting
thinking that the client is not meant to see, which can desynchronize the
agentic loop. Serve Qwen3 with
--reasoning-parser qwen3and--default-chat-template-kwargs '{"enable_thinking":false}'as shown in section 1, and restart vLLM; the reasoning parser is a server flag, so nothing on the Praxis or client side changes it. On the on-demand endpoint, check thereasoning_parserandenable_thinkinginputs of the run. - Auto mode fails with
Error: You are not in plan mode. To enter plan mode, call the EnterPlanMode tool first.: treat this as a symptom, not a root cause. It can be a plain Claude Code plan-state failure, or reasoning text bleeding into the response and desynchronizing the agentic loop. Check whether reasoning text is also present before applying the Qwen3 settings above. - TLS or connection failure: use only the tunnel hostname in the endpoint and
tls.sni; do not includehttps://in Praxis’sendpointsentry. - Connection refused on 8080 from another machine, while vLLM on 8000 answers:
every example in section 2 binds the listener to
127.0.0.1:8080, so Praxis is reachable only from its own host. This is independent of how Praxis runs —--network hostneeds the sameaddress: "0.0.0.0:8080"edit as the published-port path, not just the port mapping. After that edit, open 8080 on the host firewall; a loopback-only listener refuses the connection immediately, whereas a blocked port usually hangs or reports no route. Binding to0.0.0.0exposes the gateway to the network, so keep the authentication filter in place and terminate TLS in front of it — the shared Basic password is otherwise readable on the wire. A second listener, such as the Codex one in the all-in-one gateway, needs the same treatment plus its own-p 8081:8081under the published-port container path. - Claude startup probes may call
/v1/messages/count_tokens. Native vLLM supports it; the translated Chat path can return 404 and Claude degrades gracefully.