Troubleshooting
Start at /admin/health. One call names the failing leg.
Check deployment health
/admin/health probes every leg of the deployment live and tells not configured apart from
configured but unreachable:
curl -fsS "https://<your-gateway>/admin/health" \
-H "Authorization: Bearer $ANYRAY_ADMIN_TOKEN"
200 means every required leg is up. 503 means a required leg is down, and its name is in
the response body. Only required legs can turn the check red; a down optimizer or a lagging schema
never fails it.
| Leg | What it checks | Required | If it's the failing leg |
|---|---|---|---|
gateway | The process is serving requests | No | A response at all means it's up |
observability | Trace store reachable, or intentionally not configured | When configured | Check ANYRAY_OBSERVABILITY_DB_URL and the Langfuse stack |
spend | Spend store (Postgres) answers a live SELECT 1. spend.auth names how this process authenticates: password or rds-iam | Yes | Check ANYRAY_SPEND_DB_URL and that Postgres is reachable |
schema | Boot migrations applied. state says where the database sits relative to this image | No · informational | See the schema leg reports drift below |
optimizer | Optimizer /health reachable, plus configStore for settings and retrievalStore for durable handle writes | No · fails open | Check the optimizer service and ANYRAY_OPTIMIZER_URL |
inference | This deployment has a working way to serve requests: a provider key (configuredProviders), an upstream you run (byoUpstream), or successful inference already served (servedRequests). defaultRoute names whether requests that send no routing header have a route | No · informational | See inference.ok is false below |
portal | Billing app metering, entitlement lease status, and where this replica's lease came from (leaseSource, leaseTrust, leaseAcquire) | No | Check the Billing app connection (configure); see a replica answers 503 on every request below |
inference.ok is false
inference.ok: false means this deployment has no working lane at all: no server-side provider
key, no upstream you run, and no successful inference served since the replica booted. A deployment
can be green everywhere else and still refuse every request. Developers on the org-key lane get:
424 no provider key configured for anthropic: the request authenticated with
a gateway client key, but the gateway holds no server-side anthropic API
key to forward; add one in the console Providers page
It is a 424, not a 401, on purpose: nothing is wrong with the developer's credential, so
re-running anyray-connect will not help. Add a provider key in the console → Providers. If
some developers work and others do not, the failing ones are on the org-key lane; same fix.
- A subscription-only deployment is healthy here, not broken. Seats riding GitHub Copilot or
their own Claude subscriptions never touch an org key; once such a deployment has served
anything,
servedRequestskeepsoktrue. servedRequestscounts 2xx on inference paths since this replica booted, so zero on a fresh or idle replica is not evidence of a problem; it can only ever makeoktrue, never false.configuredProvidersis a count, not a list. The endpoint never names your vendors, and it readsnullwhen this probe could not open the provider store.defaultRouteis a separate question fromok. A deployment with two provider keys and no default routing config can serve any request that names a provider, sookstays true while every header-free client is refused. See Requests are refused for a missing routing header below.
Common issues
Scan the titles, then expand the one that matches.
Optimizer settings revert after a deploy
A change on the console Optimizer page saves cleanly, then comes back as it was after the next image bump; or two browser tabs disagree about a strategy.
Check optimizer.configStore on /admin/health:
| Value | What it means | What to do |
|---|---|---|
shared | Settings live in Postgres, read by every replica | Nothing; healthy |
per-pod | Settings live on whichever replica served the save, discarded on the next roll | Set ANYRAY_SPEND_DB_URL on the optimizer service, or check that the gateway can reach the optimizer |
refused | A store was supplied but the optimizer cannot read it, so saves are rejected | Check the optimizer can reach the gateway's database (security groups, network policy, credentials) |
probing | Store just handed over, being verified | Wait a few seconds, re-check |
| absent | Optimizer unreachable, or its image predates this field | Check the optimizer leg, then the image tag |
The gateway hands its database URL to the optimizer every 60 seconds, so most deployments reach
shared on their own shortly after boot; a value that stays per-pod means those pushes are not
arriving or not accepted. The Optimizer page shows the same state as a banner before you save.
Claude Desktop will not route, and setup says a machine policy owns it
Claude Desktop treats a managed configuration source as the whole configuration: once a
profile sets any recognized Claude policy key beyond the three app-behavior-only ones
(disableAutoUpdates, autoUpdaterEnforcementHours, disableWslSessions), local values are
ignored, so Connect declines to write settings the app would silently discard.
npx anyray-connect@latest --tools claude-desktop
Connect before 0.11.147 could report a machine policy on a Mac carrying no Claude profile at all. Most machines stop here, and naming the tool leaves everything else untouched.
If it still declines, the policy is real, and Connect already wrote the fixing profile:
~/.anyray/claude-desktop-anyray.mobileconfig (macOS) or
%USERPROFILE%\.anyray\claude-desktop-anyray.reg (Windows). No MDM needed: on macOS, double-click
it and approve under System Settings → General → Device Management (an administrator password,
nothing else). The profile carries no credential; it points at shared helpers, installed once:
sudo anyray-connect desktop helper --write --platform posix --bin /usr/local/bin/anyray-connect
Then fully quit and reopen Claude Desktop (Cmd+Q); it reads configuration once at launch.
The identical profile deploys through Jamf, Intune, or Kandji fleet-wide; to keep it editable
without repushing MDM, generate a bootstrap profile instead
(anyray-connect desktop fleet bootstrap <platform> <https-url>). Both modes:
Managed fleets (MDM).
Claude Code, Codex, and Cursor are configured per user and route immediately; run the normal setup rather than waiting on IT.
“Connection closed mid-response” or an API error naming your gateway host
The host in the error is just the client's ANTHROPIC_BASE_URL (or OPENAI_BASE_URL) rendered
into a fixed template; it is not evidence the gateway caused the failure. The exact wording
names the origin:
| The client says | What actually happened | Where to look |
|---|---|---|
Server error mid-response | the provider dropped the stream and the gateway relayed and labelled it | the provider's status page |
Connection closed mid-response | the TCP connection between the tool and the gateway died | your own edge (below) |
Response stalled mid-stream | the client's own idle watchdog fired | the provider, then latency |
request failed, with no other detail | the upstream refused the request and its message did not survive translation | upgrade past v1.10.319 |
The last row is the one case where the wording is the gateway's fault: translating a Claude-format
client to an OpenAI-format upstream, builds through v1.10.319 read the upstream's failure message
from only one of the places providers put it, so an out-of-credits notice or model-not-found
arrived as the bare words request failed. Until you upgrade, read the upstream's logs.
When the provider closes a stream after it started, the gateway appends a final SSE error
frame naming the origin; a client with nothing rendered yet shows it in full, and otherwise
summarises it as Server error mid-response, still provider-origin:
Anthropic (api.anthropic.com) closed the response stream before it finished. This is an
upstream provider failure, not an Anyray gateway failure: the gateway relayed the stream as
it arrived and the connection was closed from the provider side. Check https://status.claude.ai/
for provider status. Retry the request.
Fix: retry; these are transient. If they cluster, check the provider's status page. Confirm the origin in gateway logs, where a provider-origin close writes a single line:
passthrough stream closed by upstream: upstream=Anthropic class=TypeError code=UND_ERR_SOCKET
A failure that never got a first byte returns a 502 with a status-page hint and logs
passthrough fetch failed. Both are upstream-origin.
No such line, and the wording was Connection closed mid-response? The stream died on your
side. Check the timestamps against a rollout or restart (kubectl get pods restart counts;
lastState.terminated.reason for an OOMKilled), then the read and idle timeouts on every hop in
front of the gateway; they must outlast a single completion (the chart's Ingress values are
streaming-safe by default). The gateway drains in-flight requests for ANYRAY_SHUTDOWN_DRAIN_MS
(default 90s), so give each edge a stop budget above that: stop_grace_period in Compose,
terminationGracePeriodSeconds in the chart, StopTimeout plus deregistration delay on ECS,
RAILWAY_DEPLOYMENT_DRAINING_SECONDS on Railway. Size each service against its own drain: the
optimizer reads the same variable with a 15s default, and end-point control force-exits at 35s.
The error frame exists only for streaming responses. A non-streaming request that dies mid-body surfaces as a bare truncation; the log line above is how you attribute it.
Replies come back blank, or an error says the body has no choices
Your own gateway answered 200 with a body Anyray could not read as an OpenAI chat completion, so
there was no reply to return:
upstream returned 200 with no choices; the body is not an OpenAI chat completion (keys=[data,request_id])
The keys list names the top level of what your gateway sent back, so you can see which format it
answered in. Values are never included.
Earlier builds showed this as an empty reply with zero tokens and no error, which also recorded the request as a success. Anyray already forwards a native Claude response untouched, so the usual cause is a third shape, such as a wrapper around the completion.
Fix: if your gateway serves the Claude Messages API, set the provider to litellm and point
custom_host at that endpoint. Both directions then pass through unchanged. If it does not, check
that custom_host names the chat-completions endpoint itself.
Requests are refused for a missing routing header
A request that names no provider is refused before it is routed:
Send either x-anyray-config or x-anyray-provider, or ask the gateway operator to set a
default route for headerless requests.
Tools configured by anyray-connect send the header themselves, so this is almost always a
hand-written request, or a deployment meant to run without headers whose default route is missing.
Check inference.defaultRoute on /admin/health:
| Value | What it means | What to do |
|---|---|---|
configured | Header-free requests have a route | Nothing; healthy |
no-configured-providers | Nothing is configured to route to | Add a provider in the console → Providers |
multiple-providers-no-default-route | Several providers are configured, so none is the default | Set the default on the console → Routing page |
provider-store-unreadable | This replica could not open the provider store | Check the spend leg; the store is shared |
A single configured provider becomes the default on its own. Adding a second one is what removes it, which is why header-free requests can start failing after a change that looks unrelated.
An upstream error says Not Found, or names a status without a cause
Errors relayed from an upstream carry the status and the endpoint Anyray called:
litellm error (HTTP 404 from https://llm.example.internal/v1/messages): Not Found
The URL is the request Anyray actually made, with any query string removed. Compare it against what your upstream serves:
- The path is right and your upstream does not serve it. The route is missing on that build.
LiteLLM serves the Claude Messages API at
/v1/messageson current versions only. - The path is doubled, or has a prefix you did not expect.
custom_hostis being appended to. Set it to the full endpoint URL instead, which is used as-is when it names the endpoint. - Your upstream serves the endpoint somewhere else entirely, or serves two of them at unrelated
paths. Name the path yourself with
custom_endpoints(Endpoint paths on the provider key in the console) and Anyray stops inferring it. See Naming the paths yourself.
Traces show “—” in Tokens Saved: optimization is paused
Every request is served, but the console's Traces list shows — in Tokens Saved, savings sit
at zero, and an “Optimizations are paused” banner sits on every page.
A billing state, not a bug: when an Anyray trial ends with no active subscription, the signed
entitlement lease pauses the optimizer (optimizerEnabled: false). The gateway keeps proxying
every request unchanged; only the optimize hook is skipped. An image bump can surface it, because
older gateways kept optimizing while lapsed.
Fix: add a payment method in the Anyray billing portal. Optimization resumes on the next lease refresh, usually within minutes, no redeploy. If your workspace is on a contract and shouldn't be on the self-serve trial clock, contact Anyray.
One gateway replica answers 503 on every /v1 request, and its readiness check fails
Symptom. After a rollout, requests landing on one replica fail with 503; the pod is
Running but not Ready, and GET / on it returns AI Gateway awaiting entitlement lease.
A metered gateway serves /v1/* only while it holds a signed entitlement lease. Replicas share it
through the spend database, so a new pod normally adopts its peer's lease within seconds.
Readiness stays 503 only while the pod holds no lease at all; an expired or suspended lease
keeps it Ready (answering the right 402/403).
Check the portal leg on that pod (sh -c so the token expands from the container's
environment; on Compose, docker compose exec gateway in place of the kubectl exec):
kubectl -n "$ANYRAY_NAMESPACE" exec <gateway-pod> -- sh -c \
'wget -qO- --header "Authorization: Bearer $ANYRAY_ADMIN_TOKEN" localhost:8787/admin/health' \
| jq .portal
| Field | Meaning |
|---|---|
lease | active, grace, suspended, or null (none held) |
leaseSource | Where the held lease came from: db (shared by a peer replica), file (this pod's cache), cp (the Billing app) |
leaseTrust | Verdict on the last lease read: trusted, bad_signature, or deployment_mismatch |
leaseAcquire.lastVia / lastOutcome / lastStatus | The last Billing app attempt: meter or entitlement, ending in lease, throttled, rejected (untrusted lease), http_error (status in lastStatus), or network (never connected) |
leaseAcquire.retryInMs | When the next attempt is armed after a throttled or leaseless outcome (null on the regular cadence) |
leaseTrust: deployment_mismatch. The stored lease is bound to another deployment (two gateways sharing oneadt_token, or one spend database shared between deployments); give each its own token and database.bad_signature: the lease does not verify against the pinned Billing app key.lastOutcome: throttledwithlease: nullthat does not clear. The Billing app is shedding load; the pod retries at theRetry-Afterit was given. Leaseless for more than a few minutes: send a support bundle.lastOutcome: network. No path to the Billing app; check egress and any TLS-inspecting proxy (accordion below).http_errorwithlastStatus: 401: the deployment token is invalid or revoked.
These fields carry only the lease status and provenance, never content.
/admin/health returns 503: a required leg is down
Only spend (always) and observability (when configured) can turn the check red. The body names
the one whose ok is false.
spend. ConfirmANYRAY_SPEND_DB_URLpoints at a reachable Postgres that accepts connections.observability. A trace store is configured but unreachable: confirmANYRAY_OBSERVABILITY_DB_URLand the Langfuse stack. If you don't run observability, leave it unconfigured; the leg reportsnot configuredand never gates.
Re-run once the leg is reachable; the check flips back to 200. Configured, reachable, and still
failing: send a support bundle.
IAM database auth is not taking effect
spend.auth is derived from ANYRAY_SPEND_DB_URL, not configured separately, so it is how you
confirm a switch to
RDS IAM authentication
landed:
curl -fsS "https://<your-gateway>/admin/health" \
-H "Authorization: Bearer $ANYRAY_ADMIN_TOKEN" | jq '.spend.auth'
password. A password was found, and a password always wins. The gateway looks everywhere Postgres itself looks: the URL, a?password=parameter,PGPASSWORD, and a matching line in the password file (PGPASSFILE, otherwise~/.pgpass). Remove it, leaving the user (postgresql://appuser@mydb.abc123.us-east-1.rds.amazonaws.com:5432/anyray). A URL with no user at all also readspassword, since IAM tokens are minted per database user.rds-iam. Tokens are minted from the pod's AWS identity. If connections still fail, the IAM role needsrds-db:connectonarn:aws:rds-db:<region>:<account>:dbuser:<db-resource-id>/<db-user>, and the database user needsGRANT rds_iam TO <db-user>.null. The gateway is not backed by Postgres.
A connection string that asks for IAM but cannot be honoured never shows up here: the gateway
refuses to start rather than serve traffic it cannot attribute. Look for the FATAL line in the
startup logs:
| Code | What to change |
|---|---|
RDS_IAM_NO_REGION | The endpoint carries no region AWS recognizes. Set AWS_REGION on the service. |
RDS_IAM_SSL_DISABLED | The URL sets sslmode=disable. Remove it: RDS IAM requires TLS, and the gateway configures it for you. |
RDS_IAM_BAD_PORT | The port in the URL is not a valid port number. |
RDS_IAM_EMPTY_TOKEN | AWS returned no token. Check that the pod has working AWS credentials. |
The schema leg reports drift after an update
The gateway self-migrates its schema on boot, so an image bump is all a schema change needs. The
schema leg reports version (newest migration applied), expected (newest this image ships), and
state. After an update:
curl -fsS "https://<your-gateway>/admin/health" \
-H "Authorization: Bearer $ANYRAY_ADMIN_TOKEN" | jq -e '.schema.state == "converged"'
state | What it means | What to do |
|---|---|---|
converged | The applied schema is the one this image ships | Nothing |
ahead | Another replica on a newer image already migrated. Normal during a rolling deploy or after a rollback, and safe: each release reads and writes the neighbouring version's schema | Nothing. It clears once every replica runs the new image |
behind | This replica's own migration is pending or failed | Give a fresh deploy a moment and re-check. If it persists, read the gateway logs for the migration error |
unknown | The database could not be read, so the applied version is not knowable | Confirm Postgres is reachable (spend.ok) |
none | No Postgres, so nothing to migrate | Nothing |
The leg stays informational: no state fails the overall health check, because a mixed-version
window is expected during every rolling deploy.
schema.converged is still published as a boolean for older scripts. It reads true for both
converged and ahead.
Externalized output can't be pulled back (retrieval is dark)
When Anyray trims an oversized tool output it leaves a · retrieve ctx_… marker and stashes the
original; the model pulls it back through the anyray_retrieve MCP tool. New handle-producing
trims run only while the gateway has a fresh authenticated retrieval lease and the optimizer has
confirmed the span's durable write. If either side is unavailable, Anyray keeps the original span
and safely stands the handle-producing strategy down. A settled decision pin may still replay an
older marker byte-for-byte for provider-cache safety.
If a managed integration remains dark, diagnose it on the affected developer's machine:
anyray-connect doctor --verify
- Tools shows each tool's retrieval MCP-server registration. Not registered usually means
connect last ran from a one-off
npxcache (registration needs a durable install); binary is gone means the registered executable moved. Both: re-runanyray-connect. - Retrieval loop (with
--verify) sends one probe-marked request with a synthetic handle; the healthy verdict is the gateway answering "unknown handle", proving reachability, key auth, and the optimizer's retrieve wiring in one shot. Failures name the leg:auth(enroll / set a personal key),unsupported(gateway image predates the loop; update it),unavailable(optimizer down orANYRAY_OPTIMIZER_URLunset),unreachable(network).
The probe is side-effect-free: the synthetic handle reads nothing, and its
x-anyray-retrieve-probe header never counts as model retrieve capability. Open a marker by hand
with anyray-connect retrieve ctx_…; find one that scrolled away with
anyray-connect recall "describe it".
Operators can inspect optimizer.retrievalStore on /admin/health without touching a developer
machine. ready permits new durable handles. cooldown means the bounded backend circuit is open
and will retry automatically after five minutes. unconfigured and content_disabled keep new
handles off until durable encrypted storage is available. An absent value means the optimizer is
unreachable or predates this field.
anyray-connect doctor --repair re-applies any tool whose gateway routing drifted (a tool update
resetting its config is the usual cause). It only touches tools a previous apply recorded; a tool
you explicitly reverted is never resurrected. The same repair runs automatically whenever Connect's
key refresher notices drift.
Savings look low and the hook lane may be dark
Connect's PostToolUse hook trims oversized tool output before it reaches the model. When that lane
dies nothing errors: the hook exits 0 and config-based checks stay green. So anyray-connect doctor probes the lane itself and prints a Hook lane section:
| State | Meaning and fix |
|---|---|
live | The gateway accepted this machine's key and answered the probe. |
key_rejected | Usually a hand-pasted key that lapsed. On an enrolled machine an expired key needs no action: the next scheduled refresh re-mints from the enrollment certificate and rewrites the tool configs; doctor --repair does the same immediately. It mints a credential, which plain doctor never does (ANYRAY_REFRESH_DISABLE=true forbids it). Only a missing or expired key is ever re-minted, and only when the gateway names the class: a revoked key, an unknown key, and a deactivated user are never re-minted, by --repair either; re-enroll instead. An older gateway sends no class, so nothing re-mints; re-run anyray-connect. |
target_null | No gateway target resolves, so the hook silently sends nothing. Typical on a machine templated by hand; run anyray-connect once. |
never_invoked | Installed and the gateway answers, but nothing ran the hook in over a week. Restart the tool so it re-reads its settings. On OpenCode check the binary path in the generated plugin: it converts a failed launch into empty output silently. |
undetermined | The state could not be established (old gateway protocol, a proxy or WAF, an unreachable host). Informational only: never a problem, never repaired, never reported to the fleet. |
Claude Code's machine-wide managed-settings.json can also kill the lane: disableAllHooks: true
skips every hook, and allowManagedHooksOnly: true drops user hooks (healthy only when the
managed hooks block itself carries the Anyray PostToolUse hook). Neither is repairable from user
scope: ask the MDM owner to drop the key, or deploy the credential-free block that
anyray-connect managed --print --sso <link> --bin <path> prints. Fleet check id: hook_policy
(disabled or restricted_dropping_ours); a clean, absent, unreadable, or malformed managed file
is never a fault.
The probe sends no tool output and carries the same x-anyray-retrieve-probe marker as the
retrieval probe, so it can never book a saving.
Claude Desktop lost its MCP servers, skills, or plugins after switching to Anyray
Third-party inference gives Claude Desktop its own profile, so anything configured in the
first-party one is unread, not deleted. Connect copies local MCP servers, user-created skills, and
user-installed plugins across on every apply lane, MDM reconciles included; skills and plugins land
the next time Claude Desktop is fully quit. Run anyray-connect doctor and read the Claude
Desktop profile section: pending means the copy is queued, not_routed means this machine does
not route Desktop through Anyray at all. Every state is listed in the
Connect reference.
To bring conversation history across as well, fully quit Claude Desktop and run
anyray-connect migrate. Fleet check id: desktop_config_carry; it never counts against
compliance.
Console shows an old version after an upgrade
The deployment version (GET /admin/update-status → version) is baked into the running gateway
image, so a stale number means the new image never rolled, not that reporting is behind: a
helm upgrade or docker compose up -d only restarts the gateway when the rendered spec actually
changes. (schema.version on /admin/health is different: the newest migration applied to your
database.)
# What image is live right now?
kubectl -n "$ANYRAY_NAMESPACE" get deployment anyray-gateway \
-o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'
# Pin the build and roll it out
helm upgrade anyray ./helm -f my-values.yaml --namespace "$ANYRAY_NAMESPACE" \
--set image.tag=vX.Y.Z
kubectl -n "$ANYRAY_NAMESPACE" rollout status deployment/anyray-gateway
On a moving tag (image.tag: latest) pair it with pullPolicy: Always and
kubectl -n "$ANYRAY_NAMESPACE" rollout restart deployment/anyray-gateway, or the node keeps its
cached image. Full flow: Kubernetes → Upgrade. The
console catches up within a couple of minutes of the new pod turning Ready.
One user's tools fail, but health is green
The failure usually lives on that machine: a stale base URL, an expired enrollment, a missing key-renewal schedule. None of that is visible to the gateway, so run the client doctor there:
npx anyray-connect@latest doctor --json > anyray-client-report.json
doctor inspects that shell's own environment. A shell that was open when the user reverted
keeps exporting the gateway with no credential, so every tool launched from it returns
401 valid client key required while every file on disk reads clean. doctor reports that as a
Shell problem with the exact unset to run.
The auth smoke probe runs by default: one tiny request whose verdict distinguishes key
rejected from enrollment required from provider rejected. Config checks alone cannot see a
rejected upstream credential, so an all-clear with --no-verify means "nothing looks
misconfigured", not "this works". The report carries config metadata only: versions, gateway
origin, per-tool pointing state, cert validity window, exported variable names, key
presence; never the key, the cert signature, or any prompt content.
Endpoint security quarantined the key-renewal schedule
doctor reports the key-renewal schedule enabled but missing on a machine whose gateway,
enrollment, and personal key all read clean; tools may also be left unconfigured because the
install was killed partway.
That is an EDR product acting on a persistence heuristic (SentinelOne's
persistence_deception, and equivalents in CrowdStrike and Defender): a login item that restarts
itself is the shape an implant uses. Recent doctor builds name it under Endpoint security
with the quarantined files. Re-running the installer writes the same login item back into
quarantine, and signing does not clear it (the heuristic reads behavior). Unblock the seat first,
then allowlist:
cd ~ && anyray-connect --no-key-refresh # no background service at all
anyray-connect doctor --security-review # the sheet for your security team
--no-key-refresh points tools straight at the gateway, so optimization, metering, and spend
attribution are unchanged; the only cost is a re-run when the short-lived key lapses.
Connect installs the scheduler only where keys rotate faster than a week, or where no configured
tool triggers renewal itself. A seat enrolled before that check may still carry one, and Connect
now retires it on the next background tick, so an alert predating the upgrade clears itself. Once
the paths are allowlisted, anyray-connect --yes restores renewal on a seat that needs it; on one
that does not, it installs nothing and doctor reports Retired. Approve by path and service
name: a file-or-hash approval does not carry, because the binary's hash changes each release. Full
footprint: Key renewal.
Certificate errors, or could not reach the control plane, behind a TLS-inspecting proxy
npx anyray-connect@latest … never reaches the registry: npm exits with
UNABLE_TO_GET_ISSUER_CERT_LOCALLY (or SELF_SIGNED_CERT_IN_CHAIN) against registry.npmjs.org,
on a machine whose browser opens that URL fine.
That is a TLS-inspecting corporate proxy: it re-signs traffic with a private root CA your MDM
installed in the OS trust store. Browsers and curl read that store; Node ships its own CA list
and never reads the OS store, so every Node program (any npm install) fails identically.
Unblock now with the standalone binary channel: no Node, and it reads the OS trust store in
every process it starts. Every flag passes through (doctor, migrate, a plain gateway URL):
- macOS / Linux
- Windows PowerShell
curl -fsSL https://app.anyray.ai/connect.sh | sh -s -- --sso https://app.anyray.ai/sso/<tenantId> --yes
& ([scriptblock]::Create((irm https://app.anyray.ai/connect.ps1))) "--sso" "https://app.anyray.ai/sso/<tenantId>" --yes
Older standalone binaries carried only a bundled CA list: the download succeeded, then the very
next step failed with could not reach the control plane at https://app.anyray.ai. That split
(curl and the binary disagreeing about the same host seconds apart) is the fingerprint;
reinstall with the line above, because the self-updater's check fails the same way.
Then fix Node: Claude Code, the Codex CLI, npm, and the Anthropic and OpenAI SDKs all carry
Node's CA list, and the moment inspection covers your gateway host they fail the same way, naming
your gateway instead of the registry. Export the proxy's root CA to a PEM and point
NODE_EXTRA_CA_CERTS at it; the variable adds to the built-in list, so public certificates keep
verifying:
- macOS / Linux
- Windows PowerShell
# macOS: MDM-installed roots live in the System keychain
security find-certificate -a -p /Library/Keychains/System.keychain > ~/corp-ca.pem
echo 'export NODE_EXTRA_CA_CERTS="$HOME/corp-ca.pem"' >> ~/.zshrc
export NODE_EXTRA_CA_CERTS="$HOME/corp-ca.pem"
Get-ChildItem Cert:\LocalMachine\Root |
Where-Object { $_.Subject -match 'YourProxyVendor' } |
ForEach-Object {
"-----BEGIN CERTIFICATE-----`n" +
[Convert]::ToBase64String($_.RawData, 'InsertLineBreaks') +
"`n-----END CERTIFICATE-----"
} | Set-Content "$env:USERPROFILE\corp-ca.pem"
[Environment]::SetEnvironmentVariable('NODE_EXTRA_CA_CERTS', "$env:USERPROFILE\corp-ca.pem", 'User')
Your security team can usually name the PEM (most proxy vendors publish one); a Connect run never
installs a CA or changes proxy settings itself. Re-run anyray-connect doctor in a new shell:
open terminals and already-started background services keep the old environment.
npm config set strict-ssl false, NODE_TLS_REJECT_UNAUTHORIZED=0, and per-tool equivalents turn
off verification for every connection that process makes, including package downloads,
precisely the channel a supply-chain attack uses. Trusting the CA your organization already
installed costs the same effort.
Adding the CA solves trust, not reachability. If the proxy blocks hosts outright, ask for
registry.npmjs.org (npm), github.com plus release-assets.githubusercontent.com (the binary
channel redirects from the first to the second), app.anyray.ai (enrollment and metering), and
your own gateway origin. anyray-connect doctor --security-review prints the same hosts with
exact URLs and paths.
The installer stops mid-download with a connection error
The one-liner downloads a 100 MB binary. On a proxied, VPN'd or otherwise unreliable link the connection can drop part-way through, and the installer stops before it ever enrolls:
anyray-connect: downloading anyray-connect-windows-x64.exe...
Invoke-WebRequest : The request was aborted: The connection was closed unexpectedly.
On macOS and Linux the same drop reads curl: (18) transfer closed with N bytes remaining to read.
Re-run the same command. Current installers resume from the bytes already downloaded and print
transfer interrupted at … resuming (attempt 2 of 4), so a drop costs seconds rather than the whole
download. Nothing unverified ever runs: the binary is checked against the release SHA-256 either
way, and a truncated or spliced file is refused.
If every attempt dies at the same point, a proxy is cutting the transfer rather than the network.
Allowlist github.com and release-assets.githubusercontent.com (the download redirects from the
first to the second), or install through npm, which fetches in smaller pieces:
npx anyray-connect@latest --enroll <your-enrollment-link> --yes
anyray-connect login cannot start or expires
Run the current Connect version and name the same public gateway origin your tools use:
npx anyray-connect@latest login --gateway https://<your-anyray-gateway>
| What you see | Cause and fix |
|---|---|
| Could not start SSO login | The gateway is not connected to the Billing app, SSO is not configured for that tenant, or the shared spend database is unavailable. Ask an operator to check /admin/health, the adt_ connection, and the SSO setup. |
| Login expired | The browser flow exceeded ten minutes. Start again; the code and poll secret are one-session capabilities. |
| Multiple teams are available (non-interactive shell) | Pass --team <name>; the gateway accepts only a team returned by the verified identity policy. |
| The gateway rejected this login session | The poll secret did not match or the session was already consumed. Start again; never put the poll secret in a URL or support report. |
anyray-connect finishes but the verify step returns HTTP 404
connect wrote the config, then its own smoke test came back 404: an enrollment link was used as
a gateway URL, so the tools point at …/sso/<tenantId>/v1/…, an enrollment path that serves no
inference. Two causes:
- A pinned, outdated connect that predates the
--ssoflag, so the link fell through to the gateway slot. Always run the current version (connect.shornpx anyray-connect@latest). - The link passed to
--gateway. An/sso/…or/enroll/…link goes to--sso(SSO) or--enroll(invite link); connect resolves the real gateway URL from the link itself.
Re-run with the link in the right slot; enrollment runs, mints the key, and the verify step
returns 200:
- macOS / Linux
- Windows PowerShell
curl -fsSL https://app.anyray.ai/connect.sh | sh -s -- --sso https://app.anyray.ai/sso/<tenantId> --yes --force
& ([scriptblock]::Create((irm https://app.anyray.ai/connect.ps1))) "--sso" "https://app.anyray.ai/sso/<tenantId>" --yes --force
Enrollment succeeded but Claude Code still talks to Bedrock or Vertex directly
CLAUDE_CODE_USE_BEDROCK (or CLAUDE_CODE_USE_VERTEX) puts Claude Code in direct-cloud mode: it
signs requests to AWS/GCP itself and never reads the ANTHROPIC_BASE_URL connect writes, so
enrollment "succeeds" while nothing reaches the gateway.
connect repairs the sources it can own: at apply time (and on doctor --repair) it pins the switch
to '' in the ~/.claude/settings.json env block, Claude Code's documented unset, which outranks
a shell export. Restart Claude Code and the route engages; the pin drops once the original export
is gone. The one source connect never fights is the machine's managed-settings.json (IT policy):
apply prints a leading WARNING:, doctor reports the routing as bypassed, and the admin who
deploys that file removes the switch.
To keep serving the same models from AWS, configure the gateway's
bedrock provider: the gateway signs SigV4 with the org credential,
and per-developer attribution comes from the enrolled key.
Enrollment succeeded but a managed-settings.json overrides the route
managed-settings.json outranks every other Claude Code scope (CLI args, project settings, the
~/.claude/settings.json connect writes, shell exports), so an MDM file carrying its own
ANTHROPIC_BASE_URL wins while apply reports success and user scope reads back healthy.
anyray-connect doctor flags any key connect owns that the managed file overrides, naming the key,
the managed file's path, and, for the base URL, the origin requests actually reach (reduced to
scheme, host, and port; values are never printed).
doctor --repair leaves it alone: the file is IT policy on an admin-owned path. Two endings:
the admin drops the key so per-user config governs, or, on a centrally managed fleet, deploys
Anyray's own block with
anyray-connect managed,
the stronger setup (it survives a developer editing their own settings). Once both scopes name the
same gateway, doctor goes quiet.
A SCIM-provisioned user's key returns user_deactivated
The key is valid, but its bound email is inactive in the durable SCIM identity store. Confirm the
IdP user is active, then inspect with GET /scim/v2/Users?filter=userName eq "dev@example.com"
using the SCIM bearer. Reactivate the user in the IdP so it sends active:true; do not mint a new
key, because verification blocks every key bound to that inactive email.
If multiple users fail at once, check the spend leg on /admin/health: SCIM access reads fail
closed when the shared Postgres store is unavailable, so restoring ANYRAY_SPEND_DB_URL
connectivity restores verification without changing keys.
Send Anyray a support bundle
When the checks above don't explain it, /admin/support/bundle assembles a one-shot diagnostic
snapshot to send to Anyray support:
curl -fsS "https://<your-gateway>/admin/support/bundle" \
-H "Authorization: Bearer $ANYRAY_ADMIN_TOKEN" \
-o anyray-support-bundle.json
| Section | What it carries |
|---|---|
gateway | Gateway version, product release, Node runtime, platform, uptime, memory use |
health | The same per-leg probes as /admin/health, captured at bundle time |
config | Every set ANYRAY_* variable: plain values verbatim, URLs stripped to scheme://host, secrets shown as [set] |
optimizer | Whether an optimizer is wired up, and its live strategy configuration |
telemetry | Request counts by status class and route since boot, plus the last 100 error events (route, status, error class, duration) |
manifest | The section names, generation time, and approximate serialized size shown before send |
Secrets are redacted to presence ([set]), connection URLs lose their credentials, and errors are
recorded as class names, never messages. The console never sends the bundle. It builds it, shows it
to you, and you copy it into your own support thread.
Trigger the failing request once if you can, so the error lands in the telemetry ring.
Open the account menu, select Diagnostics, then Generate & review, and read the JSON.
Generating needs config:read and sends nothing; the curl above produces the same local
artifact.
Select Copy, then paste the JSON into your support thread. The console does not send it for you, so nothing leaves your deployment until you paste it.