Fix DeepSeek cache usage accounting (#937)
Some checks failed
CI / Ban type ignore suppressions (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / ruff-check (push) Has been cancelled
CI / ruff-format (push) Has been cancelled
CI / ty (push) Has been cancelled

This commit is contained in:
Ali Khokhar 2026-06-28 22:08:51 -07:00 committed by GitHub
parent 478e96655c
commit bdefb46d16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 272 additions and 235 deletions

View file

@ -352,7 +352,10 @@ Native Anthropic providers call the native request policy for raw request
dumping, default tokens, stream flags, thinking payloads, and `extra_body`
handling. Concrete provider packages keep only true upstream quirks such as
Gemini thought signatures, NIM tool-schema aliases and retry downgrades, or
DeepSeek attachment/tool/thinking compatibility.
DeepSeek attachment/tool/thinking compatibility. DeepSeek intentionally uses its
OpenAI-compatible Chat Completions endpoint because that is the endpoint that
reports prompt-cache hit/miss counters; the provider maps those counters back
into Anthropic usage fields for Claude-compatible clients.
Shared provider responsibilities include upstream rate limiting, model listing,
safe error mapping, transport cleanup, thinking/tool handling, retry or recovery

View file

@ -197,7 +197,7 @@ Get a key at [platform.deepseek.com/api_keys](https://platform.deepseek.com/api_
In the Admin UI, paste it into `DEEPSEEK_API_KEY`, then set `MODEL` to a DeepSeek slug such as `deepseek/deepseek-chat`.
This provider uses DeepSeek's Anthropic-compatible endpoint, not the OpenAI chat-completions endpoint.
FCC uses DeepSeek's OpenAI-compatible Chat Completions endpoint so DeepSeek's prompt-cache hit/miss counters can be mapped into Claude-compatible usage metadata.
### 5. [Mistral La Plateforme](https://console.mistral.ai/)
@ -570,8 +570,8 @@ Important pieces:
- Responses requests convert to Anthropic Messages internally, then share the same model router, normalizer, and provider adapters.
- `fcc-codex` registers a custom `fcc` provider that points Codex at the local proxy's `/v1/responses` endpoint.
- Model routing resolves Claude model names to `MODEL_OPUS`, `MODEL_SONNET`, `MODEL_HAIKU`, or `MODEL`.
- NIM, OpenCode Zen, and OpenCode Go use OpenAI chat streaming translated into Anthropic SSE.
- Wafer, OpenRouter, DeepSeek, Kimi, Fireworks AI, Cloudflare, Z.ai, LM Studio, llama.cpp, and Ollama use Anthropic Messages style transports where applicable (with provider-specific quirks and model-list URLs).
- NIM, DeepSeek, OpenCode Zen, and OpenCode Go use OpenAI chat streaming translated into Anthropic SSE.
- Wafer, OpenRouter, Kimi, Fireworks AI, Cloudflare, Z.ai, LM Studio, llama.cpp, and Ollama use Anthropic Messages style transports where applicable (with provider-specific quirks and model-list URLs).
- The proxy normalizes thinking blocks, tool calls, token usage metadata, and provider errors into the shape each client expects.
- Request optimizations answer trivial Claude Code probes locally to save latency and quota.

View file

@ -16,10 +16,8 @@ NVIDIA_NIM_DEFAULT_BASE = "https://integrate.api.nvidia.com/v1"
# Moonshot Kimi Anthropic-compatible Messages API (POST …/messages).
KIMI_DEFAULT_BASE = "https://api.moonshot.ai/anthropic/v1"
WAFER_DEFAULT_BASE = "https://pass.wafer.ai/v1"
# DeepSeek Anthropic-compatible Messages API (not OpenAI ``/v1`` chat completions).
DEEPSEEK_ANTHROPIC_DEFAULT_BASE = "https://api.deepseek.com/anthropic"
# Historical export name: DeepSeek upstream is the native Anthropic path above.
DEEPSEEK_DEFAULT_BASE = DEEPSEEK_ANTHROPIC_DEFAULT_BASE
# DeepSeek Chat Completions API; cache usage is reported on this endpoint.
DEEPSEEK_DEFAULT_BASE = "https://api.deepseek.com"
FIREWORKS_DEFAULT_BASE = "https://api.fireworks.ai/inference/v1"
# Cloudflare account-scoped AI REST root; provider appends /accounts/{id}/ai/v1.
CLOUDFLARE_AI_REST_ROOT = "https://api.cloudflare.com/client/v4"
@ -94,12 +92,12 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = {
"deepseek": ProviderDescriptor(
provider_id="deepseek",
display_name="DeepSeek",
transport_type="anthropic_messages",
transport_type="openai_chat",
credential_env="DEEPSEEK_API_KEY",
credential_url="https://platform.deepseek.com/api_keys",
credential_attr="deepseek_api_key",
default_base_url=DEEPSEEK_ANTHROPIC_DEFAULT_BASE,
capabilities=("chat", "streaming", "tools", "thinking", "native_anthropic"),
default_base_url=DEEPSEEK_DEFAULT_BASE,
capabilities=("chat", "streaming", "tools", "thinking", "rate_limit"),
),
"mistral": ProviderDescriptor(
provider_id="mistral",

View file

@ -5,7 +5,7 @@ from __future__ import annotations
import hashlib
import json
import uuid
from collections.abc import Iterator
from collections.abc import Iterator, Mapping
from contextlib import suppress
from dataclasses import dataclass, field
from typing import Any
@ -207,16 +207,31 @@ class AnthropicStreamLedger:
},
)
def message_delta(self, stop_reason: str, output_tokens: int | None) -> str:
def message_delta(
self,
stop_reason: str,
output_tokens: int | None,
*,
usage_fields: Mapping[str, int] | None = None,
) -> str:
self.stop_reason = stop_reason
safe_in = _safe_usage_int(self.input_tokens)
safe_out = output_tokens if isinstance(output_tokens, int) else 0
usage = {"input_tokens": safe_in, "output_tokens": safe_out}
if usage_fields:
usage.update(
{
key: value
for key, value in usage_fields.items()
if isinstance(value, int)
}
)
return self._emitter.event(
"message_delta",
{
"type": "message_delta",
"delta": {"stop_reason": stop_reason, "stop_sequence": None},
"usage": {"input_tokens": safe_in, "output_tokens": safe_out},
"usage": usage,
},
)

View file

@ -1,11 +1,10 @@
"""DeepSeek provider exports."""
from providers.defaults import DEEPSEEK_ANTHROPIC_DEFAULT_BASE, DEEPSEEK_DEFAULT_BASE
from providers.defaults import DEEPSEEK_DEFAULT_BASE
from .client import DeepSeekProvider
__all__ = [
"DEEPSEEK_ANTHROPIC_DEFAULT_BASE",
"DEEPSEEK_DEFAULT_BASE",
"DeepSeekProvider",
]

View file

@ -1,26 +1,26 @@
"""DeepSeek provider implementation (native Anthropic-compatible Messages)."""
"""DeepSeek provider implementation (OpenAI-compatible Chat Completions)."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
import httpx
from providers.base import ProviderConfig
from providers.defaults import DEEPSEEK_ANTHROPIC_DEFAULT_BASE
from providers.transports.anthropic_messages import AnthropicMessagesTransport
from providers.defaults import DEEPSEEK_DEFAULT_BASE
from providers.transports.openai_chat import OpenAIChatTransport
from .compat import build_deepseek_request_body
class DeepSeekProvider(AnthropicMessagesTransport):
"""DeepSeek using ``https://api.deepseek.com/anthropic`` (Anthropic Messages API)."""
class DeepSeekProvider(OpenAIChatTransport):
"""DeepSeek using ``https://api.deepseek.com`` Chat Completions."""
def __init__(self, config: ProviderConfig):
super().__init__(
config,
provider_name="DEEPSEEK",
default_base_url=DEEPSEEK_ANTHROPIC_DEFAULT_BASE,
base_url=config.base_url or DEEPSEEK_DEFAULT_BASE,
api_key=config.api_key,
)
def _build_request_body(
@ -31,21 +31,26 @@ class DeepSeekProvider(AnthropicMessagesTransport):
thinking_enabled=self._is_thinking_enabled(request, thinking_enabled),
)
def _request_headers(self) -> dict[str, str]:
return {
"Accept": "text/event-stream",
"Content-Type": "application/json",
"x-api-key": self._api_key,
}
def _anthropic_usage_fields(self, usage_info: Any) -> dict[str, int]:
usage_fields: dict[str, int] = {}
cache_hit_tokens = _usage_int(usage_info, "prompt_cache_hit_tokens")
if cache_hit_tokens is not None:
usage_fields["cache_read_input_tokens"] = cache_hit_tokens
cache_miss_tokens = _usage_int(usage_info, "prompt_cache_miss_tokens")
if cache_miss_tokens is not None:
usage_fields["cache_creation_input_tokens"] = cache_miss_tokens
return usage_fields
async def _send_model_list_request(self) -> httpx.Response:
"""DeepSeek lists models from the OpenAI-format root, not /anthropic."""
url = str(
httpx.URL(self._base_url).copy_with(
path="/models", query=None, fragment=None
)
)
return await self._client.get(url, headers=self._model_list_headers())
def _model_list_headers(self) -> dict[str, str]:
return {"Authorization": f"Bearer {self._api_key}"}
def _usage_int(usage_info: Any, key: str) -> int | None:
if usage_info is None:
return None
if isinstance(usage_info, Mapping):
value = usage_info.get(key)
else:
value = getattr(usage_info, key, None)
if value is None:
extra = getattr(usage_info, "model_extra", None)
if isinstance(extra, Mapping):
value = extra.get(key)
return value if isinstance(value, int) else None

View file

@ -1,8 +1,9 @@
"""DeepSeek native Anthropic compatibility request policy."""
"""DeepSeek Anthropic-to-OpenAI chat request policy."""
from __future__ import annotations
from collections.abc import Mapping
from types import SimpleNamespace
from typing import Any
from loguru import logger
@ -11,6 +12,15 @@ from config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
from core.anthropic import serialize_tool_result_content
from core.anthropic.native_messages_request import dump_raw_messages_request
from providers.exceptions import InvalidRequestError
from providers.transports.openai_chat import (
OpenAIChatRequestPolicy,
build_openai_chat_request_body,
)
_REQUEST_POLICY = OpenAIChatRequestPolicy(
provider_name="DEEPSEEK",
include_extra_body=True,
)
_UNSUPPORTED_MESSAGE_BLOCK_TYPES = frozenset(
{
@ -29,9 +39,9 @@ _OMITTED_ATTACHMENT_BLOCK = {"type": "text", "text": _OMITTED_ATTACHMENT_TEXT}
def build_deepseek_request_body(request_data: Any, *, thinking_enabled: bool) -> dict:
"""Build a DeepSeek ``/v1/messages`` JSON body (Anthropic format)."""
"""Build a DeepSeek Chat Completions body from an Anthropic request."""
logger.debug(
"DEEPSEEK_REQUEST: native build model={} msgs={}",
"DEEPSEEK_REQUEST: chat build model={} msgs={}",
getattr(request_data, "model", "?"),
len(getattr(request_data, "messages", [])),
)
@ -39,8 +49,7 @@ def build_deepseek_request_body(request_data: Any, *, thinking_enabled: bool) ->
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)
_validate_deepseek_request_dict(data)
_downgrade_forced_tool_choice(data)
has_tool_history = _has_tool_history(data)
@ -74,41 +83,37 @@ def build_deepseek_request_body(request_data: Any, *, thinking_enabled: bool) ->
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,
)
data["messages"] = _normalize_tool_result_content(
sanitize_deepseek_messages_for_openai(
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
sanitized_request = _request_from_dict(request_data, data)
body = build_openai_chat_request_body(
sanitized_request,
thinking_enabled=effective_thinking_enabled,
policy=_REQUEST_POLICY,
postprocessors=(_apply_deepseek_chat_extras,),
)
if "max_tokens" not in body or body.get("max_tokens") is None:
body["max_tokens"] = ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
logger.debug(
"DEEPSEEK_REQUEST: build done model={} msgs={} tools={}",
data.get("model"),
len(data.get("messages", [])),
len(data.get("tools", [])),
body.get("model"),
len(body.get("messages", [])),
len(body.get("tools", [])),
)
return data
return body
def sanitize_deepseek_messages_for_native(
def sanitize_deepseek_messages_for_openai(
messages: Any, *, thinking_enabled: bool
) -> Any:
"""Filter assistant content for DeepSeek's partial native Anthropic support."""
"""Filter assistant content before converting to DeepSeek Chat Completions."""
if not isinstance(messages, list):
return messages
@ -244,19 +249,17 @@ def _walk_block_list_for_unsupported(blocks: Any, *, where: str) -> None:
)
def _validate_deepseek_native_request_dict(data: dict[str, Any]) -> None:
def _validate_deepseek_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."
)
raise InvalidRequestError("DeepSeek 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 "
"DeepSeek does not support listed Anthropic server tools "
"(web_search / web_fetch). Remove them or use a different provider."
)
@ -401,20 +404,6 @@ def _normalize_tool_result_content(messages: Any) -> Any:
return normalized
def _strip_reasoning_content_when_native(messages: Any) -> Any:
if not isinstance(messages, list):
return messages
out: list[Any] = []
for message in messages:
if not isinstance(message, dict):
out.append(message)
continue
out.append(
{key: value for key, value in message.items() if key != "reasoning_content"}
)
return out
def _downgrade_forced_tool_choice(data: dict[str, Any]) -> None:
tool_choice = data.get("tool_choice")
if not isinstance(tool_choice, dict):
@ -429,3 +418,24 @@ def _downgrade_forced_tool_choice(data: dict[str, Any]) -> None:
tool_choice["name"],
)
data["tool_choice"] = {"type": "auto"}
def _request_from_dict(request_data: Any, data: dict[str, Any]) -> Any:
validator = getattr(type(request_data), "model_validate", None)
if callable(validator):
return validator(data)
return SimpleNamespace(**data)
def _apply_deepseek_chat_extras(
body: dict[str, Any], _request_data: Any, thinking_enabled: bool
) -> None:
stream_options = body.setdefault("stream_options", {})
if isinstance(stream_options, dict):
stream_options["include_usage"] = True
if not thinking_enabled or body.get("model") == "deepseek-reasoner":
return
extra_body = body.setdefault("extra_body", {})
if isinstance(extra_body, dict):
extra_body.setdefault("thinking", {"type": "enabled"})

View file

@ -4,7 +4,6 @@ from config.provider_catalog import (
CEREBRAS_DEFAULT_BASE,
CLOUDFLARE_AI_REST_ROOT,
CODESTRAL_DEFAULT_BASE,
DEEPSEEK_ANTHROPIC_DEFAULT_BASE,
DEEPSEEK_DEFAULT_BASE,
GEMINI_DEFAULT_BASE,
GROQ_DEFAULT_BASE,
@ -25,7 +24,6 @@ __all__ = (
"CEREBRAS_DEFAULT_BASE",
"CLOUDFLARE_AI_REST_ROOT",
"CODESTRAL_DEFAULT_BASE",
"DEEPSEEK_ANTHROPIC_DEFAULT_BASE",
"DEEPSEEK_DEFAULT_BASE",
"GEMINI_DEFAULT_BASE",
"GROQ_DEFAULT_BASE",

View file

@ -380,6 +380,7 @@ class OpenAIChatStreamAdapter:
ledger.message_delta(
ledger.final_stop_reason(map_stop_reason(finish_reason)),
output_tokens,
usage_fields=self._transport._anthropic_usage_fields(usage_info),
)
):
yield event

View file

@ -107,6 +107,10 @@ class OpenAIChatTransport(BaseProvider):
"""Return provider-specific per-tool argument aliases for this request."""
return {}
def _anthropic_usage_fields(self, usage_info: Any) -> dict[str, int]:
"""Return provider-specific Anthropic usage fields for final SSE usage."""
return {}
async def _create_stream(self, body: dict) -> tuple[Any, dict]:
"""Create a streaming chat completion, optionally retrying once."""
try:

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "free-claude-code"
version = "2.4.0"
version = "2.4.1"
description = "Middleware between Claude Code CLI (Anthropic API) and NVIDIA NIM"
readme = "README.md"
requires-python = ">=3.14.0"

View file

@ -1,8 +1,9 @@
"""Tests for DeepSeek native Anthropic Messages provider."""
"""Tests for DeepSeek OpenAI-compatible Chat Completions provider."""
import logging
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, patch
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
@ -13,9 +14,9 @@ from api.models.anthropic import (
Tool,
)
from config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
from core.anthropic.stream_contracts import parse_sse_text
from providers.base import ProviderConfig
from providers.deepseek import (
DEEPSEEK_ANTHROPIC_DEFAULT_BASE,
DEEPSEEK_DEFAULT_BASE,
DeepSeekProvider,
)
@ -26,7 +27,7 @@ from providers.exceptions import InvalidRequestError
def deepseek_config():
return ProviderConfig(
api_key="test_deepseek_key",
base_url=DEEPSEEK_ANTHROPIC_DEFAULT_BASE,
base_url=DEEPSEEK_DEFAULT_BASE,
rate_limit=10,
rate_window=60,
enable_thinking=True,
@ -39,9 +40,7 @@ def mock_rate_limiter():
async def _slot():
yield
with patch(
"providers.transports.anthropic_messages.transport.GlobalRateLimiter"
) as mock:
with patch("providers.transports.openai_chat.transport.GlobalRateLimiter") as mock:
instance = mock.get_scoped_instance.return_value
async def _passthrough(fn, *args, **kwargs):
@ -58,26 +57,18 @@ def deepseek_provider(deepseek_config):
def test_default_base_url_alias():
assert DEEPSEEK_DEFAULT_BASE == DEEPSEEK_ANTHROPIC_DEFAULT_BASE
assert DEEPSEEK_ANTHROPIC_DEFAULT_BASE == "https://api.deepseek.com/anthropic"
assert DEEPSEEK_DEFAULT_BASE == "https://api.deepseek.com"
def test_init(deepseek_config):
with patch("httpx.AsyncClient") as mock_client:
with patch("providers.transports.openai_chat.transport.AsyncOpenAI") as mock_client:
provider = DeepSeekProvider(deepseek_config)
assert provider._api_key == "test_deepseek_key"
assert provider._base_url == "https://api.deepseek.com/anthropic"
assert provider._base_url == "https://api.deepseek.com"
assert mock_client.called
def test_request_headers_includes_x_api_key(deepseek_provider):
h = deepseek_provider._request_headers()
assert h["x-api-key"] == "test_deepseek_key"
assert h["Content-Type"] == "application/json"
assert h["Accept"] == "text/event-stream"
def test_build_request_body_native_shape(deepseek_provider):
def test_build_request_body_openai_chat_shape(deepseek_provider):
request = MessagesRequest(
model="deepseek-v4-pro",
max_tokens=100,
@ -86,14 +77,12 @@ def test_build_request_body_native_shape(deepseek_provider):
)
body = deepseek_provider._build_request_body(request)
assert body["model"] == "deepseek-v4-pro"
assert body["stream"] is True
assert body["messages"][0]["role"] == "user"
assert body["messages"][0]["content"] in (
"Hello",
[{"type": "text", "text": "Hello"}],
)
assert body["system"] == "S"
assert "stream" not in body
assert body["messages"][0] == {"role": "system", "content": "S"}
assert body["messages"][1]["role"] == "user"
assert body["messages"][1] == {"role": "user", "content": "Hello"}
assert body["max_tokens"] == 100
assert body["stream_options"] == {"include_usage": True}
def test_build_request_body_default_max_tokens(deepseek_provider):
@ -114,8 +103,7 @@ def test_build_request_body_thinking_enabled(deepseek_provider):
}
)
body = deepseek_provider._build_request_body(request)
assert body["thinking"] == {"type": "enabled", "budget_tokens": 2000}
assert "extra_body" not in body
assert body["extra_body"]["thinking"] == {"type": "enabled"}
def test_build_request_body_tool_list_keeps_thinking(deepseek_provider):
@ -136,8 +124,8 @@ def test_build_request_body_tool_list_keeps_thinking(deepseek_provider):
body = deepseek_provider._build_request_body(request)
assert body["thinking"] == {"type": "enabled", "budget_tokens": 2000}
assert body["tools"][0]["name"] == "Read"
assert body["extra_body"]["thinking"] == {"type": "enabled"}
assert body["tools"][0]["function"]["name"] == "Read"
def test_build_request_body_tool_choice_keeps_thinking(deepseek_provider):
@ -152,8 +140,8 @@ def test_build_request_body_tool_choice_keeps_thinking(deepseek_provider):
body = deepseek_provider._build_request_body(request)
assert body["thinking"] == {"type": "enabled", "budget_tokens": 2000}
assert body["tool_choice"] == {"type": "auto"}
assert body["extra_body"]["thinking"] == {"type": "enabled"}
assert body["tool_choice"] == "auto"
def test_build_request_body_forced_tool_choice_downgrades_to_auto(
@ -177,15 +165,15 @@ def test_build_request_body_forced_tool_choice_downgrades_to_auto(
body = deepseek_provider._build_request_body(request)
assert body["thinking"] == {"type": "enabled", "budget_tokens": 2000}
assert body["tool_choice"] == {"type": "auto"}
assert body["extra_body"]["thinking"] == {"type": "enabled"}
assert body["tool_choice"] == "auto"
def test_build_request_body_respects_global_thinking_disable():
provider = DeepSeekProvider(
ProviderConfig(
api_key="k",
base_url=DEEPSEEK_ANTHROPIC_DEFAULT_BASE,
base_url=DEEPSEEK_DEFAULT_BASE,
rate_limit=1,
rate_window=1,
enable_thinking=False,
@ -199,7 +187,8 @@ def test_build_request_body_respects_global_thinking_disable():
}
)
body = provider._build_request_body(request)
assert "thinking" not in body
assert "extra_body" not in body
assert body["stream_options"] == {"include_usage": True}
def test_preserve_unsigned_thinking_when_thinking_on(deepseek_provider):
@ -222,10 +211,8 @@ def test_preserve_unsigned_thinking_when_thinking_on(deepseek_provider):
}
)
body = deepseek_provider._build_request_body(request)
blocks = body["messages"][0]["content"]
assert len(blocks) == 2
assert blocks[0]["type"] == "thinking"
assert blocks[0]["thinking"] == "plain"
assert body["messages"][0]["content"] == "out"
assert body["messages"][0]["reasoning_content"] == "plain"
def test_strip_redacted_thinking_when_thinking_on(deepseek_provider):
@ -244,9 +231,7 @@ def test_strip_redacted_thinking_when_thinking_on(deepseek_provider):
}
)
body = deepseek_provider._build_request_body(request)
types = {b["type"] for b in body["messages"][0]["content"]}
assert "redacted_thinking" not in types
assert "text" in types
assert body["messages"][0] == {"role": "assistant", "content": "out"}
def test_tool_history_with_replayable_thinking_preserves_thinking(deepseek_provider):
@ -292,17 +277,19 @@ def test_tool_history_with_replayable_thinking_preserves_thinking(deepseek_provi
body = deepseek_provider._build_request_body(request)
assert body["thinking"] == {"type": "enabled", "budget_tokens": 2000}
assert body["context_management"] == {
"edits": [{"type": "clear_thinking_20251015", "keep": "all"}]
assert body["extra_body"]["thinking"] == {"type": "enabled"}
assert "context_management" not in body
assert "output_config" not in body
assistant = body["messages"][0]
assert assistant["content"] == ""
assert assistant["reasoning_content"] == "hidden"
assert assistant["tool_calls"][0]["function"]["name"] == "Read"
assert assistant["tool_calls"][0]["function"]["arguments"] == '{"file_path": "x"}'
assert body["messages"][1] == {
"role": "tool",
"tool_call_id": "t1",
"content": "ok",
}
assert body["output_config"] == {"effort": "high"}
assistant_blocks = body["messages"][0]["content"]
assert [block["type"] for block in assistant_blocks] == ["thinking", "tool_use"]
assert assistant_blocks[0]["thinking"] == "hidden"
assert assistant_blocks[0]["signature"] == "sig_123"
assert assistant_blocks[1]["name"] == "Read"
assert body["messages"][1]["content"][0]["type"] == "tool_result"
def test_tool_history_with_unsigned_thinking_preserves_thinking(deepseek_provider):
@ -339,11 +326,8 @@ def test_tool_history_with_unsigned_thinking_preserves_thinking(deepseek_provide
body = deepseek_provider._build_request_body(request)
assert body["thinking"] == {"type": "enabled"}
assert body["messages"][0]["content"][0] == {
"type": "thinking",
"thinking": "plain",
}
assert body["extra_body"]["thinking"] == {"type": "enabled"}
assert body["messages"][0]["reasoning_content"] == "plain"
def test_tool_history_without_thinking_disables_thinking_and_hints(deepseek_provider):
@ -395,16 +379,13 @@ def test_tool_history_without_thinking_disables_thinking_and_hints(deepseek_prov
body = deepseek_provider._build_request_body(request)
assert "thinking" not in body
assert body["context_management"] == {
"edits": [{"type": "other_edit", "keep": "all"}],
"other": True,
}
assert body["output_config"] == {"format": "text"}
assert body["tools"][0]["name"] == "Read"
assert body["tool_choice"] == {"type": "auto"}
assert body["messages"][0]["content"][0]["type"] == "tool_use"
assert body["messages"][1]["content"][0]["type"] == "tool_result"
assert "extra_body" not in body
assert "context_management" not in body
assert "output_config" not in body
assert body["tools"][0]["function"]["name"] == "Read"
assert body["tool_choice"] == "auto"
assert body["messages"][0]["tool_calls"][0]["function"]["name"] == "Read"
assert body["messages"][1]["role"] == "tool"
def test_tool_history_with_empty_thinking_disables_thinking(deepseek_provider):
@ -441,15 +422,16 @@ def test_tool_history_with_empty_thinking_disables_thinking(deepseek_provider):
body = deepseek_provider._build_request_body(request)
assert "thinking" not in body
assert [block["type"] for block in body["messages"][0]["content"]] == ["tool_use"]
assert "extra_body" not in body
assert "reasoning_content" not in body["messages"][0]
assert body["messages"][0]["tool_calls"][0]["function"]["name"] == "Read"
def test_thinking_off_strips_thinking_history():
provider = DeepSeekProvider(
ProviderConfig(
api_key="k",
base_url=DEEPSEEK_ANTHROPIC_DEFAULT_BASE,
base_url=DEEPSEEK_DEFAULT_BASE,
rate_limit=1,
rate_window=1,
enable_thinking=False,
@ -470,8 +452,7 @@ def test_thinking_off_strips_thinking_history():
}
)
body = provider._build_request_body(request)
for b in body["messages"][0]["content"]:
assert b["type"] != "thinking"
assert "reasoning_content" not in body["messages"][0]
assert "sec" not in str(body["messages"])
@ -505,8 +486,8 @@ def test_passthrough_tool_use_and_result(deepseek_provider):
}
)
body = deepseek_provider._build_request_body(request)
assert body["messages"][0]["content"][0]["type"] == "tool_use"
assert body["messages"][1]["content"][0]["type"] == "tool_result"
assert body["messages"][0]["tool_calls"][0]["function"]["name"] == "n"
assert body["messages"][1]["role"] == "tool"
def test_preflight_strips_user_image():
@ -532,7 +513,7 @@ def test_preflight_strips_user_image():
provider = DeepSeekProvider(
ProviderConfig(
api_key="k",
base_url=DEEPSEEK_ANTHROPIC_DEFAULT_BASE,
base_url=DEEPSEEK_DEFAULT_BASE,
rate_limit=1,
rate_window=1,
)
@ -541,8 +522,8 @@ def test_preflight_strips_user_image():
provider.preflight_stream(request, thinking_enabled=True)
body = provider._build_request_body(request)
content = body["messages"][0]["content"]
block_types = [b["type"] for b in content] if isinstance(content, list) else []
assert "image" not in block_types
assert "attachment omitted" in content.lower()
assert "image or document inputs" in content.lower()
def test_preflight_rejects_mcp_servers():
@ -554,7 +535,7 @@ def test_preflight_rejects_mcp_servers():
provider = DeepSeekProvider(
ProviderConfig(
api_key="k",
base_url=DEEPSEEK_ANTHROPIC_DEFAULT_BASE,
base_url=DEEPSEEK_DEFAULT_BASE,
rate_limit=1,
rate_window=1,
)
@ -572,7 +553,7 @@ def test_preflight_rejects_listed_server_tools_in_tools_list():
provider = DeepSeekProvider(
ProviderConfig(
api_key="k",
base_url=DEEPSEEK_ANTHROPIC_DEFAULT_BASE,
base_url=DEEPSEEK_DEFAULT_BASE,
rate_limit=1,
rate_window=1,
)
@ -608,7 +589,7 @@ def test_preflight_rejects_server_tool_result_blocks():
provider = DeepSeekProvider(
ProviderConfig(
api_key="k",
base_url=DEEPSEEK_ANTHROPIC_DEFAULT_BASE,
base_url=DEEPSEEK_DEFAULT_BASE,
rate_limit=1,
rate_window=1,
)
@ -617,8 +598,7 @@ def test_preflight_rejects_server_tool_result_blocks():
provider.preflight_stream(request)
def test_strip_reasoning_content_not_forwarded(deepseek_provider):
# ``reasoning_content`` is for OpenAI helpers only, not in native body.
def test_reasoning_content_replayed_to_openai_chat(deepseek_provider):
request = MessagesRequest(
model="m",
messages=[
@ -630,39 +610,77 @@ def test_strip_reasoning_content_not_forwarded(deepseek_provider):
],
)
body = deepseek_provider._build_request_body(request)
assert "reasoning_content" not in body["messages"][0]
assert body["messages"][0]["reasoning_content"] == "r"
@pytest.mark.asyncio
async def test_stream_uses_post_messages_path(deepseek_provider):
async def test_stream_uses_chat_completions_and_maps_cache_usage(deepseek_provider):
request = MessagesRequest(
model="m",
messages=[Message(role="user", content="hi")],
)
called: dict[str, str] = {}
async def fake_send(request, *args, **kwargs):
called["path"] = request.url.path
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.is_closed = False
mock_resp.raise_for_status = lambda: None
async def fake_stream():
yield SimpleNamespace(
choices=[
SimpleNamespace(
delta=SimpleNamespace(
content="hello", reasoning_content=None, tool_calls=None
),
finish_reason=None,
)
],
usage=None,
)
yield SimpleNamespace(
choices=[
SimpleNamespace(
delta=SimpleNamespace(
content=None, reasoning_content=None, tool_calls=None
),
finish_reason="stop",
)
],
usage=None,
)
yield SimpleNamespace(
choices=[],
usage=SimpleNamespace(
completion_tokens=3,
prompt_tokens=30,
prompt_cache_hit_tokens=10,
prompt_cache_miss_tokens=20,
),
)
async def aiter():
if False: # pragma: no cover
yield ""
create = AsyncMock(return_value=fake_stream())
with patch.object(deepseek_provider._client.chat.completions, "create", create):
chunks = [
chunk
async for chunk in deepseek_provider.stream_response(
request, input_tokens=7, request_id="r1"
)
]
mock_resp.aiter_lines = aiter
mock_resp.aclose = AsyncMock()
return mock_resp
deepseek_provider._client.send = fake_send
_ = [x async for x in deepseek_provider.stream_response(request, request_id="r1")]
assert called["path"] == "/anthropic/messages"
create.assert_awaited_once()
await_args = create.await_args
assert await_args is not None
assert await_args.kwargs["model"] == "m"
assert await_args.kwargs["stream"] is True
assert await_args.kwargs["stream_options"] == {"include_usage": True}
parsed = parse_sse_text("".join(chunks))
usage = next(
event.data["usage"] for event in parsed if event.event == "message_delta"
)
assert usage == {
"input_tokens": 7,
"output_tokens": 3,
"cache_read_input_tokens": 10,
"cache_creation_input_tokens": 20,
}
def test_drops_extra_body_from_canonical_request(deepseek_provider):
def test_preserves_extra_body_for_openai_chat_request(deepseek_provider):
raw = {
"model": "m",
"max_tokens": 3,
@ -671,7 +689,7 @@ def test_drops_extra_body_from_canonical_request(deepseek_provider):
}
r = MessagesRequest.model_validate(raw)
body = deepseek_provider._build_request_body(r)
assert "extra_body" not in body
assert body["extra_body"] == {"note": 1, "thinking": {"type": "enabled"}}
def test_normalizes_tool_result_content_array_to_string(deepseek_provider):
@ -710,10 +728,8 @@ def test_normalizes_tool_result_content_array_to_string(deepseek_provider):
body = deepseek_provider._build_request_body(request)
# Verify tool_result content is now a string
user_msg = body["messages"][1]
tool_result = user_msg["content"][0]
assert tool_result["type"] == "tool_result"
tool_result = body["messages"][1]
assert tool_result["role"] == "tool"
assert isinstance(tool_result["content"], str)
assert "file1.txt" in tool_result["content"]
assert "file2.txt" in tool_result["content"]
@ -746,11 +762,11 @@ def test_strips_document_blocks_for_deepseek(deepseek_provider):
body = deepseek_provider._build_request_body(request)
# Document block should be stripped; tool_result preserved
content = body["messages"][0]["content"]
block_types = [block["type"] for block in content]
assert "document" not in block_types
assert "tool_result" in block_types
assert body["messages"][0] == {
"role": "tool",
"tool_call_id": "t1",
"content": "PDF text extracted",
}
def test_strips_image_blocks_for_deepseek(deepseek_provider):
@ -779,10 +795,7 @@ def test_strips_image_blocks_for_deepseek(deepseek_provider):
body = deepseek_provider._build_request_body(request)
content = body["messages"][0]["content"]
block_types = [block["type"] for block in content]
assert "image" not in block_types
assert "text" in block_types
assert body["messages"][0] == {"role": "user", "content": "describe this"}
def test_normalizes_tool_result_content_dict_to_string(deepseek_provider):
@ -818,10 +831,8 @@ def test_normalizes_tool_result_content_dict_to_string(deepseek_provider):
body = deepseek_provider._build_request_body(request)
# Verify tool_result content is now a JSON string
user_msg = body["messages"][1]
tool_result = user_msg["content"][0]
assert tool_result["type"] == "tool_result"
tool_result = body["messages"][1]
assert tool_result["role"] == "tool"
assert isinstance(tool_result["content"], str)
assert "status" in tool_result["content"]
assert "success" in tool_result["content"]
@ -870,8 +881,8 @@ def test_strips_image_block_inside_tool_result(deepseek_provider):
body = deepseek_provider._build_request_body(request)
tool_result = body["messages"][1]["content"][0]
assert tool_result["type"] == "tool_result"
tool_result = body["messages"][1]
assert tool_result["role"] == "tool"
# After stripping + string-normalization, no base64/image marker survives.
assert isinstance(tool_result["content"], str)
assert "screenshot saved" in tool_result["content"]
@ -921,8 +932,8 @@ def test_image_only_tool_result_replaced_with_placeholder(deepseek_provider):
body = deepseek_provider._build_request_body(request)
tool_result = body["messages"][1]["content"][0]
assert tool_result["type"] == "tool_result"
tool_result = body["messages"][1]
assert tool_result["role"] == "tool"
assert isinstance(tool_result["content"], str)
assert tool_result["content"] != ""
assert "attachment omitted" in tool_result["content"].lower()
@ -972,8 +983,8 @@ def test_document_only_tool_result_replaced_with_generic_placeholder(
body = deepseek_provider._build_request_body(request)
tool_result = body["messages"][1]["content"][0]
assert tool_result["type"] == "tool_result"
tool_result = body["messages"][1]
assert tool_result["role"] == "tool"
assert isinstance(tool_result["content"], str)
assert "attachment omitted" in tool_result["content"].lower()
assert "document inputs" in tool_result["content"].lower()
@ -1006,10 +1017,8 @@ def test_image_only_message_replaced_with_placeholder(deepseek_provider):
body = deepseek_provider._build_request_body(request)
content = body["messages"][0]["content"]
assert len(content) == 1
assert content[0]["type"] == "text"
assert "attachment omitted" in content[0]["text"].lower()
assert "image or document inputs" in content[0]["text"].lower()
assert "attachment omitted" in content.lower()
assert "image or document inputs" in content.lower()
def test_document_only_message_replaced_with_placeholder(deepseek_provider):
@ -1034,10 +1043,8 @@ def test_document_only_message_replaced_with_placeholder(deepseek_provider):
body = deepseek_provider._build_request_body(request)
content = body["messages"][0]["content"]
assert len(content) == 1
assert content[0]["type"] == "text"
assert "attachment omitted" in content[0]["text"].lower()
assert "document inputs" in content[0]["text"].lower()
assert "attachment omitted" in content.lower()
assert "document inputs" in content.lower()
def test_warns_when_stripping_attachment_blocks(deepseek_provider, caplog):

View file

@ -94,17 +94,14 @@ async def test_native_openai_compatible_provider_lists_model_ids() -> None:
async def test_deepseek_lists_models_from_root_endpoint() -> None:
provider = DeepSeekProvider(ProviderConfig(api_key="deepseek-key"))
with patch.object(
provider._client,
"get",
provider._client.models,
"list",
new_callable=AsyncMock,
return_value=_response(200, {"data": [{"id": "deepseek-chat"}]}),
) as mock_get:
return_value=SimpleNamespace(data=[SimpleNamespace(id="deepseek-chat")]),
) as mock_list:
assert await provider.list_model_ids() == frozenset({"deepseek-chat"})
mock_get.assert_awaited_once_with(
"https://api.deepseek.com/models",
headers={"Authorization": "Bearer deepseek-key"},
)
mock_list.assert_awaited_once_with()
@pytest.mark.asyncio

2
uv.lock generated
View file

@ -561,7 +561,7 @@ wheels = [
[[package]]
name = "free-claude-code"
version = "2.4.0"
version = "2.4.1"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },