When a provider won't connect
Work from the symptom you have to the field that explains it and the config key that fixes it.
On this page
You have a symptom and not much else: a 401 from somebody, a row with no cost on it, or traffic going somewhere you did not configure. This page starts from those, not from the model.
Run the three checks first. Most provider problems are caught by one of them before a request is ever sent, and they take a few seconds together.
$ tokentriage validate --config tokentriage.yaml # shape, params, auth pairing
$ tokentriage doctor --config tokentriage.yaml # variables actually set, ports, pricing
$ curl -s localhost:9090/tt/api/status # a running instance's view
Start from what you have
| You are looking at | Go to |
|---|---|
| a validation error | config that will not load |
validate turning away two settings that are each legal |
two features that will not load together |
| 401 or 403 from the provider | credential problems |
| 404 from the provider | url problems |
| HTTP 200 and no cost on the row | a row with no dollar |
| two surfaces disagreeing about how many rows are unpriced | a row with no dollar |
| a stream that stopped early, or one upstream 400ing on every stream | when a stream misbehaves |
| traffic reaching an upstream you did not pick | routing surprises |
| everything intermittently failing | cooldown and reachability |
Config that will not load
Validation errors name the YAML path, so read the path before the prose.
| Message contains | Cause | Fix |
|---|---|---|
does not name a known provider |
the descriptor id is not in the registry | check the id in the reference |
base_url: must not be empty |
provider: set without a host |
descriptors carry relative paths only |
is not a template variable of provider |
a params: key the descriptor does not use |
the message lists the allowed set |
requires template variable … (missing) |
a required params: key absent |
supply it |
params.model or params.region |
putting either in params: |
model is per-tier; region has its own field |
requires a region |
a Bedrock upstream with no region: |
add it; it signs and prices |
the multi-part auth.* fields are not valid here |
an auth: block on bearer or header |
use api_key_env |
this looks like a secret value |
a key literal in a *_env field |
put the variable name there |
unknown variable {…} at offset |
an inline paths template |
only six variables are allowed |
REFUSED while routing.allow_cross_dialect_streaming |
two features that will not run together | flip one of them |
Two features that will not load together
Stream-phase guardrail inspection and translated streams cannot both be armed in this build, and
the config is turned away at validation rather than at the first request. Arm the guardrail layer,
give any guard the stream phase, set routing.allow_cross_dialect_streaming: true, and
validate exits 1:
$ tokentriage validate --config t7-guard-stream-vs-translated-stream.yaml
Error: configuration is invalid: config D:/tt-w01-neutral/work/t7-guard-stream-vs-translated-stream.yaml: guardrails.guards[0].phases[1]: guard "pii-stream" inspects at the "stream" phase, which is REFUSED while routing.allow_cross_dialect_streaming is true (BB14). The stream-framing gate supports exactly (openai, chat) and is armed from the ROUTE before the first byte relays (internal/proxy/stream_framing.go:34-97,127-129); it assumes streams never cross dialects, so on a translated stream it would arm an inspector over frames it cannot decode and produce a "guarded, clean" row. Turn off one of the two: drop the "stream" phase from this guard, or set routing.allow_cross_dialect_streaming: false
The absolute path is the capture machine’s and yours carries your own; the array index and the
guard id belong to the config that was written to trip the check; the sentence is the binary’s. The
second copy the binary prints is cut here, for the reason the note above gives. Grep your terminal
for REFUSED while routing.allow_cross_dialect_streaming and you have found this.
Either single flip loads clean, and the two are alternatives rather than a ranked pair. Each was
run through validate as a one-line edit of the config that produced the refusal above, and each
printed OK: configuration is valid.
# A: keep stream-phase inspection, turn translated streams off.
routing:
allow_cross_dialect_streaming: false
# B: keep translated streams, take the stream phase off the guard.
guardrails:
guards:
- id: pii-stream
phases: [input]
A guard with no stream phase does not trip the check, and neither does a
guardrails.enabled: false layer. The check reads the config rather than your traffic, so it is
wider than the conflict it protects against, which is the safe direction for a gate that fails
closed. Translated streams has the reason it is
drawn that wide.
Credential problems
Work down this list; the first two are the answer most of the time.
The variable is not set. doctor reports it by name, and the boot banner prints a line at
startup:
! keys — 1 upstream(s), 1 *_env reference(s): 1 unset (GROQ_API_KEY) — missing_env
set: export GROQ_API_KEY=… (referenced by an upstream but unset)
With the variable unset, requests are forwarded carrying whatever credential the client sent. A client that sends nothing gets the provider’s own 401, which is why this looks like an authentication bug rather than a configuration one.
The variable is set but empty. Counted as unset. Reporting a set-but-empty variable as clean would hide the case the check exists to find.
A multi-part credential is unset. The boot banner covers api_key_env only. An upstream whose
credential arrives through auth.credentials_file_env, the AWS pair or auth.private_key_file_env
boots silently. doctor’s keys line counts five of the six credential fields and leaves
auth.private_key_file_env out, so for a key-file upstream the status payload is the surface that
answers:
"vertex": { "auth_scheme": "gcp_oauth",
"auth_health": { "configured": false, "fresh": false,
"state": "missing_env" } }
The credential file is unreadable. state: unreadable_credentials means the path resolved and
the file did not parse. Check what the variable resolves to: it holds a path, not a key. A
passphrase-encrypted private key lands here too, in both PEM spellings, because neither key-file
scheme takes a passphrase. Generate the key with -nocrypt and point the variable at that file.
A token expired. refresh_failed means the last good material is still serving and a refresh is
failing; expired means it lapsed. Both point at provider reachability or clock skew.
URL problems
A 404 from the provider is nearly always the path rather than the host.
- Azure: the deployment in
params.deploymentmust exist on that resource. The path carries it; the body carries the model. - Vertex:
params.locationmust be a location that serves your model, and it appears in both the host and the path. - OpenAI-compatible vendors: check whether the vendor’s base URL already ends in
/v1. Doubling it is the most common paste error. - A vendor whose path is not the driver’s default: declare it under an inline provider’s
paths. See adding a provider.
A row with no dollar
HTTP 200 with no cost is the symptom most often mistaken for breakage, and there are three distinct causes with three different fields.
unpriced: true with a reason. Read cost.unpriced_reason. Six values are defined, in two
families that want different things from you.
| Reason | Family | First thing to check |
|---|---|---|
model_unknown |
pricing miss | is the upstream naming the descriptor you meant? |
region_unknown |
pricing miss | is your region outside us-, eu- and ap-? |
endpoint_unpriced |
pricing miss | is the model priced for the endpoint you called? |
unit_missing |
pricing miss | did an image call carry a size and quality? |
settlement_pending |
async settlement | nothing yet; the provider has not reported the money |
settlement_terminal |
async settlement | the job ended without a priceable result |
The four pricing misses mean a lookup ran and found nothing usable, and each is a work item for a catalog or a config. The two settlement reasons mean there is no price to look up: not yet, or not at all. No pricing action clears either, and folding them in with the first four reports a catalog gap that does not exist. Reading a settled cost is the page for a pending row you are trying to add up, and providers that take a job and answer later is why the row is written that way in the first place.
No unpriced flag, empty pricing_key, a descriptor@ snapshot. A declared-free upstream.
Working as configured.
No cost fields at all. The request took the passthrough path, because its endpoint kind is not
one this descriptor declares. Check http.endpoint on the row.
Two unpriced counts that disagree. The Requests list and the Cost view can report different
unpriced totals over the same ledger and the same window. The list counts each row as it was
written and does not fold a settlement into it, so a job that has settled still reads UNPRICED
there while its own record drawer shows the dollar. The folded count is the one to believe. No
dashboard surface renders a settlement figure at all, so a pending-settlement diagnosis is a
command-line job: tokentriage top prints the exclusion and the pending count as two lines under
its table.
The pricing-key page has the full walk and the diagnosis table.
When a stream misbehaves
It stopped early and the last thing through was an error. On a translated stream, a frame the
codec cannot encode ends the stream: your client receives an error frame in its own dialect, no
terminating [DONE] arrives, finish_reason is empty, and the row carries the same string in
outcome.error. That is a named case with a capture behind it, in
translated streams.
One upstream 400s on every streamed chat request. On a streaming chat request to an
OpenAI-dialect upstream, the gateway adds stream_options.include_usage: true to the outbound body
and strips the resulting usage chunk from your copy if you did not ask for it. That is how a
streamed row carries provider-reported usage instead of an estimate, and it lands on every upstream
riding that driver rather than on a chosen list. An upstream that rejects the field therefore fails
every streamed chat request, in a way that reads as the gateway’s fault. Turn it off with
ledger.usage_injection: false and note that the switch is global: rows on every OpenAI-dialect
upstream then price from an estimated token count, with usage.source saying so. Oracle Cloud is
the one upstream where this is a watch item rather than a settled question, because its published
documentation says nothing about the field.
Routing surprises
A tier is never chosen. If its upstream speaks a different dialect than the request and
routing.allow_cross_dialect is off, the tier is skipped and the request escalates to the
same-dialect default. That is the default configuration.
A cross-dialect tier is chosen and the body is not translated. The target upstream needs both
provider: and dialect: named. With provider: alone the request is forwarded untranslated;
the cross-dialect page shows both captures, and
the dialect matrix is the table of pairs that can translate
at all.
decision.fail_safe: true. Something degraded to a passthrough rather than failing: a model
rewrite that could not be applied, a translation that hit an unmodelled parameter, a tier naming an
upstream that no longer exists.
Cooldown and reachability
Nothing here probes your providers. Reachability is inferred from requests that actually failed, and it surfaces as one boolean per upstream:
"openai": {
"base_url": "https://api.openai.com",
"dialect": "",
"in_cooldown": false,
"auth_scheme": "bearer",
"auth_health": {
"configured": true,
"fresh": true,
"state": "ok"
}
}
That entry came off GET /tt/api/status on the admin port, re-indented from the single line
curl returns. The upstream name and its base_url are yours; the field set is the payload’s.
in_cooldown: true means routing is currently avoiding that upstream after failures. The policy
that puts it there and takes it out belongs to routing limits.
Two things this payload does not tell you. It does not say whether the provider is up — only
whether requests to it have been failing. And dialect reads empty for every upstream configured
with provider:, because the derived dialect is not written back into the config this payload
reads. Use the descriptor id to know which wire you are on.
What /health does and does not mean
/health on the admin port answers OK while the process is running. It performs no provider
check, no ledger check and no credential check, so a green /health alongside a failing provider is
not a contradiction. For provider state, read /tt/api/status; for setup state, run doctor.
When it is not you
Some catalog prefixes cannot be reached from any configuration: a credential mechanism sitting outside the shipped authentication schemes, a wire with no primary documentation to build a mock against, or a bespoke contract with no verifiable shape. Submit-and-poll is no longer one of those blockers; the class that held it is closed, and providers that take a job and answer later is where those rows are explained. What is not covered lists every prefix with its blocker, so you can tell a misconfiguration from an absence in about a minute.