mirror of
https://github.com/Alishahryar1/free-claude-code.git
synced 2026-07-03 14:05:26 +02:00
19e08f2da1
## What Fixes the 422 error returned by the proxy when Claude Code v2.1.128+ sends PDF context as Anthropic `document` blocks, plus two related DeepSeek-only issues uncovered while debugging. Closes #357. ## Why Claude Code now attaches PDFs/files as `document` content blocks. The `Message.content` union didn't include that variant, so every follow-up turn after a PDF read returned `422 Unprocessable Content` from the proxy itself (before the request ever reached DeepSeek). ## Changes - `api/models/anthropic.py`: new `ContentBlockDocument`, added to `Message.content` union. - `providers/deepseek/request.py`: - `_strip_unsupported_attachment_blocks` — silently drops `image`/`document` blocks for DeepSeek. - `_serialize_tool_result_content` + `_normalize_tool_result_content` — coerces `tool_result.content` to a string per DeepSeek's API contract. - Hooked into `build_request_body` (strip before validation, normalize before send). - `tests/providers/test_deepseek.py`: +4 tests, 1 legacy test updated. ## Verification - `uv run ruff format` ✅ - `uv run ruff check` ✅ - `uv run ty check` ✅ - `uv run pytest` ✅ (1194 passed) - Manual: `claude-deepseek` against a PDF no longer 422s. ## Scope / non-goals - No README change (per CONTRIBUTING). - Image/document blocks for DeepSeek are stripped, not converted to text — Claude Code already ships the extracted text in the paired tool_result. - No changes to other providers. --------- Co-authored-by: Alishahryar1 <alishahryar2@gmail.com>
459 lines
16 KiB
Python
459 lines
16 KiB
Python
"""Request builder and DeepSeek native Anthropic compatibility sanitizer."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from collections.abc import Mapping
|
|
from typing import Any
|
|
|
|
from loguru import logger
|
|
|
|
from config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
|
|
from core.anthropic.native_messages_request import dump_raw_messages_request
|
|
from providers.exceptions import InvalidRequestError
|
|
|
|
# Block types not supported on DeepSeek partial Anthropic-compatible API.
|
|
_UNSUPPORTED_MESSAGE_BLOCK_TYPES = frozenset(
|
|
{
|
|
"image",
|
|
"document",
|
|
"server_tool_use",
|
|
"web_search_tool_result",
|
|
"web_fetch_tool_result",
|
|
}
|
|
)
|
|
|
|
# Block types silently stripped for DeepSeek since the content is typically
|
|
# also provided via tool_result (e.g. Claude Code attaches PDFs as document
|
|
# blocks alongside a Read tool_result containing the text).
|
|
_STRIPPABLE_MESSAGE_BLOCK_TYPES = frozenset({"image", "document"})
|
|
_OMITTED_ATTACHMENT_TEXT = (
|
|
"[attachment omitted: DeepSeek does not support image or document inputs]"
|
|
)
|
|
_OMITTED_ATTACHMENT_BLOCK = {"type": "text", "text": _OMITTED_ATTACHMENT_TEXT}
|
|
|
|
|
|
def _strip_unsupported_attachment_blocks(messages: Any) -> Any:
|
|
"""Remove image/document blocks that DeepSeek cannot process.
|
|
|
|
Claude Code sends PDFs as ``document`` blocks alongside a Read ``tool_result``
|
|
that already contains the extracted text. Stripping preserves the request
|
|
instead of failing with an unsupported block error.
|
|
"""
|
|
if not isinstance(messages, list):
|
|
return messages
|
|
|
|
stripped: list[Any] = []
|
|
top_level_dropped: dict[str, int] = {}
|
|
nested_dropped: dict[str, int] = {}
|
|
placeholder_replacements = 0
|
|
|
|
for message in messages:
|
|
if not isinstance(message, dict):
|
|
stripped.append(message)
|
|
continue
|
|
content = message.get("content")
|
|
if not isinstance(content, list):
|
|
stripped.append(message)
|
|
continue
|
|
|
|
new_content: list[Any] = []
|
|
message_dropped_attachment = False
|
|
for block in content:
|
|
if isinstance(block, dict):
|
|
btype = block.get("type")
|
|
if btype in _STRIPPABLE_MESSAGE_BLOCK_TYPES:
|
|
top_level_dropped[btype] = top_level_dropped.get(btype, 0) + 1
|
|
message_dropped_attachment = True
|
|
continue
|
|
if btype == "tool_result":
|
|
inner = block.get("content")
|
|
if isinstance(inner, list):
|
|
filtered_inner: list[Any] = []
|
|
for sub in inner:
|
|
if (
|
|
isinstance(sub, dict)
|
|
and sub.get("type") in _STRIPPABLE_MESSAGE_BLOCK_TYPES
|
|
):
|
|
sub_type = sub["type"]
|
|
nested_dropped[sub_type] = (
|
|
nested_dropped.get(sub_type, 0) + 1
|
|
)
|
|
continue
|
|
filtered_inner.append(sub)
|
|
if not filtered_inner:
|
|
filtered_inner = [_OMITTED_ATTACHMENT_BLOCK]
|
|
placeholder_replacements += 1
|
|
new_block = dict(block)
|
|
new_block["content"] = filtered_inner
|
|
new_content.append(new_block)
|
|
continue
|
|
new_content.append(block)
|
|
if not new_content and message_dropped_attachment:
|
|
new_content = [_OMITTED_ATTACHMENT_BLOCK]
|
|
placeholder_replacements += 1
|
|
new_msg = dict(message)
|
|
new_msg["content"] = new_content
|
|
stripped.append(new_msg)
|
|
|
|
if top_level_dropped or nested_dropped:
|
|
logger.warning(
|
|
"DEEPSEEK_REQUEST: stripped unsupported attachment blocks "
|
|
"(top_level={} nested_in_tool_result={} placeholder_tool_results={}). "
|
|
"DeepSeek has no vision/document support; the model will not see this content.",
|
|
dict(top_level_dropped),
|
|
dict(nested_dropped),
|
|
placeholder_replacements,
|
|
)
|
|
return stripped
|
|
|
|
|
|
def _is_server_listed_tool(tool: Mapping[str, Any]) -> bool:
|
|
"""True for Anthropic web_search / web_fetch-style tool definitions (listed tools)."""
|
|
name = (tool.get("name") or "").strip()
|
|
if name in ("web_search", "web_fetch"):
|
|
return True
|
|
typ = tool.get("type")
|
|
if isinstance(typ, str):
|
|
return typ.startswith("web_search") or typ.startswith("web_fetch")
|
|
return False
|
|
|
|
|
|
def _walk_block_list_for_unsupported(blocks: Any, *, where: str) -> None:
|
|
if not isinstance(blocks, list):
|
|
return
|
|
for block in blocks:
|
|
if not isinstance(block, dict):
|
|
continue
|
|
btype = block.get("type")
|
|
if btype in _UNSUPPORTED_MESSAGE_BLOCK_TYPES:
|
|
raise InvalidRequestError(
|
|
f"DeepSeek native does not support {btype!r} blocks ({where})."
|
|
)
|
|
if btype == "tool_result" and "content" in block:
|
|
_walk_block_list_for_unsupported(
|
|
block["content"], where=f"{where} (tool_result content)"
|
|
)
|
|
|
|
|
|
def _validate_deepseek_native_request_dict(data: dict[str, Any]) -> None:
|
|
mcp = data.get("mcp_servers")
|
|
if mcp:
|
|
raise InvalidRequestError(
|
|
"DeepSeek native does not support mcp_servers on requests."
|
|
)
|
|
|
|
for tool in data.get("tools") or ():
|
|
if not isinstance(tool, dict):
|
|
continue
|
|
if _is_server_listed_tool(tool):
|
|
raise InvalidRequestError(
|
|
"DeepSeek native does not support listed Anthropic server tools "
|
|
"(web_search / web_fetch). Remove them or use a different provider."
|
|
)
|
|
|
|
for i, message in enumerate(data.get("messages") or ()):
|
|
if not isinstance(message, dict):
|
|
continue
|
|
c = message.get("content")
|
|
if isinstance(c, list):
|
|
_walk_block_list_for_unsupported(c, where=f"messages[{i}].content")
|
|
if isinstance(c, str) and "<think>" in c:
|
|
# Unusual, but block encoded redacted content — treat as unsafe for DeepSeek.
|
|
pass
|
|
|
|
system = data.get("system")
|
|
if isinstance(system, list):
|
|
_walk_block_list_for_unsupported(system, where="system")
|
|
|
|
|
|
def _has_tool_history_blocks(message: Mapping[str, Any]) -> bool:
|
|
role = message.get("role")
|
|
content = message.get("content")
|
|
if not isinstance(content, list):
|
|
return False
|
|
|
|
for block in content:
|
|
if not isinstance(block, dict):
|
|
continue
|
|
btype = block.get("type")
|
|
if role == "assistant" and btype == "tool_use":
|
|
return True
|
|
if role == "user" and btype == "tool_result":
|
|
return True
|
|
return False
|
|
|
|
|
|
def _has_replayable_thinking_before_tool_use(message: Mapping[str, Any]) -> bool:
|
|
if message.get("role") != "assistant":
|
|
return False
|
|
content = message.get("content")
|
|
if not isinstance(content, list):
|
|
return False
|
|
|
|
has_thinking = False
|
|
for block in content:
|
|
if not isinstance(block, dict):
|
|
continue
|
|
btype = block.get("type")
|
|
if btype == "thinking" and isinstance(block.get("thinking"), str):
|
|
has_thinking = bool(block["thinking"])
|
|
continue
|
|
if btype == "tool_use":
|
|
return has_thinking
|
|
return False
|
|
|
|
|
|
def _has_tool_history(data: dict[str, Any]) -> bool:
|
|
for message in data.get("messages") or ():
|
|
if isinstance(message, Mapping) and _has_tool_history_blocks(message):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _has_replayable_tool_thinking(data: dict[str, Any]) -> bool:
|
|
for message in data.get("messages") or ():
|
|
if isinstance(message, Mapping) and _has_replayable_thinking_before_tool_use(
|
|
message
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _remove_deepseek_thinking_hints(data: dict[str, Any]) -> None:
|
|
"""Remove request hints that can keep DeepSeek in thinking mode after fallback."""
|
|
output_config = data.get("output_config")
|
|
if isinstance(output_config, dict) and "effort" in output_config:
|
|
cleaned_output_config = dict(output_config)
|
|
cleaned_output_config.pop("effort", None)
|
|
if cleaned_output_config:
|
|
data["output_config"] = cleaned_output_config
|
|
else:
|
|
data.pop("output_config", None)
|
|
|
|
context_management = data.get("context_management")
|
|
if not isinstance(context_management, dict):
|
|
return
|
|
edits = context_management.get("edits")
|
|
if not isinstance(edits, list):
|
|
return
|
|
filtered_edits = [
|
|
edit
|
|
for edit in edits
|
|
if not (
|
|
isinstance(edit, dict)
|
|
and isinstance(edit.get("type"), str)
|
|
and edit["type"].startswith("clear_thinking_")
|
|
)
|
|
]
|
|
if len(filtered_edits) == len(edits):
|
|
return
|
|
cleaned_context_management = dict(context_management)
|
|
if filtered_edits:
|
|
cleaned_context_management["edits"] = filtered_edits
|
|
data["context_management"] = cleaned_context_management
|
|
else:
|
|
cleaned_context_management.pop("edits", None)
|
|
if cleaned_context_management:
|
|
data["context_management"] = cleaned_context_management
|
|
else:
|
|
data.pop("context_management", None)
|
|
|
|
|
|
def sanitize_deepseek_messages_for_native(
|
|
messages: Any, *, thinking_enabled: bool
|
|
) -> Any:
|
|
"""Filter assistant content for DeepSeek: unsigned ``thinking`` is allowed; no ``redacted_thinking``."""
|
|
if not isinstance(messages, list):
|
|
return messages
|
|
|
|
sanitized: list[Any] = []
|
|
for message in messages:
|
|
if not isinstance(message, dict):
|
|
sanitized.append(message)
|
|
continue
|
|
if message.get("role") != "assistant":
|
|
sanitized.append(message)
|
|
continue
|
|
content = message.get("content")
|
|
if not isinstance(content, list):
|
|
sanitized.append(message)
|
|
continue
|
|
|
|
if not thinking_enabled:
|
|
filtered = [
|
|
block
|
|
for block in content
|
|
if not (
|
|
isinstance(block, dict)
|
|
and block.get("type") in ("thinking", "redacted_thinking")
|
|
)
|
|
]
|
|
else:
|
|
filtered = [
|
|
block
|
|
for block in content
|
|
if not (
|
|
isinstance(block, dict) and block.get("type") == "redacted_thinking"
|
|
)
|
|
]
|
|
new_msg = dict(message)
|
|
new_msg["content"] = filtered or ""
|
|
sanitized.append(new_msg)
|
|
return sanitized
|
|
|
|
|
|
def _serialize_tool_result_content(content: Any) -> str:
|
|
"""Serialize tool_result content to string for DeepSeek API.
|
|
|
|
DeepSeek's Anthropic-compatible API expects tool_result.content to be a string,
|
|
not an array of content blocks.
|
|
"""
|
|
if content is None:
|
|
return ""
|
|
if isinstance(content, str):
|
|
return content
|
|
if isinstance(content, dict):
|
|
return json.dumps(content, ensure_ascii=False)
|
|
if isinstance(content, list):
|
|
parts: list[str] = []
|
|
for item in content:
|
|
if isinstance(item, dict) and item.get("type") == "text":
|
|
parts.append(str(item.get("text", "")))
|
|
elif isinstance(item, dict):
|
|
parts.append(json.dumps(item, ensure_ascii=False))
|
|
else:
|
|
parts.append(str(item))
|
|
return "\n".join(parts)
|
|
return str(content)
|
|
|
|
|
|
def _normalize_tool_result_content(messages: Any) -> Any:
|
|
"""Normalize tool_result content to strings for DeepSeek API compatibility."""
|
|
if not isinstance(messages, list):
|
|
return messages
|
|
|
|
normalized: list[Any] = []
|
|
for message in messages:
|
|
if not isinstance(message, dict):
|
|
normalized.append(message)
|
|
continue
|
|
|
|
content = message.get("content")
|
|
if not isinstance(content, list):
|
|
normalized.append(message)
|
|
continue
|
|
|
|
# Process content blocks
|
|
new_content: list[Any] = []
|
|
for block in content:
|
|
if not isinstance(block, dict):
|
|
new_content.append(block)
|
|
continue
|
|
|
|
if block.get("type") == "tool_result":
|
|
# Normalize tool_result content to string
|
|
normalized_block = dict(block)
|
|
normalized_block["content"] = _serialize_tool_result_content(
|
|
block.get("content")
|
|
)
|
|
new_content.append(normalized_block)
|
|
else:
|
|
new_content.append(block)
|
|
|
|
new_msg = dict(message)
|
|
new_msg["content"] = new_content
|
|
normalized.append(new_msg)
|
|
|
|
return normalized
|
|
|
|
|
|
def _strip_reasoning_content_when_native(messages: Any) -> Any:
|
|
"""``reasoning_content`` is OpenAI-helper metadata; not part of native Anthropic body."""
|
|
if not isinstance(messages, list):
|
|
return messages
|
|
out: list[Any] = []
|
|
for m in messages:
|
|
if not isinstance(m, dict):
|
|
out.append(m)
|
|
continue
|
|
msg = {k: v for k, v in m.items() if k != "reasoning_content"}
|
|
out.append(msg)
|
|
return out
|
|
|
|
|
|
def build_request_body(request_data: Any, *, thinking_enabled: bool) -> dict:
|
|
"""Build a DeepSeek ``/v1/messages`` JSON body (Anthropic format)."""
|
|
logger.debug(
|
|
"DEEPSEEK_REQUEST: native build model={} msgs={}",
|
|
getattr(request_data, "model", "?"),
|
|
len(getattr(request_data, "messages", [])),
|
|
)
|
|
|
|
data = dump_raw_messages_request(request_data)
|
|
if "messages" in data:
|
|
data["messages"] = _strip_unsupported_attachment_blocks(data["messages"])
|
|
_validate_deepseek_native_request_dict(data)
|
|
data.pop("extra_body", None)
|
|
|
|
has_tool_history = _has_tool_history(data)
|
|
has_replayable_tool_thinking = _has_replayable_tool_thinking(data)
|
|
unsafe_tool_followup = has_tool_history and not has_replayable_tool_thinking
|
|
effective_thinking_enabled = thinking_enabled and not unsafe_tool_followup
|
|
if thinking_enabled:
|
|
if unsafe_tool_followup:
|
|
logger.debug(
|
|
"DEEPSEEK_REQUEST: disabling thinking for tool follow-up without "
|
|
"replayable thinking model={} msgs={} tools={}",
|
|
data.get("model"),
|
|
len(data.get("messages", [])),
|
|
len(data.get("tools", [])),
|
|
)
|
|
_remove_deepseek_thinking_hints(data)
|
|
elif has_tool_history:
|
|
logger.debug(
|
|
"DEEPSEEK_REQUEST: keeping thinking for tool follow-up with "
|
|
"replayable thinking model={} msgs={} tools={}",
|
|
data.get("model"),
|
|
len(data.get("messages", [])),
|
|
len(data.get("tools", [])),
|
|
)
|
|
elif data.get("tools") or data.get("tool_choice"):
|
|
logger.debug(
|
|
"DEEPSEEK_REQUEST: keeping thinking for initial tool request "
|
|
"model={} msgs={} tools={}",
|
|
data.get("model"),
|
|
len(data.get("messages", [])),
|
|
len(data.get("tools", [])),
|
|
)
|
|
|
|
thinking_cfg = data.pop("thinking", None)
|
|
if effective_thinking_enabled and isinstance(thinking_cfg, dict):
|
|
thinking_payload: dict[str, Any] = {"type": "enabled"}
|
|
budget_tokens = thinking_cfg.get("budget_tokens")
|
|
if isinstance(budget_tokens, int):
|
|
thinking_payload["budget_tokens"] = budget_tokens
|
|
data["thinking"] = thinking_payload
|
|
|
|
if "messages" in data:
|
|
data["messages"] = _strip_reasoning_content_when_native(
|
|
_normalize_tool_result_content(
|
|
sanitize_deepseek_messages_for_native(
|
|
data["messages"],
|
|
thinking_enabled=effective_thinking_enabled,
|
|
)
|
|
)
|
|
)
|
|
if "max_tokens" not in data or data.get("max_tokens") is None:
|
|
data["max_tokens"] = ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
|
|
|
|
data["stream"] = True
|
|
|
|
logger.debug(
|
|
"DEEPSEEK_REQUEST: build done model={} msgs={} tools={}",
|
|
data.get("model"),
|
|
len(data.get("messages", [])),
|
|
len(data.get("tools", [])),
|
|
)
|
|
return data
|