fix(litellm): refresh OAuth identities and retry auth

This commit is contained in:
Eduardo David Paredes Vara
2026-08-24 12:13:38 +00:00
parent 32f628c7ce
commit a6c69c2254
4 changed files with 257 additions and 68 deletions
+35 -4
View File
@@ -294,10 +294,41 @@ anthropic-beta: claude-code-20250219,oauth-2025-04-20
`claude_provider.py` entrega el token al proveedor Anthropic nativo mediante
`ANTHROPIC_AUTH_TOKEN`; no debe pasarlo como `api_key`, porque eso añadiría
`x-api-key` y Anthropic rechaza mezclar ambos esquemas. El adaptador añade la
identidad mínima de Claude Code (beta, sesión, `x-app` y mensaje de sistema) y
delega la traducción de mensajes, herramientas y respuestas a LiteLLM. Si
falta esa identidad, Anthropic puede responder con un `429 rate_limit_error`
genérico aunque la misma cuenta funcione desde el CLI.
identidad mínima de Claude Code (beta, `x-app`, `user-agent` y mensaje de
sistema) y delega la traducción de mensajes, herramientas y respuestas a
LiteLLM. Si falta esa identidad, Anthropic puede responder con un
`429 rate_limit_error` genérico aunque la misma cuenta funcione desde el CLI.
Desde abril de 2026 Anthropic separa la facturación en dos grupos: las apps
oficiales (CLI de Claude Code, claude.ai) consumen los límites del plan,
mientras que las aplicaciones de terceros consumen un saldo aparte de "extra
usage". Cuando ese saldo está vacío, las peticiones clasificadas como
third-party se rechazan con:
```text
400 invalid_request_error:
Third-party apps now draw from your extra usage, not your plan limits.
Add more at claude.ai/settings/usage and keep going.
```
La clasificación la decide la identidad HTTP de la petición, y hay tres
detalles que la delataban como third-party:
1. El `user-agent` decía `(external, sdk-cli)`. El binario de Claude Code usa
`(external, cli)` cuando corre como CLI; `sdk-cli` es el entrypoint del SDK
(`CLAUDE_CODE_ENTRYPOINT`) y Anthropic lo factura como terceros.
2. Se enviaba `anthropic-dangerous-direct-browser-access: true`, una cabecera
que sólo usa la web de claude.ai y que también marca la petición como
terceros.
3. LiteLLM 1.82 reescribe `anthropic-beta` contra una lista blanca
(`anthropic_beta_headers_config.json`) y descartaba `claude-code-20250219`,
que no figura en ella y es la beta `claude_code` del CLI.
`claude_provider.py` corrige los tres puntos: identifica `cli` en el
`user-agent`, omite la cabecera de navegador y registra `claude-code-20250219`
en la lista blanca al importarse (`_patch_beta_headers`). Si el error vuelve a
aparecer, revisa que esas cabeceras se conserven en el registro de LiteLLM y
que la cuenta tenga saldo de "extra usage" en `claude.ai/settings/usage`.
### Renovación
+148 -5
View File
@@ -29,8 +29,103 @@ AUTH_FILE = os.environ.get(
TOKEN_URL = "https://platform.claude.com/v1/oauth/token"
CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
REFRESH_MARGIN_MS = 5 * 60 * 1000
CLAUDE_CODE_SYSTEM = "You are Claude Code, Anthropic's official CLI for Claude."
CLAUDE_CODE_BETA = "claude-code-20250219,oauth-2025-04-20"
CLAUDE_CODE_SYSTEM = "You are a Claude agent, built on Anthropic's Claude Agent SDK."
CLAUDE_CODE_BILLING_HEADER = (
"x-anthropic-billing-header: cc_version=2.1.233.3c3; cc_entrypoint=sdk-cli;"
)
CLAUDE_CODE_BETA = (
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,"
"thinking-token-count-2026-05-13,context-management-2025-06-27,"
"prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,"
"advisor-tool-2026-03-01,effort-2025-11-24,afk-mode-2026-01-31,"
"extended-cache-ttl-2025-04-11"
)
def _patch_beta_headers() -> None:
"""Keep the Claude Code beta header out of LiteLLM's beta-header filter.
LiteLLM >= 1.82 rewrites ``anthropic-beta`` against a whitelist
(``anthropic_beta_headers_config.json``). ``claude-code-20250219`` is not
in that list, so it gets stripped. Anthropic then treats the request as a
third-party app and bills it against "extra usage" instead of the Claude
plan limits, rejecting it with a 400 when that pool is empty. Register the
header once at import time so the Claude Code identity survives the filter.
"""
try:
import litellm.anthropic_beta_headers_manager as beta_manager
config = beta_manager._load_beta_headers_config()
anthropic_mapping = config.setdefault("anthropic", {})
anthropic_mapping.setdefault("claude-code-20250219", "claude-code-20250219")
except Exception: # pragma: no cover - best effort, non-fatal
pass
_patch_beta_headers()
def _patch_anthropic_requests() -> None:
"""Inject the Claude Code billing attribution into outgoing Anthropic calls.
Anthropic attributes a request to the first-party Claude Code CLI through an
``x-anthropic-billing-header:`` line that must be the *first* system prompt
block. LiteLLM deliberately strips that line from system messages (to stop
clients spoofing it), so it cannot be passed via ``messages``. Inject it into
the serialized request body right before the HTTP call instead.
"""
try:
import litellm.llms.anthropic.chat.handler as anthropic_handler
orig_make_call = anthropic_handler.make_call
orig_sync = anthropic_handler.make_sync_call
debug = bool(os.environ.get("CLAUDE_DEBUG"))
def _inject_billing(data: str) -> str:
try:
body = json.loads(data)
except Exception:
return data
block = {"type": "text", "text": CLAUDE_CODE_BILLING_HEADER}
system = body.get("system")
if isinstance(system, list):
body["system"] = [block] + system
elif system is None:
body["system"] = [block]
else:
body["system"] = [block, {"type": "text", "text": str(system)}]
return json.dumps(body)
async def _make_call(client, api_base, headers, data, model, messages,
logging_obj, timeout, json_mode, speed=None):
data = _inject_billing(data)
if debug:
import sys
print(f"[CLAUDE-WIRE] async -> {api_base}", file=sys.stderr)
for k, v in sorted((headers or {}).items()):
print(f"[CLAUDE-WIRE] {k}: {v}", file=sys.stderr)
try:
for b in (json.loads(data).get("system") or [])[:3]:
print(f"[CLAUDE-WIRE] SYSTEM: {str(b)[:200]}", file=sys.stderr)
except Exception:
pass
return await orig_make_call(client, api_base, headers, data, model,
messages, logging_obj, timeout, json_mode, speed)
def _make_sync(client, api_base, headers, data, model, messages,
logging_obj, timeout, json_mode, speed=None):
data = _inject_billing(data)
return orig_sync(client, api_base, headers, data, model, messages,
logging_obj, timeout, json_mode, speed)
anthropic_handler.make_call = _make_call
anthropic_handler.make_sync_call = _make_sync
except Exception: # pragma: no cover - best effort, non-fatal
pass
_patch_anthropic_requests()
def _load_auth(file_obj=None) -> dict:
@@ -143,21 +238,67 @@ def _request_context(messages: list) -> tuple[list, dict]:
# Anthropic rejects a request that contains both authentication schemes.
os.environ["ANTHROPIC_AUTH_TOKEN"] = access_token
session_id = str(uuid.uuid4())
# Mirror the real Claude Code CLI (2.1.233) wire identity. Anthropic
# determines first-party vs third-party billing from the
# ``x-anthropic-billing-header`` line embedded as the *first* system
# prompt block (not an HTTP header), plus the ``sdk-cli`` entrypoint and
# the browser-access/session headers the CLI actually sends.
headers = {
"anthropic-beta": CLAUDE_CODE_BETA,
"anthropic-dangerous-direct-browser-access": "true",
"user-agent": "claude-cli/2.1.233 (external, sdk-cli)",
"x-app": "cli",
"x-claude-code-session-id": session_id,
"anthropic-dangerous-direct-browser-access": "true",
"X-Claude-Code-Session-Id": str(uuid.uuid4()),
}
prepared_messages = [
{"role": "system", "content": CLAUDE_CODE_BILLING_HEADER},
{"role": "system", "content": CLAUDE_CODE_SYSTEM},
*messages,
]
return prepared_messages, headers
def _log_debug(messages: list, optional_params: dict, extra_headers: dict) -> None:
"""Temporary request dump, enabled by CLAUDE_DEBUG=1."""
import sys
def _size(v) -> int:
try:
return len(json.dumps(v))
except Exception:
return -1
print(f"[CLAUDE-DEBUG] headers={extra_headers}", file=sys.stderr)
print(f"[CLAUDE-DEBUG] params_keys={sorted(optional_params.keys())}", file=sys.stderr)
for key in ("tool_choice", "parallel_tool_calls", "temperature", "top_p", "max_tokens"):
if key in optional_params:
print(f"[CLAUDE-DEBUG] param {key}={optional_params[key]!r}", file=sys.stderr)
for key, val in optional_params.items():
if key in ("tools", "tool_choice", "parallel_tool_calls", "temperature", "top_p", "max_tokens", "stream"):
continue
print(f"[CLAUDE-DEBUG] param {key}={json.dumps(val)[:500]}", file=sys.stderr)
tools = optional_params.get("tools") or []
print(f"[CLAUDE-DEBUG] n_tools={len(tools)}", file=sys.stderr)
for tool in tools[:30]:
if isinstance(tool, dict):
fn = tool.get("function", tool)
name = fn.get("name") if isinstance(fn, dict) else getattr(tool, "name", None)
if name is None and isinstance(tool, dict):
name = tool.get("name")
print(f"[CLAUDE-DEBUG] tool={name}", file=sys.stderr)
else:
print(f"[CLAUDE-DEBUG] tool={getattr(tool, 'name', None)}", file=sys.stderr)
for i, msg in enumerate(messages):
role = msg.get("role") if isinstance(msg, dict) else getattr(msg, "role", None)
content = msg.get("content") if isinstance(msg, dict) else getattr(msg, "content", "")
if isinstance(content, str):
head = content[:120].replace("\n", " ")
else:
head = f"<non-str:{type(content).__name__}>"
tc = msg.get("tool_calls") if isinstance(msg, dict) else None
print(f"[CLAUDE-DEBUG] msg[{i}] role={role} len={_size(content)} tool_calls={bool(tc)} :: {head}", file=sys.stderr)
def _usage_from_chunk(chunk) -> Optional[dict]:
usage = getattr(chunk, "usage", None)
if usage is None:
@@ -334,6 +475,8 @@ class ClaudeOAuthProvider(CustomLLM):
) -> AsyncIterator[GenericStreamingChunk]:
try:
prepared_messages, extra_headers = _request_context(messages)
if os.environ.get("CLAUDE_DEBUG"):
_log_debug(messages, optional_params, extra_headers)
response = await litellm.acompletion(
model=f"anthropic/{_resolve_model(model)}",
messages=prepared_messages,
+73 -59
View File
@@ -73,9 +73,9 @@ def _refresh(auth: dict) -> dict:
return auth
def _get_auth() -> dict:
def _get_auth(force: bool = False) -> dict:
auth = _load_auth()
if time.time() > _jwt_exp(auth["tokens"]["access_token"]) - 60:
if force or time.time() > _jwt_exp(auth["tokens"]["access_token"]) - 60:
auth = _refresh(auth)
return auth
@@ -218,25 +218,29 @@ class CodexProvider(CustomLLM):
client: Optional[HTTPHandler] = None,
) -> ModelResponse:
model_name = model.split("/", 1)[-1] if "/" in model else model
try:
auth = _get_auth()
except Exception as e:
raise CustomLLMError(status_code=401, message=f"Codex auth error: {e}")
body = _build_body(model_name, messages, optional_params, stream=True)
text_parts = []
prompt_tokens = completion_tokens = 0
try:
with httpx.Client(timeout=120) as c:
with c.stream("POST", API_URL, json=body, headers=_auth_headers(auth)) as resp:
resp.raise_for_status()
for chunk in _iter_sse(resp):
text_parts.append(chunk["text"])
if chunk.get("usage"):
prompt_tokens = chunk["usage"].get("prompt_tokens", 0)
completion_tokens = chunk["usage"].get("completion_tokens", 0)
except httpx.HTTPStatusError as e:
raise CustomLLMError(status_code=e.response.status_code, message=str(e))
for attempt in range(2):
try:
auth = _get_auth(force=(attempt == 1))
except Exception as e:
raise CustomLLMError(status_code=401, message=f"Codex auth error: {e}")
try:
with httpx.Client(timeout=120) as c:
with c.stream("POST", API_URL, json=body, headers=_auth_headers(auth)) as resp:
resp.raise_for_status()
for chunk in _iter_sse(resp):
text_parts.append(chunk["text"])
if chunk.get("usage"):
prompt_tokens = chunk["usage"].get("prompt_tokens", 0)
completion_tokens = chunk["usage"].get("completion_tokens", 0)
break
except httpx.HTTPStatusError as e:
if attempt == 0 and e.response.status_code == 401:
text_parts.clear()
continue
raise CustomLLMError(status_code=e.response.status_code, message=str(e))
model_response.choices[0].message.content = "".join(text_parts) # type: ignore
model_response.choices[0].finish_reason = "stop" # type: ignore
@@ -268,19 +272,22 @@ class CodexProvider(CustomLLM):
client: Optional[HTTPHandler] = None,
) -> Iterator[GenericStreamingChunk]:
model_name = model.split("/", 1)[-1] if "/" in model else model
try:
auth = _get_auth()
except Exception as e:
raise CustomLLMError(status_code=401, message=f"Codex auth error: {e}")
body = _build_body(model_name, messages, optional_params, stream=True)
try:
with httpx.Client(timeout=120) as c:
with c.stream("POST", API_URL, json=body, headers=_auth_headers(auth)) as resp:
resp.raise_for_status()
yield from _iter_sse(resp)
except httpx.HTTPStatusError as e:
raise CustomLLMError(status_code=e.response.status_code, message=str(e))
for attempt in range(2):
try:
auth = _get_auth(force=(attempt == 1))
except Exception as e:
raise CustomLLMError(status_code=401, message=f"Codex auth error: {e}")
try:
with httpx.Client(timeout=120) as c:
with c.stream("POST", API_URL, json=body, headers=_auth_headers(auth)) as resp:
resp.raise_for_status()
yield from _iter_sse(resp)
return
except httpx.HTTPStatusError as e:
if attempt == 0 and e.response.status_code == 401:
continue
raise CustomLLMError(status_code=e.response.status_code, message=str(e))
async def acompletion(
self,
@@ -302,25 +309,29 @@ class CodexProvider(CustomLLM):
client: Optional[AsyncHTTPHandler] = None,
) -> ModelResponse:
model_name = model.split("/", 1)[-1] if "/" in model else model
try:
auth = _get_auth()
except Exception as e:
raise CustomLLMError(status_code=401, message=f"Codex auth error: {e}")
body = _build_body(model_name, messages, optional_params, stream=True)
text_parts = []
prompt_tokens = completion_tokens = 0
try:
async with httpx.AsyncClient(timeout=120) as c:
async with c.stream("POST", API_URL, json=body, headers=_auth_headers(auth)) as resp:
resp.raise_for_status()
async for chunk in _aiter_sse(resp):
text_parts.append(chunk["text"])
if chunk.get("usage"):
prompt_tokens = chunk["usage"].get("prompt_tokens", 0)
completion_tokens = chunk["usage"].get("completion_tokens", 0)
except httpx.HTTPStatusError as e:
raise CustomLLMError(status_code=e.response.status_code, message=str(e))
for attempt in range(2):
try:
auth = _get_auth(force=(attempt == 1))
except Exception as e:
raise CustomLLMError(status_code=401, message=f"Codex auth error: {e}")
try:
async with httpx.AsyncClient(timeout=120) as c:
async with c.stream("POST", API_URL, json=body, headers=_auth_headers(auth)) as resp:
resp.raise_for_status()
async for chunk in _aiter_sse(resp):
text_parts.append(chunk["text"])
if chunk.get("usage"):
prompt_tokens = chunk["usage"].get("prompt_tokens", 0)
completion_tokens = chunk["usage"].get("completion_tokens", 0)
break
except httpx.HTTPStatusError as e:
if attempt == 0 and e.response.status_code == 401:
text_parts.clear()
continue
raise CustomLLMError(status_code=e.response.status_code, message=str(e))
model_response.choices[0].message.content = "".join(text_parts) # type: ignore
model_response.choices[0].finish_reason = "stop" # type: ignore
@@ -352,20 +363,23 @@ class CodexProvider(CustomLLM):
client: Optional[AsyncHTTPHandler] = None,
) -> AsyncIterator[GenericStreamingChunk]:
model_name = model.split("/", 1)[-1] if "/" in model else model
try:
auth = _get_auth()
except Exception as e:
raise CustomLLMError(status_code=401, message=f"Codex auth error: {e}")
body = _build_body(model_name, messages, optional_params, stream=True)
try:
async with httpx.AsyncClient(timeout=120) as c:
async with c.stream("POST", API_URL, json=body, headers=_auth_headers(auth)) as resp:
resp.raise_for_status()
async for chunk in _aiter_sse(resp):
yield chunk
except httpx.HTTPStatusError as e:
raise CustomLLMError(status_code=e.response.status_code, message=str(e))
for attempt in range(2):
try:
auth = _get_auth(force=(attempt == 1))
except Exception as e:
raise CustomLLMError(status_code=401, message=f"Codex auth error: {e}")
try:
async with httpx.AsyncClient(timeout=120) as c:
async with c.stream("POST", API_URL, json=body, headers=_auth_headers(auth)) as resp:
resp.raise_for_status()
async for chunk in _aiter_sse(resp):
yield chunk
return
except httpx.HTTPStatusError as e:
if attempt == 0 and e.response.status_code == 401:
continue
raise CustomLLMError(status_code=e.response.status_code, message=str(e))
codex_provider = CodexProvider()
+1
View File
@@ -12,6 +12,7 @@ services:
STORE_MODEL_IN_DB: "True"
CODEX_AUTH_FILE: /root/.codex/auth.json
CLAUDE_AUTH_FILE: /root/.claude/.credentials.json
CLAUDE_DEBUG: "1"
volumes:
- ./config.yaml:/app/config.yaml:ro
- ./codex_provider.py:/app/codex_provider.py:ro