mirror of
https://github.com/Alishahryar1/free-claude-code.git
synced 2026-07-09 16:00:45 +00:00
Fix DeepSeek cache usage accounting (#937)
This commit is contained in:
parent
478e96655c
commit
bdefb46d16
14 changed files with 272 additions and 235 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
2
uv.lock
generated
|
|
@ -561,7 +561,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "free-claude-code"
|
||||
version = "2.4.0"
|
||||
version = "2.4.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue