From bdefb46d1623d5f4a8f5388051bc5a57e1bd9d21 Mon Sep 17 00:00:00 2001 From: Ali Khokhar Date: Sun, 28 Jun 2026 22:08:51 -0700 Subject: [PATCH] Fix DeepSeek cache usage accounting (#937) --- ARCHITECTURE.md | 5 +- README.md | 6 +- config/provider_catalog.py | 12 +- core/anthropic/streaming/ledger.py | 21 +- providers/deepseek/__init__.py | 3 +- providers/deepseek/client.py | 53 ++-- providers/deepseek/compat.py | 104 ++++--- providers/defaults.py | 2 - providers/transports/openai_chat/stream.py | 1 + providers/transports/openai_chat/transport.py | 4 + pyproject.toml | 2 +- tests/providers/test_deepseek.py | 279 +++++++++--------- tests/providers/test_model_validation.py | 13 +- uv.lock | 2 +- 14 files changed, 272 insertions(+), 235 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d0836e38..c3f821ac 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/README.md b/README.md index 9d74b84b..45020f85 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/config/provider_catalog.py b/config/provider_catalog.py index 5808c050..8bb9c9a8 100644 --- a/config/provider_catalog.py +++ b/config/provider_catalog.py @@ -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", diff --git a/core/anthropic/streaming/ledger.py b/core/anthropic/streaming/ledger.py index 886487b9..8415dee8 100644 --- a/core/anthropic/streaming/ledger.py +++ b/core/anthropic/streaming/ledger.py @@ -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, }, ) diff --git a/providers/deepseek/__init__.py b/providers/deepseek/__init__.py index 97b814aa..5a337c12 100644 --- a/providers/deepseek/__init__.py +++ b/providers/deepseek/__init__.py @@ -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", ] diff --git a/providers/deepseek/client.py b/providers/deepseek/client.py index 9bdf24b2..b0e43a80 100644 --- a/providers/deepseek/client.py +++ b/providers/deepseek/client.py @@ -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 diff --git a/providers/deepseek/compat.py b/providers/deepseek/compat.py index 509aec7a..9d02cd28 100644 --- a/providers/deepseek/compat.py +++ b/providers/deepseek/compat.py @@ -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"}) diff --git a/providers/defaults.py b/providers/defaults.py index 2d2af230..786be128 100644 --- a/providers/defaults.py +++ b/providers/defaults.py @@ -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", diff --git a/providers/transports/openai_chat/stream.py b/providers/transports/openai_chat/stream.py index cd9ca49b..fa7a33a8 100644 --- a/providers/transports/openai_chat/stream.py +++ b/providers/transports/openai_chat/stream.py @@ -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 diff --git a/providers/transports/openai_chat/transport.py b/providers/transports/openai_chat/transport.py index e7cba810..3b474db4 100644 --- a/providers/transports/openai_chat/transport.py +++ b/providers/transports/openai_chat/transport.py @@ -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: diff --git a/pyproject.toml b/pyproject.toml index ae1c1094..0c5ed05f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/providers/test_deepseek.py b/tests/providers/test_deepseek.py index 43ee4a7b..b64d7922 100644 --- a/tests/providers/test_deepseek.py +++ b/tests/providers/test_deepseek.py @@ -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): diff --git a/tests/providers/test_model_validation.py b/tests/providers/test_model_validation.py index 6d039bed..36f2748c 100644 --- a/tests/providers/test_model_validation.py +++ b/tests/providers/test_model_validation.py @@ -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 diff --git a/uv.lock b/uv.lock index 834b4f05..2d62fd03 100644 --- a/uv.lock +++ b/uv.lock @@ -561,7 +561,7 @@ wheels = [ [[package]] name = "free-claude-code" -version = "2.4.0" +version = "2.4.1" source = { editable = "." } dependencies = [ { name = "aiohttp" },