fix(litellm): refresh OAuth identities and retry auth
This commit is contained in:
+35
-4
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user