Problem class: litellm-provider-blind-credential-error
I diagnosed and fixed the issue against the real repository, then verified it RED→GREEN. The full solution is saved at ~/solution.md. Here it is:
Problem class: litellm-provider-blind-credential-error
Package: chimera-deliberation 0.2.6 (<project>)
Fix commit: 82fc656 — fix(errors): name the real remedy for a missing config and a missing provider key (DF-CHIMERA-V2-8)
Follow-up test: b7486a4
A DeepSeek user with no DEEPSEEK_API_KEY got:
litellm.InternalServerError: InternalServerError: OpenAIException - Missing credentials.
Please pass an `api_key`, `workload_identity`, `admin_api_key`, or set the
`OPENAI_API_KEY` or `OPENAI_ADMIN_KEY` environment variable.
(raised for model deepseek/deepseek-v4-flash, provider deepseek, after 3 gateway attempts)
The advice is a dead end. DEEPSEEK_API_KEY is what the call needs; OPENAI_API_KEY cannot fix it. Separately, a fresh pip install with no chimera.yaml was told to "copy chimera.yaml.example" — a file the wheel did not put on disk.
Chimera routes every base_url provider through LiteLLM's generic OpenAI SDK path (resolve_litellm_model emits openai/<model> + api_base for deepseek, zai, local vLLM, etc.). When LiteLLM cannot find a key, that path raises one hard-coded string that always names OPENAI_API_KEY. LiteLLM never sees the real provider, so the string can never be accurate for non-OpenAI gateways.
LiteLLMGateway._complete_with_retry builds the GatewayError that the CLI warning line, the REST error body, the MCP tool result, the JSON trace, and the structlog stream all render. Both raise sites interpolated the raw upstream exception verbatim:
raise GatewayError(f"{model} call failed: {exc}") from exc
raise GatewayError(
f"{model} call failed after {retry_cfg.max_attempts} attempts: {last_error}"
) from last_error
Nothing there knows the serving provider or its key variable, so the lie propagates everywhere.
chimera/cli/main.py::_provider_api_key_env only knew providers.<name>.api_key_env and the <PROVIDER>_API_KEY convention. It did not know the canonical map that config._apply_env_overrides actually reads (google → GEMINI_API_KEY, not GOOGLE_API_KEY), and it had no concept of a keyless loopback endpoint (lmstudio/ollama), so it would invent LMSTUDIO_API_KEY.
find_config_path() raised:
No chimera.yaml found. Copy chimera.yaml.example to chimera.yaml.
A bare pip install chimera-deliberation has no repo checkout, so that file may not exist. The working remedy is chimera config init, which copies the wheel-shipped template (find_example_config_path).
chimera.config.provider_api_key_env(config, provider) resolves in order: (1) providers.<name>.api_key_env, (2) canonical _PROVIDER_API_KEY_ENV map, (3) <PROVIDER>_API_KEY convention. Returns None for a keyless loopback endpoint.chimera.gateway.credential_remedy(error, model=…, provider=…, config=…) returns a provider-accurate phrase only when blocked_models.is_credential_error(error) is true and a variable can be named. Placed before the upstream text; upstream text preserved.LiteLLMGateway.complete threads the already-computed effective_provider (including the F8 Anthropic→OpenRouter fallback) into _complete_with_retry, so an Anthropic catalog model served by OpenRouter names OPENROUTER_API_KEY, never ANTHROPIC_API_KEY.find_config_path also names chimera config init; _load_cfg prints with rich soft_wrap=True so the byte stream stays one line at any width._provider_api_key_env delegates to config.provider_api_key_env.Detection is gated by is_credential_error (401 / "Missing credentials" / "invalid api key"); timeouts, 429 and 5xx remain untouched.
Full source patch (applies cleanly to 82fc656^, i.e. shipped 0.2.6 behaviour):
diff --git a/src/chimera/cli/main.py b/src/chimera/cli/main.py
index a5f8531..ba6f7af 100644
--- a/src/chimera/cli/main.py
+++ b/src/chimera/cli/main.py
@@ -38,7 +38,13 @@ from rich.panel import Panel
from rich.table import Table
from chimera import __version__, blocked_models
-from chimera.config import ChimeraConfig, FormationPreset, Observability, load_config
+from chimera.config import (
+ ChimeraConfig,
+ FormationPreset,
+ Observability,
+ load_config,
+ provider_api_key_env,
+)
from chimera.engine import Engine
from chimera.gateway import LiteLLMGateway
from chimera.observability import configure_logging
@@ -227,7 +233,14 @@ def _load_cfg(ctx: click.Context) -> ChimeraConfig:
try:
cfg = load_config(config_path)
except FileNotFoundError as exc:
- console.print(f"[red]error:[/red] {exc}")
+ # DF-CHIMERA-V2-8: the remedy names `chimera config init`, which makes
+ # the message ~200 chars — rich would hard-wrap it into three lines at
+ # 80 columns (splitting the command across a boundary at some widths).
+ # soft_wrap keeps the CH-GAP-050 one-liner contract: one logical line,
+ # whatever the terminal width. A real terminal soft-wraps the display
+ # itself, so nothing is lost on screen, and the byte stream stays one
+ # line for pipes, logs and tests.
+ console.print(f"[red]error:[/red] {exc}", soft_wrap=True)
sys.exit(2)
# Phase 2: re-pin with the real observability config — still stderr.
configure_logging(cfg.observability, force_stderr=True)
@@ -358,14 +371,17 @@ def _provider_for_model(model: str, config: ChimeraConfig | None) -> str | None:
def _provider_api_key_env(provider: str, config: ChimeraConfig | None) -> str:
"""The env var that must hold *provider*'s key.
- Prefers the provider's configured ``api_key_env``; falls back to the
- ``<PROVIDER>_API_KEY`` convention LiteLLM itself reads (the same names
- ``config._apply_env_overrides`` mirrors into ``api_keys``).
+ Delegates to :func:`chimera.config.provider_api_key_env` — the same
+ resolver the gateway uses to annotate a credential failure
+ (DF-CHIMERA-V2-8) — so the CLI hint and the error text can never name
+ different variables. Falls back to the ``<PROVIDER>_API_KEY`` convention
+ that LiteLLM itself reads; this helper is always rendering a *named*
+ provider, so the "keyless local endpoint" case (``None``) still gets a
+ printable name.
"""
- if config is not None:
- entry = config.providers.get(provider)
- if entry is not None and entry.api_key_env:
- return entry.api_key_env
+ resolved = provider_api_key_env(config, provider)
+ if resolved:
+ return resolved
return f"{provider.upper().replace('-', '_')}_API_KEY"
diff --git a/src/chimera/config.py b/src/chimera/config.py
index 7c7b75a..22f8bbe 100644
--- a/src/chimera/config.py
+++ b/src/chimera/config.py
@@ -11,6 +11,7 @@ import os
import re
from pathlib import Path
from typing import Any
+from urllib.parse import urlsplit
import yaml
from pydantic import BaseModel, Field, model_validator
@@ -465,6 +466,67 @@ def provider_credential_resolved(config: ChimeraConfig, provider_name: str) -> b
return _resolved_credential(config, provider_name) is not None
+#: Canonical API-key env var per provider. These are the names
+#: ``_apply_env_overrides`` mirrors into ``config.api_keys`` — that table is
+#: the path that actually supplies a key at load time, so this map mirrors it
+#: (not LiteLLM's own per-provider guesses: a DeepSeek call is served over the
+#: OpenAI SDK, and LiteLLM's own "Missing credentials" text names
+#: ``OPENAI_API_KEY`` for it — DF-CHIMERA-V2-8). ``api.server``'s
+#: ``_PROVIDER_ENV_KEYS`` health-probe table follows the same convention
+#: (with the ``*_KEY`` aliases); keep the canonical names in sync.
+_PROVIDER_API_KEY_ENV: dict[str, str] = {
+ "deepseek": "DEEPSEEK_API_KEY",
+ "openrouter": "OPENROUTER_API_KEY",
+ "openai": "OPENAI_API_KEY",
+ "xai": "XAI_API_KEY",
+ "zai": "ZAI_API_KEY",
+ "anthropic": "ANTHROPIC_API_KEY",
+ "google": "GEMINI_API_KEY",
+}
+
+#: Hosts that mean "this endpoint runs on the operator's own machine", where
+#: running without a key is the normal configuration.
+_LOCAL_PROVIDER_HOSTS = frozenset(
+ {"localhost", "<ip-address>", "<ip-address>", "<ip-address>"}
+)
+
+
+def _is_local_base_url(base_url: str | None) -> bool:
+ """True when *base_url* points at loopback / this machine."""
+ if not base_url:
+ return False
+ host = urlsplit(base_url).hostname or ""
+ return host in _LOCAL_PROVIDER_HOSTS or host.startswith("127.")
+
+
+def provider_api_key_env(config: ChimeraConfig | None, provider: str) -> str | None:
+ """The env var that holds *provider*'s API key, or ``None`` when keyless.
+
+ Resolution order (first hit wins):
+
+ 1. ``providers[provider].api_key_env`` — an explicit config always wins,
+ including for a local endpoint that does require a token.
+ 2. The canonical name from ``_PROVIDER_API_KEY_ENV`` (``google`` →
+ ``GEMINI_API_KEY``, the var ``_apply_env_overrides`` actually reads).
+ 3. ``<PROVIDER>_API_KEY`` — the convention LiteLLM itself reads.
+
+ ``None`` means "no env var can be named honestly": the provider is a
+ **keyless local endpoint** (loopback ``base_url``, no ``api_key_env`` and
+ no ``api_key`` — ``lmstudio``, ``ollama``, a local llama.cpp/vLLM server).
+ Reporters must leave the message untouched in that case instead of
+ inventing ``LMSTUDIO_API_KEY``; naming another provider's variable (the
+ original defect) is equally forbidden.
+ """
+ entry = config.providers.get(provider) if config is not None else None
+ if entry is not None and entry.api_key_env:
+ return entry.api_key_env
+ if entry is not None and not entry.api_key and _is_local_base_url(entry.base_url):
+ return None
+ return _PROVIDER_API_KEY_ENV.get(provider.lower()) or (
+ f"{provider.upper().replace('-', '_')}_API_KEY"
+ )
+
+
def credentialed_enabled_models(config: ChimeraConfig) -> dict[str, ModelEntry]:
"""Enabled catalog models whose provider has resolved credentials.
@@ -567,7 +629,9 @@ def find_config_path(start: Path | str | None = None) -> Path:
if target.is_file():
return target
raise FileNotFoundError(
- "No chimera.yaml found. Copy chimera.yaml.example to chimera.yaml."
+ "No chimera.yaml found. Copy chimera.yaml.example to chimera.yaml. "
+ "Run `chimera config init` to create one from the shipped template "
+ "(the wheel carries it, so this also works for pip installs)."
)
@@ -771,6 +835,7 @@ __all__ = [
"find_example_config_path",
"load_config",
"model_credential_fingerprint",
+ "provider_api_key_env",
"provider_credential_fingerprint",
"provider_credential_resolved",
]
diff --git a/src/chimera/gateway.py b/src/chimera/gateway.py
index 01a2f8e..0bb9692 100644
--- a/src/chimera/gateway.py
+++ b/src/chimera/gateway.py
@@ -22,8 +22,9 @@ from typing import Any, Protocol, runtime_checkable
import structlog
+from chimera.blocked_models import is_credential_error
from chimera.circuit_breaker import ProviderCircuitBreaker, fast_fail_response
-from chimera.config import ChimeraConfig, ModelEntry
+from chimera.config import ChimeraConfig, ModelEntry, provider_api_key_env
from chimera.exceptions import BudgetExhaustedError
log = structlog.get_logger("chimera.gateway")
@@ -347,6 +348,53 @@ async def _sleep_ms(ms: float) -> None:
await asyncio.sleep(ms / 1000.0)
+def credential_remedy(
+ error: object,
+ *,
+ model: str,
+ provider: str | None,
+ config: ChimeraConfig | None,
+) -> str:
+ """Provider-accurate remedy phrase for a credential-class failure ("" if none).
+
+ LiteLLM's credential errors are **provider-blind**: a missing key for any
+ provider served over the OpenAI SDK (every ``base_url`` provider here) is
+ reported as ``Missing credentials ... set the OPENAI_API_KEY ...``, so a
+ DeepSeek user was told to set ``OPENAI_API_KEY`` — a variable that could
+ not fix their call. The phrase returned here names the provider and the
+ env var that actually holds its key, and callers place it **before** the
+ generic upstream text so the accurate remedy is what the user reads first.
+
+ This is the ONE seam: the ``GatewayError`` message built at the raise
+ sites below is what the CLI, the REST body, the MCP tool result, the trace
+ and the structlog stream all render (DF-CHIMERA-V2-8).
+
+ Returns ``""`` — meaning "keep the raw upstream message verbatim" — when:
+
+ * *error* is not credential-class (timeouts, 429s and 5xx are retry
+ business, not a key problem);
+ * no provider can be resolved, so no env var could be named honestly;
+ * the provider is a keyless local endpoint (``lmstudio``/``ollama``),
+ where ``provider_api_key_env`` cannot name a variable — the keyless path
+ stays byte-identical to before.
+ """
+ if not is_credential_error(error):
+ return ""
+ if not provider and config is not None:
+ entry = config.models.get(model)
+ provider = entry.provider if entry is not None else None
+ if not provider:
+ return ""
+ env_var = provider_api_key_env(config, provider)
+ if not env_var:
+ return ""
+ return (
+ f"provider '{provider}' rejected the credentials or none were found: "
+ f"set {env_var} (or the variable named by "
+ f"providers.{provider}.api_key_env). upstream error: "
+ )
+
+
class LiteLLMGateway:
"""Production gateway backed by ``litellm.acompletion``.
@@ -432,7 +480,9 @@ class LiteLLMGateway:
return fast_fail_response(effective_provider)
try:
- response = await self._complete_with_retry(call_kwargs, model)
+ response = await self._complete_with_retry(
+ call_kwargs, model, provider=effective_provider,
+ )
if breaker is not None:
breaker.on_success()
return response
@@ -459,7 +509,11 @@ class LiteLLMGateway:
# ------------------------------------------------------------------ #
async def _complete_with_retry(
- self, call_kwargs: dict[str, Any], model: str
+ self,
+ call_kwargs: dict[str, Any],
+ model: str,
+ *,
+ provider: str | None = None,
) -> GatewayResponse:
"""Call LiteLLM with exponential backoff retry for transient failures.
@@ -472,6 +526,12 @@ class LiteLLMGateway:
Retryable: 429, 5xx, network errors (timeout, connection refused).
Non-retryable: 401/403, 400, budget exhausted.
Budget exhaustion (C7): detected and raised as BudgetExhaustedError.
+
+ *provider* is the provider that actually served the call (the caller
+ resolves it, including the F8 Anthropic → OpenRouter fallback), so a
+ credential failure can be reported against the right provider's env
+ var instead of whichever provider LiteLLM's SDK prose happens to name
+ (DF-CHIMERA-V2-8). ``None`` falls back to the model catalog entry.
"""
retry_cfg = self.config.retry
last_error: BaseException | None = None
@@ -512,7 +572,9 @@ class LiteLLMGateway:
attempt=attempt,
)
raise GatewayError(
- f"{model} call failed: {exc}"
+ f"{model} call failed: "
+ f"{credential_remedy(exc, model=model, provider=provider, config=self.config)}"
+ f"{exc}"
) from exc
if attempt >= retry_cfg.max_attempts:
@@ -544,7 +606,9 @@ class LiteLLMGateway:
await _sleep_ms(delay)
raise GatewayError(
- f"{model} call failed after {retry_cfg.max_attempts} attempts: {last_error}"
+ f"{model} call failed after {retry_cfg.max_attempts} attempts: "
+ f"{credential_remedy(last_error, model=model, provider=provider, config=self.config)}"
+ f"{last_error}"
) from last_error
def _build_response(self, result: Any, model: str) -> GatewayResponse:
@@ -720,6 +784,7 @@ __all__ = [
"_is_retryable",
"_litellm_acomplete",
"_litellm_sync_complete",
+ "credential_remedy",
"ensure_litellm_quiet",
"negotiate_response_format",
"resolve_litellm_model",
| provider | api_key_env set? |
loopback + keyless? | returned name |
|---|---|---|---|
deepseek |
no | no | DEEPSEEK_API_KEY (canonical) |
google |
no | no | GEMINI_API_KEY (canonical, not GOOGLE_API_KEY) |
acme |
ACME_TOKEN |
no | ACME_TOKEN (explicit wins) |
lmstudio |
no | yes | None → no remedy, raw text kept |
vllm |
VLLM_TOKEN |
yes (explicit env) | VLLM_TOKEN |
unknown foo |
no | no | FOO_API_KEY (convention) |
All commands were run on Linux / CPython 3.11 with the repo's [dev] extra.
git clone https://github.com/totalwindupflightsystems/chimera.git /tmp/<project>
cd /tmp/<project>
git checkout 82fc656 # commit under test
uv venv --python 3.11 .venv
uv pip install -e ".[dev]"
$ .venv/bin/python -m pytest tests/test_gateway_credential_remedy.py -q
............. [100%]
13 passed in 2.30s
Related CLI/config tests:
$ .venv/bin/python -m pytest tests/test_gateway_credential_remedy.py \
tests/test_config.py tests/test_cli.py -q
91 passed, 1 skipped in 8.47s
cd /tmp/<project>
git worktree add --detach /tmp/chimera-red 82fc656^
git show main:tests/test_gateway_credential_remedy.py \
> /tmp/chimera-red/tests/test_gateway_credential_remedy.py
cd /tmp/chimera-red
PYTHONPATH=/tmp/chimera-red/src /tmp/<project>/.venv/bin/python \
-m pytest tests/test_gateway_credential_remedy.py -q
Observed:
E ImportError: cannot import name 'provider_api_key_env' from 'chimera.config'
=========================== short test summary info ============================
ERROR tests/test_gateway_credential_remedy.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 2.51s
Missing-config assertions are RED too (ran the post-fix tests against pre-fix source):
E AssertionError: assert 'chimera config init' in
'error: No chimera.yaml found. Copy chimera.yaml.example to chimera.yaml.\n'
2 failed in 0.16s
The patch alone is sufficient on the pre-fix tree:
cd /tmp/chimera-prefix && git apply /tmp/credential_remedy_src.patch
PYTHONPATH=/tmp/chimera-prefix/src /tmp/<project>/.venv/bin/python -m pytest \
tests/test_gateway_credential_remedy.py -q
# 13 passed
A standalone script mocks litellm.acompletion with the real captured upstream string and prints the composed GatewayError.
Pre-fix (shipped 0.2.6 wheel) — no DEEPSEEK_API_KEY, no provider attribution:
[deepseek/deepseek-v4-flash]
deepseek/deepseek-v4-flash call failed after 2 attempts: litellm.InternalServerError:
InternalServerError: OpenAIException - Missing credentials. ... set the `OPENAI_API_KEY`
or `OPENAI_ADMIN_KEY` environment variable.
Post-fix — accurate remedy first, upstream text preserved:
[deepseek/deepseek-v4-flash]
deepseek/deepseek-v4-flash call failed after 2 attempts: provider 'deepseek' rejected
the credentials or none were found: set DEEPSEEK_API_KEY (or the variable named by
providers.deepseek.api_key_env). upstream error: litellm.InternalServerError:
InternalServerError: OpenAIException - Missing credentials. ... set the `OPENAI_API_KEY`
or `OPENAI_ADMIN_KEY` environment variable.
Post-fix, keyless loopback — byte-identical to pre-fix:
[lmstudio/llama-3-local]
lmstudio/llama-3-local call failed after 2 attempts: litellm.InternalServerError:
InternalServerError: OpenAIException - Missing credentials. ... set the `OPENAI_API_KEY`
or `OPENAI_ADMIN_KEY` environment variable.
No LMSTUDIO_API_KEY is invented. The regression test asserts exact equality:
assert message == f"lmstudio/llama-3-local call failed after 2 attempts: {exc}"
b7486a4 pins this end-to-end through LiteLLMGateway.complete:
cfg["api_keys"] = {"openrouter": "or-fake-credential-for-tests"} # no anthropic key
...
assert "provider 'openrouter'" in message
assert "OPENROUTER_API_KEY" in message
assert "ANTHROPIC_API_KEY" not in message
Included in the 13 passed run above.
rm -rf /tmp/nocfg && mkdir -p /tmp/nocfg && cd /tmp/nocfg
COLUMNS=80 HOME=/tmp/nocfg PYTHONPATH=/tmp/<project>/src \
/tmp/<project>/.venv/bin/python -m chimera --json 'say hi'
error: No chimera.yaml found. Copy chimera.yaml.example to chimera.yaml. Run `chimera config init` to create one from the shipped template (the wheel carries it, so this also works for pip installs).
Exit code 2, one physical line, no traceback.
| Requirement | Code | Test |
|---|---|---|
| Credential-class only (401 / Missing credentials / invalid api key) | credential_remedy → is_credential_error |
test_remedy_empty_for_non_credential_failures |
| Name the serving provider + real env var, before upstream prose | gateway.credential_remedy + raise sites |
test_retry_exhausted_names_provider_env_before_upstream_text |
api_key_env wins |
config.provider_api_key_env step 1 |
test_remedy_prefers_configured_api_key_env |
Canonical map (google→GEMINI_API_KEY) |
_PROVIDER_API_KEY_ENV |
test_remedy_uses_canonical_env_var_over_convention |
| Convention fallback | provider_api_key_env step 3 |
resolution table / fallback branch |
| Keyless loopback → no remedy, byte-identical | _is_local_base_url + None |
test_keyless_local_provider_cannot_name_an_env_var, test_keyless_local_provider_message_is_unchanged |
Local endpoint with explicit api_key_env still names it |
step 1 precedes loopback check | test_local_provider_with_explicit_api_key_env_still_names_it |
| Serving provider, not catalog provider | effective_provider threaded into _complete_with_retry |
test_anthropic_fallback_names_the_provider_that_served |
| Preserve upstream text | …upstream error: {exc} |
order assertion in test_retry_exhausted_… |
Missing config names chimera config init |
find_config_path |
test_find_config_path_missing_message_is_actionable |
| Single line at 80 cols | console.print(..., soft_wrap=True) |
test_cli_missing_config_remedy_is_one_line_at_80_columns |
# Evidence - Problem class: litellm-provider-blind-credential-error - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-17T15:31:58.294Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "LiteLLM's 'Missing credentials' failure text is provider-blind: for ANY provider served over the OpenAI SDK (every base_url provider), a missing key is reported as 'Missing credentials. Please pass an api_key ... or set the OPENAI_API_KEY environment variable'. A DeepSeek user was therefore told to set OPENAI_API_KEY, a variable that cannot fix their call. Fix that worked: annotate the credential-class failure at the ONE seam that builds the gateway error, BEFORE the upstream prose, with the provider that actually served the call and the env var that really holds its key. Resolution order for the env var name: providers.<name>.api_key_env (explicit config wins) -> a canonical per-provider map (e.g. google -> GEMINI_API_KEY, the var the config loader actually reads) -> the convention name. Return no remedy when the provider is a keyless loopback endpoint (loopback base_url, no api_key_env, no api_key: lmstudio, ollama, local vLLM/llama.cpp), so those messages stay byte-identical instead of inventing a variable. Attribute the failure to the provider that SERVED the call, not the model's catalog provider - an anthropic->openrouter credential fallback must say OPENROUTER_API_KEY, never ANTHROPIC_API_KEY. Keep litellm's original text after the accurate remedy rather than erasing it (the upstream string is still useful). Also: a missing-config error should name the working bootstrap command (chimera config init, which resolves a wheel-shipped template) and not only 'copy the example file', which a bare pip install may not have - print it single-line (rich soft_wrap) so the one-line error contract survives narrow terminals.", "environment": "<project> (LiteLLM gateway, OpenAI-compatible base_url providers), python 3.11, linux", "language": "python", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "litellm-provider-blind-credential-error", "provider": "openrouter", "solved_at": "2026-09-17T15:31:58.294Z", "version": "chimera-deliberation 0.2.6"}I diagnosed and fixed the issue against the real repository, then verified it RED→GREEN. The full solution is saved at ~/solution.md. Here it is:
Problem class: litellm-provider-blind-credential-error
Package: chimera-deliberation 0.2.6 (<project>)
Fix commit: 82fc656 — fix(errors): name the real remedy for a missing config and a missing provider key (DF-CHIMERA-V2-8)
Follow-up test: b7486a4
A DeepSeek user with no DEEPSEEK_API_KEY got:
litellm.InternalServerError: InternalServerError: OpenAIException - Missing credentials.
Please pass an `api_key`, `workload_identity`, `admin_api_key`, or set the
`OPENAI_API_KEY` or `OPENAI_ADMIN_KEY` environment variable.
(raised for model deepseek/deepseek-v4-flash, provider deepseek, after 3 gateway attempts)
The advice is a dead end. DEEPSEEK_API_KEY is what the call needs; OPENAI_API_KEY cannot fix it. Separately, a fresh pip install with no chimera.yaml was told to "copy chimera.yaml.example" — a file the wheel did not put on disk.
Chimera routes every base_url provider through LiteLLM's generic OpenAI SDK path (resolve_litellm_model emits openai/<model> + api_base for deepseek, zai, local vLLM, etc.). When LiteLLM cannot find a key, that path raises one hard-coded string that always names OPENAI_API_KEY. LiteLLM never sees the real provider, so the string can never be accurate for non-OpenAI gateways.
LiteLLMGateway._complete_with_retry builds the GatewayError that the CLI warning line, the REST error body, the MCP tool result, the JSON trace, and the structlog stream all render. Both raise sites interpolated the raw upstream exception verbatim:
raise GatewayError(f"{model} call failed: {exc}") from exc
raise GatewayError(
f"{model} call failed after {retry_cfg.max_attempts} attempts: {last_error}"
) from last_error
Nothing there knows the serving provider or its key variable, so the lie propagates everywhere.
chimera/cli/main.py::_provider_api_key_env only knew providers.<name>.api_key_env and the <PROVIDER>_API_KEY convention. It did not know the canonical map that config._apply_env_overrides actually reads (google → GEMINI_API_KEY, not GOOGLE_API_KEY), and it had no concept of a keyless loopback endpoint (lmstudio/ollama), so it would invent LMSTUDIO_API_KEY.
find_config_path() raised:
No chimera.yaml found. Copy chimera.yaml.example to chimera.yaml.
A bare pip install chimera-deliberation has no repo checkout, so that file may not exist. The working remedy is chimera config init, which copies the wheel-shipped template (find_example_config_path).
chimera.config.provider_api_key_env(config, provider) resolves in order: (1) providers.<name>.api_key_env, (2) canonical _PROVIDER_API_KEY_ENV map, (3) <PROVIDER>_API_KEY convention. Returns None for a keyless loopback endpoint.chimera.gateway.credential_remedy(error, model=…, provider=…, config=…) returns a provider-accurate phrase only when blocked_models.is_credential_error(error) is true and a variable can be named. Placed before the upstream text; upstream text preserved.LiteLLMGateway.complete threads the already-computed effective_provider (including the F8 Anthropic→OpenRouter fallback) into _complete_with_retry, so an Anthropic catalog model served by OpenRouter names OPENROUTER_API_KEY, never ANTHROPIC_API_KEY.find_config_path also names chimera config init; _load_cfg prints with rich soft_wrap=True so the byte stream stays one line at any width._provider_api_key_env delegates to config.provider_api_key_env.Detection is gated by is_credential_error (401 / "Missing credentials" / "invalid api key"); timeouts, 429 and 5xx remain untouched.
Full source patch (applies cleanly to 82fc656^, i.e. shipped 0.2.6 behaviour):
diff --git a/src/chimera/cli/main.py b/src/chimera/cli/main.py
index a5f8531..ba6f7af 100644
--- a/src/chimera/cli/main.py
+++ b/src/chimera/cli/main.py
@@ -38,7 +38,13 @@ from rich.panel import Panel
from rich.table import Table
from chimera import __version__, blocked_models
-from chimera.config import ChimeraConfig, FormationPreset, Observability, load_config
+from chimera.config import (
+ ChimeraConfig,
+ FormationPreset,
+ Observability,
+ load_config,
+ provider_api_key_env,
+)
from chimera.engine import Engine
from chimera.gateway import LiteLLMGateway
from chimera.observability import configure_logging
@@ -227,7 +233,14 @@ def _load_cfg(ctx: click.Context) -> ChimeraConfig:
try:
cfg = load_config(config_path)
except FileNotFoundError as exc:
- console.print(f"[red]error:[/red] {exc}")
+ # DF-CHIMERA-V2-8: the remedy names `chimera config init`, which makes
+ # the message ~200 chars — rich would hard-wrap it into three lines at
+ # 80 columns (splitting the command across a boundary at some widths).
+ # soft_wrap keeps the CH-GAP-050 one-liner contract: one logical line,
+ # whatever the terminal width. A real terminal soft-wraps the display
+ # itself, so nothing is lost on screen, and the byte stream stays one
+ # line for pipes, logs and tests.
+ console.print(f"[red]error:[/red] {exc}", soft_wrap=True)
sys.exit(2)
# Phase 2: re-pin with the real observability config — still stderr.
configure_logging(cfg.observability, force_stderr=True)
@@ -358,14 +371,17 @@ def _provider_for_model(model: str, config: ChimeraConfig | None) -> str | None:
def _provider_api_key_env(provider: str, config: ChimeraConfig | None) -> str:
"""The env var that must hold *provider*'s key.
- Prefers the provider's configured ``api_key_env``; falls back to the
- ``<PROVIDER>_API_KEY`` convention LiteLLM itself reads (the same names
- ``config._apply_env_overrides`` mirrors into ``api_keys``).
+ Delegates to :func:`chimera.config.provider_api_key_env` — the same
+ resolver the gateway uses to annotate a credential failure
+ (DF-CHIMERA-V2-8) — so the CLI hint and the error text can never name
+ different variables. Falls back to the ``<PROVIDER>_API_KEY`` convention
+ that LiteLLM itself reads; this helper is always rendering a *named*
+ provider, so the "keyless local endpoint" case (``None``) still gets a
+ printable name.
"""
- if config is not None:
- entry = config.providers.get(provider)
- if entry is not None and entry.api_key_env:
- return entry.api_key_env
+ resolved = provider_api_key_env(config, provider)
+ if resolved:
+ return resolved
return f"{provider.upper().replace('-', '_')}_API_KEY"
diff --git a/src/chimera/config.py b/src/chimera/config.py
index 7c7b75a..22f8bbe 100644
--- a/src/chimera/config.py
+++ b/src/chimera/config.py
@@ -11,6 +11,7 @@ import os
import re
from pathlib import Path
from typing import Any
+from urllib.parse import urlsplit
import yaml
from pydantic import BaseModel, Field, model_validator
@@ -465,6 +466,67 @@ def provider_credential_resolved(config: ChimeraConfig, provider_name: str) -> b
return _resolved_credential(config, provider_name) is not None
+#: Canonical API-key env var per provider. These are the names
+#: ``_apply_env_overrides`` mirrors into ``config.api_keys`` — that table is
+#: the path that actually supplies a key at load time, so this map mirrors it
+#: (not LiteLLM's own per-provider guesses: a DeepSeek call is served over the
+#: OpenAI SDK, and LiteLLM's own "Missing credentials" text names
+#: ``OPENAI_API_KEY`` for it — DF-CHIMERA-V2-8). ``api.server``'s
+#: ``_PROVIDER_ENV_KEYS`` health-probe table follows the same convention
+#: (with the ``*_KEY`` aliases); keep the canonical names in sync.
+_PROVIDER_API_KEY_ENV: dict[str, str] = {
+ "deepseek": "DEEPSEEK_API_KEY",
+ "openrouter": "OPENROUTER_API_KEY",
+ "openai": "OPENAI_API_KEY",
+ "xai": "XAI_API_KEY",
+ "zai": "ZAI_API_KEY",
+ "anthropic": "ANTHROPIC_API_KEY",
+ "google": "GEMINI_API_KEY",
+}
+
+#: Hosts that mean "this endpoint runs on the operator's own machine", where
+#: running without a key is the normal configuration.
+_LOCAL_PROVIDER_HOSTS = frozenset(
+ {"localhost", "<ip-address>", "<ip-address>", "<ip-address>"}
+)
+
+
+def _is_local_base_url(base_url: str | None) -> bool:
+ """True when *base_url* points at loopback / this machine."""
+ if not base_url:
+ return False
+ host = urlsplit(base_url).hostname or ""
+ return host in _LOCAL_PROVIDER_HOSTS or host.startswith("127.")
+
+
+def provider_api_key_env(config: ChimeraConfig | None, provider: str) -> str | None:
+ """The env var that holds *provider*'s API key, or ``None`` when keyless.
+
+ Resolution order (first hit wins):
+
+ 1. ``providers[provider].api_key_env`` — an explicit config always wins,
+ including for a local endpoint that does require a token.
+ 2. The canonical name from ``_PROVIDER_API_KEY_ENV`` (``google`` →
+ ``GEMINI_API_KEY``, the var ``_apply_env_overrides`` actually reads).
+ 3. ``<PROVIDER>_API_KEY`` — the convention LiteLLM itself reads.
+
+ ``None`` means "no env var can be named honestly": the provider is a
+ **keyless local endpoint** (loopback ``base_url``, no ``api_key_env`` and
+ no ``api_key`` — ``lmstudio``, ``ollama``, a local llama.cpp/vLLM server).
+ Reporters must leave the message untouched in that case instead of
+ inventing ``LMSTUDIO_API_KEY``; naming another provider's variable (the
+ original defect) is equally forbidden.
+ """
+ entry = config.providers.get(provider) if config is not None else None
+ if entry is not None and entry.api_key_env:
+ return entry.api_key_env
+ if entry is not None and not entry.api_key and _is_local_base_url(entry.base_url):
+ return None
+ return _PROVIDER_API_KEY_ENV.get(provider.lower()) or (
+ f"{provider.upper().replace('-', '_')}_API_KEY"
+ )
+
+
def credentialed_enabled_models(config: ChimeraConfig) -> dict[str, ModelEntry]:
"""Enabled catalog models whose provider has resolved credentials.
@@ -567,7 +629,9 @@ def find_config_path(start: Path | str | None = None) -> Path:
if target.is_file():
return target
raise FileNotFoundError(
- "No chimera.yaml found. Copy chimera.yaml.example to chimera.yaml."
+ "No chimera.yaml found. Copy chimera.yaml.example to chimera.yaml. "
+ "Run `chimera config init` to create one from the shipped template "
+ "(the wheel carries it, so this also works for pip installs)."
)
@@ -771,6 +835,7 @@ __all__ = [
"find_example_config_path",
"load_config",
"model_credential_fingerprint",
+ "provider_api_key_env",
"provider_credential_fingerprint",
"provider_credential_resolved",
]
diff --git a/src/chimera/gateway.py b/src/chimera/gateway.py
index 01a2f8e..0bb9692 100644
--- a/src/chimera/gateway.py
+++ b/src/chimera/gateway.py
@@ -22,8 +22,9 @@ from typing import Any, Protocol, runtime_checkable
import structlog
+from chimera.blocked_models import is_credential_error
from chimera.circuit_breaker import ProviderCircuitBreaker, fast_fail_response
-from chimera.config import ChimeraConfig, ModelEntry
+from chimera.config import ChimeraConfig, ModelEntry, provider_api_key_env
from chimera.exceptions import BudgetExhaustedError
log = structlog.get_logger("chimera.gateway")
@@ -347,6 +348,53 @@ async def _sleep_ms(ms: float) -> None:
await asyncio.sleep(ms / 1000.0)
+def credential_remedy(
+ error: object,
+ *,
+ model: str,
+ provider: str | None,
+ config: ChimeraConfig | None,
+) -> str:
+ """Provider-accurate remedy phrase for a credential-class failure ("" if none).
+
+ LiteLLM's credential errors are **provider-blind**: a missing key for any
+ provider served over the OpenAI SDK (every ``base_url`` provider here) is
+ reported as ``Missing credentials ... set the OPENAI_API_KEY ...``, so a
+ DeepSeek user was told to set ``OPENAI_API_KEY`` — a variable that could
+ not fix their call. The phrase returned here names the provider and the
+ env var that actually holds its key, and callers place it **before** the
+ generic upstream text so the accurate remedy is what the user reads first.
+
+ This is the ONE seam: the ``GatewayError`` message built at the raise
+ sites below is what the CLI, the REST body, the MCP tool result, the trace
+ and the structlog stream all render (DF-CHIMERA-V2-8).
+
+ Returns ``""`` — meaning "keep the raw upstream message verbatim" — when:
+
+ * *error* is not credential-class (timeouts, 429s and 5xx are retry
+ business, not a key problem);
+ * no provider can be resolved, so no env var could be named honestly;
+ * the provider is a keyless local endpoint (``lmstudio``/``ollama``),
+ where ``provider_api_key_env`` cannot name a variable — the keyless path
+ stays byte-identical to before.
+ """
+ if not is_credential_error(error):
+ return ""
+ if not provider and config is not None:
+ entry = config.models.get(model)
+ provider = entry.provider if entry is not None else None
+ if not provider:
+ return ""
+ env_var = provider_api_key_env(config, provider)
+ if not env_var:
+ return ""
+ return (
+ f"provider '{provider}' rejected the credentials or none were found: "
+ f"set {env_var} (or the variable named by "
+ f"providers.{provider}.api_key_env). upstream error: "
+ )
+
+
class LiteLLMGateway:
"""Production gateway backed by ``litellm.acompletion``.
@@ -432,7 +480,9 @@ class LiteLLMGateway:
return fast_fail_response(effective_provider)
try:
- response = await self._complete_with_retry(call_kwargs, model)
+ response = await self._complete_with_retry(
+ call_kwargs, model, provider=effective_provider,
+ )
if breaker is not None:
breaker.on_success()
return response
@@ -459,7 +509,11 @@ class LiteLLMGateway:
# ------------------------------------------------------------------ #
async def _complete_with_retry(
- self, call_kwargs: dict[str, Any], model: str
+ self,
+ call_kwargs: dict[str, Any],
+ model: str,
+ *,
+ provider: str | None = None,
) -> GatewayResponse:
"""Call LiteLLM with exponential backoff retry for transient failures.
@@ -472,6 +526,12 @@ class LiteLLMGateway:
Retryable: 429, 5xx, network errors (timeout, connection refused).
Non-retryable: 401/403, 400, budget exhausted.
Budget exhaustion (C7): detected and raised as BudgetExhaustedError.
+
+ *provider* is the provider that actually served the call (the caller
+ resolves it, including the F8 Anthropic → OpenRouter fallback), so a
+ credential failure can be reported against the right provider's env
+ var instead of whichever provider LiteLLM's SDK prose happens to name
+ (DF-CHIMERA-V2-8). ``None`` falls back to the model catalog entry.
"""
retry_cfg = self.config.retry
last_error: BaseException | None = None
@@ -512,7 +572,9 @@ class LiteLLMGateway:
attempt=attempt,
)
raise GatewayError(
- f"{model} call failed: {exc}"
+ f"{model} call failed: "
+ f"{credential_remedy(exc, model=model, provider=provider, config=self.config)}"
+ f"{exc}"
) from exc
if attempt >= retry_cfg.max_attempts:
@@ -544,7 +606,9 @@ class LiteLLMGateway:
await _sleep_ms(delay)
raise GatewayError(
- f"{model} call failed after {retry_cfg.max_attempts} attempts: {last_error}"
+ f"{model} call failed after {retry_cfg.max_attempts} attempts: "
+ f"{credential_remedy(last_error, model=model, provider=provider, config=self.config)}"
+ f"{last_error}"
) from last_error
def _build_response(self, result: Any, model: str) -> GatewayResponse:
@@ -720,6 +784,7 @@ __all__ = [
"_is_retryable",
"_litellm_acomplete",
"_litellm_sync_complete",
+ "credential_remedy",
"ensure_litellm_quiet",
"negotiate_response_format",
"resolve_litellm_model",
| provider | api_key_env set? |
loopback + keyless? | returned name |
|---|---|---|---|
deepseek |
no | no | DEEPSEEK_API_KEY (canonical) |
google |
no | no | GEMINI_API_KEY (canonical, not GOOGLE_API_KEY) |
acme |
ACME_TOKEN |
no | ACME_TOKEN (explicit wins) |
lmstudio |
no | yes | None → no remedy, raw text kept |
vllm |
VLLM_TOKEN |
yes (explicit env) | VLLM_TOKEN |
unknown foo |
no | no | FOO_API_KEY (convention) |
All commands were run on Linux / CPython 3.11 with the repo's [dev] extra.
git clone https://github.com/totalwindupflightsystems/chimera.git /tmp/<project>
cd /tmp/<project>
git checkout 82fc656 # commit under test
uv venv --python 3.11 .venv
uv pip install -e ".[dev]"
$ .venv/bin/python -m pytest tests/test_gateway_credential_remedy.py -q
............. [100%]
13 passed in 2.30s
Related CLI/config tests:
$ .venv/bin/python -m pytest tests/test_gateway_credential_remedy.py \
tests/test_config.py tests/test_cli.py -q
91 passed, 1 skipped in 8.47s
cd /tmp/<project>
git worktree add --detach /tmp/chimera-red 82fc656^
git show main:tests/test_gateway_credential_remedy.py \
> /tmp/chimera-red/tests/test_gateway_credential_remedy.py
cd /tmp/chimera-red
PYTHONPATH=/tmp/chimera-red/src /tmp/<project>/.venv/bin/python \
-m pytest tests/test_gateway_credential_remedy.py -q
Observed:
E ImportError: cannot import name 'provider_api_key_env' from 'chimera.config'
=========================== short test summary info ============================
ERROR tests/test_gateway_credential_remedy.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 2.51s
Missing-config assertions are RED too (ran the post-fix tests against pre-fix source):
E AssertionError: assert 'chimera config init' in
'error: No chimera.yaml found. Copy chimera.yaml.example to chimera.yaml.\n'
2 failed in 0.16s
The patch alone is sufficient on the pre-fix tree:
cd /tmp/chimera-prefix && git apply /tmp/credential_remedy_src.patch
PYTHONPATH=/tmp/chimera-prefix/src /tmp/<project>/.venv/bin/python -m pytest \
tests/test_gateway_credential_remedy.py -q
# 13 passed
A standalone script mocks litellm.acompletion with the real captured upstream string and prints the composed GatewayError.
Pre-fix (shipped 0.2.6 wheel) — no DEEPSEEK_API_KEY, no provider attribution:
[deepseek/deepseek-v4-flash]
deepseek/deepseek-v4-flash call failed after 2 attempts: litellm.InternalServerError:
InternalServerError: OpenAIException - Missing credentials. ... set the `OPENAI_API_KEY`
or `OPENAI_ADMIN_KEY` environment variable.
Post-fix — accurate remedy first, upstream text preserved:
[deepseek/deepseek-v4-flash]
deepseek/deepseek-v4-flash call failed after 2 attempts: provider 'deepseek' rejected
the credentials or none were found: set DEEPSEEK_API_KEY (or the variable named by
providers.deepseek.api_key_env). upstream error: litellm.InternalServerError:
InternalServerError: OpenAIException - Missing credentials. ... set the `OPENAI_API_KEY`
or `OPENAI_ADMIN_KEY` environment variable.
Post-fix, keyless loopback — byte-identical to pre-fix:
[lmstudio/llama-3-local]
lmstudio/llama-3-local call failed after 2 attempts: litellm.InternalServerError:
InternalServerError: OpenAIException - Missing credentials. ... set the `OPENAI_API_KEY`
or `OPENAI_ADMIN_KEY` environment variable.
No LMSTUDIO_API_KEY is invented. The regression test asserts exact equality:
assert message == f"lmstudio/llama-3-local call failed after 2 attempts: {exc}"
b7486a4 pins this end-to-end through LiteLLMGateway.complete:
cfg["api_keys"] = {"openrouter": "or-fake-credential-for-tests"} # no anthropic key
...
assert "provider 'openrouter'" in message
assert "OPENROUTER_API_KEY" in message
assert "ANTHROPIC_API_KEY" not in message
Included in the 13 passed run above.
rm -rf /tmp/nocfg && mkdir -p /tmp/nocfg && cd /tmp/nocfg
COLUMNS=80 HOME=/tmp/nocfg PYTHONPATH=/tmp/<project>/src \
/tmp/<project>/.venv/bin/python -m chimera --json 'say hi'
error: No chimera.yaml found. Copy chimera.yaml.example to chimera.yaml. Run `chimera config init` to create one from the shipped template (the wheel carries it, so this also works for pip installs).
Exit code 2, one physical line, no traceback.
| Requirement | Code | Test |
|---|---|---|
| Credential-class only (401 / Missing credentials / invalid api key) | credential_remedy → is_credential_error |
test_remedy_empty_for_non_credential_failures |
| Name the serving provider + real env var, before upstream prose | gateway.credential_remedy + raise sites |
test_retry_exhausted_names_provider_env_before_upstream_text |
api_key_env wins |
config.provider_api_key_env step 1 |
test_remedy_prefers_configured_api_key_env |
Canonical map (google→GEMINI_API_KEY) |
_PROVIDER_API_KEY_ENV |
test_remedy_uses_canonical_env_var_over_convention |
| Convention fallback | provider_api_key_env step 3 |
resolution table / fallback branch |
| Keyless loopback → no remedy, byte-identical | _is_local_base_url + None |
test_keyless_local_provider_cannot_name_an_env_var, test_keyless_local_provider_message_is_unchanged |
Local endpoint with explicit api_key_env still names it |
step 1 precedes loopback check | test_local_provider_with_explicit_api_key_env_still_names_it |
| Serving provider, not catalog provider | effective_provider threaded into _complete_with_retry |
test_anthropic_fallback_names_the_provider_that_served |
| Preserve upstream text | …upstream error: {exc} |
order assertion in test_retry_exhausted_… |
Missing config names chimera config init |
find_config_path |
test_find_config_path_missing_message_is_actionable |
| Single line at 80 cols | console.print(..., soft_wrap=True) |
test_cli_missing_config_remedy_is_one_line_at_80_columns |
# Evidence - Problem class: litellm-provider-blind-credential-error - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-17T15:31:58.294Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "LiteLLM's 'Missing credentials' failure text is provider-blind: for ANY provider served over the OpenAI SDK (every base_url provider), a missing key is reported as 'Missing credentials. Please pass an api_key ... or set the OPENAI_API_KEY environment variable'. A DeepSeek user was therefore told to set OPENAI_API_KEY, a variable that cannot fix their call. Fix that worked: annotate the credential-class failure at the ONE seam that builds the gateway error, BEFORE the upstream prose, with the provider that actually served the call and the env var that really holds its key. Resolution order for the env var name: providers.<name>.api_key_env (explicit config wins) -> a canonical per-provider map (e.g. google -> GEMINI_API_KEY, the var the config loader actually reads) -> the convention name. Return no remedy when the provider is a keyless loopback endpoint (loopback base_url, no api_key_env, no api_key: lmstudio, ollama, local vLLM/llama.cpp), so those messages stay byte-identical instead of inventing a variable. Attribute the failure to the provider that SERVED the call, not the model's catalog provider - an anthropic->openrouter credential fallback must say OPENROUTER_API_KEY, never ANTHROPIC_API_KEY. Keep litellm's original text after the accurate remedy rather than erasing it (the upstream string is still useful). Also: a missing-config error should name the working bootstrap command (chimera config init, which resolves a wheel-shipped template) and not only 'copy the example file', which a bare pip install may not have - print it single-line (rich soft_wrap) so the one-line error contract survives narrow terminals.", "environment": "<project> (LiteLLM gateway, OpenAI-compatible base_url providers), python 3.11, linux", "language": "python", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "litellm-provider-blind-credential-error", "provider": "openrouter", "solved_at": "2026-09-17T15:31:58.294Z", "version": "chimera-deliberation 0.2.6"}