mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
Merge pull request #793 from Routstr/feat/deepseek-provider
feat(upstream): add native DeepSeek provider, retire V4 pricing shim
This commit is contained in:
@@ -48,6 +48,29 @@ Connect to your AI provider(s):
|
||||
| **Upstream URL** | API endpoint (e.g., `https://api.openai.com/v1`) |
|
||||
| **API Key** | Your provider's API key |
|
||||
|
||||
### DeepSeek
|
||||
|
||||
Choose **DeepSeek** as the provider type and paste an API key from
|
||||
[platform.deepseek.com](https://platform.deepseek.com/api_keys); the base URL
|
||||
is fixed to `https://api.deepseek.com`. Setting `DEEPSEEK_API_KEY` seeds the
|
||||
provider on startup instead.
|
||||
|
||||
Models are listed from DeepSeek's own `/models` and priced from a rate table
|
||||
in `routstr/upstream/deepseek.py`, not from litellm or OpenRouter:
|
||||
|
||||
- **Peak rates only.** DeepSeek charges half price off-peak, but the node bills
|
||||
one flat price per model, so it bills the peak rate. Clients overpay
|
||||
off-peak; the node never bills below cost. Time-of-day pricing is planned.
|
||||
- **Unknown models import disabled.** A model DeepSeek lists that the table
|
||||
does not price shows up disabled in the Admin Dashboard. Enable it with a
|
||||
manual price, or add it to the table.
|
||||
- **Cache hits** bill at DeepSeek's cache-hit rate (about 2% of the input
|
||||
rate on flash, about 3% on pro).
|
||||
|
||||
Thinking-mode `reasoning_content` is returned to clients unchanged in
|
||||
responses, and forwarded unchanged when it appears in conversation history.
|
||||
DeepSeek requires it on requests that carry `tools` and ignores it otherwise.
|
||||
|
||||
### PPQ Auto Top-up
|
||||
|
||||
PPQ providers can automatically purchase more credits when their USD balance
|
||||
|
||||
@@ -22,6 +22,7 @@ dependencies = [
|
||||
"pillow>=10",
|
||||
"openai>=1.98.0",
|
||||
"litellm>=1.101.2,<1.102",
|
||||
"backoff>=2.2", # litellm's native Anthropic-messages streaming (e.g. deepseek/) imports litellm.proxy, which needs it
|
||||
"orjson>=3.10",
|
||||
]
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ from ..payment.price import update_prices_periodically
|
||||
from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically
|
||||
from ..refund import periodic_refund_reconcile
|
||||
from ..upstream.auto_topup import periodic_auto_topup
|
||||
from ..upstream.deepseek_v4_pricing_shim import register_deepseek_v4_pricing
|
||||
from ..upstream.http_client import close_upstream_http_client
|
||||
from ..upstream.litellm_routing import configure_litellm
|
||||
from ..wallet import periodic_payout, periodic_refund_sweep, periodic_routstr_fee_payout
|
||||
@@ -90,11 +89,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
# debug logging) before any upstream provider dispatches a request.
|
||||
configure_litellm()
|
||||
|
||||
# TEMPORARY: backfill DeepSeek V4 pricing missing from litellm's cost
|
||||
# map (BerriAI/litellm#30430). Remove this call and
|
||||
# deepseek_v4_pricing_shim.py once litellm ships these models.
|
||||
register_deepseek_v4_pricing()
|
||||
|
||||
# Run database migrations on startup
|
||||
run_migrations()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from .anthropic import AnthropicUpstreamProvider
|
||||
from .azure import AzureUpstreamProvider
|
||||
from .base import BaseUpstreamProvider
|
||||
from .deepseek import DeepSeekUpstreamProvider
|
||||
from .fireworks import FireworksUpstreamProvider
|
||||
from .gemini import GeminiUpstreamProvider
|
||||
from .generic import GenericUpstreamProvider
|
||||
@@ -19,6 +20,7 @@ from .xai import XAIUpstreamProvider
|
||||
upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
|
||||
AnthropicUpstreamProvider,
|
||||
AzureUpstreamProvider,
|
||||
DeepSeekUpstreamProvider,
|
||||
FireworksUpstreamProvider,
|
||||
GeminiUpstreamProvider,
|
||||
GenericUpstreamProvider,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""First-class upstream for the DeepSeek API.
|
||||
|
||||
Pricing comes from ``_PEAK_RATES`` below, not from litellm or OpenRouter:
|
||||
litellm's bundled ``deepseek-v4-flash`` entry is stale (input, output and cache
|
||||
rates alike), the OpenRouter feed carries resale prices below DeepSeek's own
|
||||
peak rate, and neither the bundled map nor OpenRouter knows the current
|
||||
``deepseek-flash`` id. A model DeepSeek lists that the table does not
|
||||
cover is imported disabled rather than priced from those sources.
|
||||
|
||||
DeepSeek bills peak hours at twice the off-peak rate. The node has one flat
|
||||
price per model, so the table holds the PEAK rates: a client may overpay
|
||||
off-peak but the node never bills below its own cost.
|
||||
|
||||
Rates: https://api-docs.deepseek.com/quick_start/pricing (checked 2026-09-30).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import BaseUpstreamProvider
|
||||
from .generic import GenericUpstreamProvider
|
||||
from .pricing_resolver import ResolvedPricing
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
_CONTEXT_LENGTH = 1_000_000
|
||||
_MAX_OUTPUT_TOKENS = 384_000
|
||||
|
||||
# USD per 1M tokens at DeepSeek's peak rate: (input cache miss, output, input
|
||||
# cache hit). DeepSeek has no cache-write charge.
|
||||
_FLASH = (0.30, 1.20, 0.006)
|
||||
_PRO = (1.32, 3.96, 0.044)
|
||||
|
||||
_PEAK_RATES: dict[str, tuple[float, float, float]] = {
|
||||
"deepseek-flash": _FLASH,
|
||||
# Retired ids DeepSeek still accepts, served and billed as deepseek-flash.
|
||||
"deepseek-v4-flash": _FLASH,
|
||||
"deepseek-v4-flash-vision-exp": _FLASH,
|
||||
"deepseek-v4-pro": _PRO,
|
||||
}
|
||||
|
||||
# Pro is the only current model without vision support.
|
||||
_TEXT_ONLY = {"deepseek-v4-pro"}
|
||||
|
||||
|
||||
class DeepSeekUpstreamProvider(GenericUpstreamProvider):
|
||||
"""Upstream provider specifically configured for the DeepSeek API."""
|
||||
|
||||
provider_type = "deepseek"
|
||||
default_base_url = "https://api.deepseek.com"
|
||||
platform_url = "https://platform.deepseek.com/api_keys"
|
||||
litellm_provider_prefix = "deepseek/"
|
||||
use_fallback_pricing = False
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.01):
|
||||
super().__init__(
|
||||
base_url=self.default_base_url,
|
||||
api_key=api_key,
|
||||
provider_fee=provider_fee,
|
||||
upstream_name="DeepSeek",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _build_from_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "DeepSeekUpstreamProvider":
|
||||
return cls(api_key=provider_row.api_key, provider_fee=provider_row.provider_fee)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "DeepSeek",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def _apply_provider_field(self, response_json: object) -> None:
|
||||
# A first-party upstream: stamp "deepseek", not Generic's hostname.
|
||||
BaseUpstreamProvider._apply_provider_field(self, response_json)
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
"""Strip the 'deepseek/' prefix for DeepSeek API compatibility."""
|
||||
return model_id.removeprefix("deepseek/")
|
||||
|
||||
def _native_pricing(
|
||||
self, model_id: str, model_spec: dict
|
||||
) -> ResolvedPricing | None:
|
||||
"""Price ``model_id`` from the peak-rate table; ``None`` if absent."""
|
||||
rates = _PEAK_RATES.get(model_id)
|
||||
if rates is None:
|
||||
return None
|
||||
input_usd, output_usd, cache_hit_usd = rates
|
||||
input_modalities = ["text"] if model_id in _TEXT_ONLY else ["text", "image"]
|
||||
return ResolvedPricing(
|
||||
prompt=input_usd / 1_000_000,
|
||||
completion=output_usd / 1_000_000,
|
||||
context_length=_CONTEXT_LENGTH,
|
||||
source="native",
|
||||
max_completion_tokens=_MAX_OUTPUT_TOKENS,
|
||||
input_cache_read=cache_hit_usd / 1_000_000,
|
||||
input_modalities=input_modalities,
|
||||
)
|
||||
@@ -1,73 +0,0 @@
|
||||
"""TEMPORARY: local DeepSeek V4 pricing shim.
|
||||
|
||||
litellm's bundled cost map does not yet ship ``deepseek-v4-flash`` /
|
||||
``deepseek-v4-pro``. Without an entry, ``backfill_cache_pricing`` cannot find a
|
||||
``cache_read_input_token_cost`` and cache reads fall back to the full input
|
||||
rate — a large overcharge on cache hits (DeepSeek V4 hits are ~0.008-0.02x
|
||||
input, i.e. cached tokens cost 50-120x less than regular input).
|
||||
|
||||
This module injects the missing entries into ``litellm.model_cost`` at startup
|
||||
so the existing backfill path resolves them. Rates mirror the canonical
|
||||
``deepseek`` provider entries now in litellm's ``model_prices`` map
|
||||
(``input_cost_per_token`` is the cache-*miss* rate;
|
||||
``cache_read_input_token_cost`` is the cache-*hit* rate), sourced from
|
||||
https://api-docs.deepseek.com/quick_start/pricing via
|
||||
https://github.com/BerriAI/litellm/pull/26380 (issue
|
||||
https://github.com/BerriAI/litellm/issues/30430).
|
||||
|
||||
=== REMOVAL (once litellm ships these models) ===
|
||||
Delete this file and the single ``register_deepseek_v4_pricing()`` call in
|
||||
``routstr/core/main.py``. Nothing else depends on it. Entries are only added
|
||||
when absent, so a stale shim is harmless after upstream lands — but remove it.
|
||||
"""
|
||||
|
||||
import litellm
|
||||
|
||||
from ..core import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# USD per token. Mirrors the canonical ``deepseek`` provider entries in
|
||||
# litellm's model_prices map (source: DeepSeek API pricing docs). Keep these in
|
||||
# sync with ``litellm.model_cost["deepseek/deepseek-v4-*"]``.
|
||||
_DEEPSEEK_V4_RATES: dict[str, dict[str, float]] = {
|
||||
"deepseek-v4-flash": {
|
||||
"input_cost_per_token": 1.4e-07,
|
||||
"output_cost_per_token": 2.8e-07,
|
||||
"cache_read_input_token_cost": 2.8e-09,
|
||||
"cache_creation_input_token_cost": 0.0,
|
||||
"input_cost_per_token_cache_hit": 2.8e-09,
|
||||
},
|
||||
"deepseek-v4-pro": {
|
||||
"input_cost_per_token": 4.35e-07,
|
||||
"output_cost_per_token": 8.7e-07,
|
||||
"cache_read_input_token_cost": 3.625e-09,
|
||||
"cache_creation_input_token_cost": 0.0,
|
||||
"input_cost_per_token_cache_hit": 3.625e-09,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def register_deepseek_v4_pricing() -> None:
|
||||
"""Inject DeepSeek V4 pricing into ``litellm.model_cost`` if absent.
|
||||
|
||||
Idempotent and non-destructive: a key already present in the cost map
|
||||
(e.g. once litellm ships it) is left untouched. Registers both the bare
|
||||
(``deepseek-v4-flash``) and prefixed (``deepseek/deepseek-v4-flash``)
|
||||
spellings since ``backfill_cache_pricing`` tries both.
|
||||
"""
|
||||
added = []
|
||||
for bare, rates in _DEEPSEEK_V4_RATES.items():
|
||||
for key in (bare, f"deepseek/{bare}"):
|
||||
if key in litellm.model_cost:
|
||||
continue
|
||||
entry: dict[str, object] = dict(rates)
|
||||
entry["litellm_provider"] = "deepseek"
|
||||
entry["mode"] = "chat"
|
||||
litellm.model_cost[key] = entry
|
||||
added.append(key)
|
||||
if added:
|
||||
logger.info(
|
||||
"Registered temporary DeepSeek V4 pricing shim",
|
||||
extra={"models": added},
|
||||
)
|
||||
@@ -28,7 +28,11 @@ class GenericUpstreamProvider(BaseUpstreamProvider):
|
||||
|
||||
provider_type = "generic"
|
||||
default_base_url = "http://localhost:8888"
|
||||
platform_url = None
|
||||
platform_url: str | None = None
|
||||
# Subclasses that own an authoritative price table set this False so a model
|
||||
# the table misses imports disabled instead of taking a litellm/OpenRouter
|
||||
# price that may undercut the upstream's own rate.
|
||||
use_fallback_pricing = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -162,7 +166,7 @@ class GenericUpstreamProvider(BaseUpstreamProvider):
|
||||
model_spec = model_data.get("model_spec", {})
|
||||
|
||||
resolved = self._native_pricing(model_id, model_spec)
|
||||
if resolved is None:
|
||||
if resolved is None and self.use_fallback_pricing:
|
||||
resolved = await resolver.resolve(model_id)
|
||||
|
||||
if resolved is None:
|
||||
|
||||
@@ -272,6 +272,7 @@ async def _seed_providers_from_settings(
|
||||
("PERPLEXITY_API_KEY", "perplexity", None, None),
|
||||
("FIREWORKS_API_KEY", "fireworks", None, None),
|
||||
("XAI_API_KEY", "xai", None, None),
|
||||
("DEEPSEEK_API_KEY", "deepseek", None, None),
|
||||
("TINFOIL_API_KEY", "tinfoil", None, None),
|
||||
("TYPESAFE_API_KEY", "typesafe", None, None),
|
||||
]
|
||||
|
||||
@@ -470,7 +470,6 @@ async def test_startup_runs_bootstrap_before_settings_initialize(
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(main, "configure_litellm", lambda: None)
|
||||
monkeypatch.setattr(main, "register_deepseek_v4_pricing", lambda: None)
|
||||
monkeypatch.setattr(main, "run_migrations", lambda: None)
|
||||
monkeypatch.setattr(main, "init_db", noop_init_db)
|
||||
monkeypatch.setattr(main, "create_session", fake_create_session)
|
||||
|
||||
@@ -31,15 +31,6 @@ from routstr.payment.models import (
|
||||
backfill_cache_pricing,
|
||||
)
|
||||
from routstr.upstream import GenericUpstreamProvider
|
||||
from routstr.upstream.deepseek_v4_pricing_shim import register_deepseek_v4_pricing
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _deepseek_v4_pricing() -> None:
|
||||
# litellm's bundled cost map lacks the DeepSeek V4 entries (they only
|
||||
# appear when its remote map is reachable); production injects them at
|
||||
# startup via this same shim.
|
||||
register_deepseek_v4_pricing()
|
||||
|
||||
|
||||
def _make_model(model_id: str, pricing: Pricing) -> Model:
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"""Unit tests for ``DeepSeekUpstreamProvider``.
|
||||
|
||||
DeepSeek is priced from the provider's own peak-rate table, never from litellm
|
||||
or OpenRouter: litellm's ``deepseek-v4-flash`` entry is stale and OpenRouter
|
||||
resells below DeepSeek's peak rate, so either would bill under cost. These
|
||||
tests pin the table prices (including the cache-hit rate), that a model the
|
||||
table misses imports disabled without consulting the fallback chain, and that
|
||||
``reasoning_content`` in history reaches DeepSeek untouched — thinking mode
|
||||
with ``tools`` answers 400 when it is stripped.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from collections.abc import Iterator
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import litellm
|
||||
import pytest
|
||||
|
||||
from routstr.upstream import upstream_provider_classes
|
||||
from routstr.upstream.deepseek import DeepSeekUpstreamProvider
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload: dict[str, Any]) -> None:
|
||||
self._payload = payload
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def json(self) -> dict[str, Any]:
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
def __init__(self, payload: dict[str, Any], calls: list[dict[str, Any]]) -> None:
|
||||
self._payload = payload
|
||||
self._calls = calls
|
||||
|
||||
async def __aenter__(self) -> "_FakeAsyncClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: object) -> bool:
|
||||
return False
|
||||
|
||||
async def get(
|
||||
self, url: str, headers: dict[str, str] | None = None
|
||||
) -> _FakeResponse:
|
||||
self._calls.append({"url": url, "headers": headers})
|
||||
return _FakeResponse(self._payload)
|
||||
|
||||
|
||||
# Shape of DeepSeek's ``GET /models``: bare ids, no pricing.
|
||||
CATALOG: dict[str, Any] = {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"id": "deepseek-flash", "object": "model", "owned_by": "deepseek"},
|
||||
{"id": "deepseek-v4-pro", "object": "model", "owned_by": "deepseek"},
|
||||
{"id": "deepseek-v4-flash", "object": "model", "owned_by": "deepseek"},
|
||||
{"id": "deepseek-chat", "object": "model", "owned_by": "deepseek"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def _fetch(
|
||||
catalog: dict[str, Any] = CATALOG,
|
||||
) -> tuple[dict[str, Any], list[dict[str, Any]], AsyncMock]:
|
||||
calls: list[dict[str, Any]] = []
|
||||
fallback = AsyncMock(return_value=None)
|
||||
provider = DeepSeekUpstreamProvider(api_key="sk-test")
|
||||
with (
|
||||
patch(
|
||||
"routstr.upstream.generic.httpx.AsyncClient",
|
||||
lambda *args, **kwargs: _FakeAsyncClient(catalog, calls),
|
||||
),
|
||||
patch("routstr.upstream.generic.FallbackPricingResolver.resolve", fallback),
|
||||
):
|
||||
models = await provider.fetch_models()
|
||||
return {m.id: m for m in models}, calls, fallback
|
||||
|
||||
|
||||
def test_metadata_and_registration() -> None:
|
||||
assert DeepSeekUpstreamProvider in upstream_provider_classes
|
||||
assert DeepSeekUpstreamProvider.get_provider_metadata() == {
|
||||
"id": "deepseek",
|
||||
"name": "DeepSeek",
|
||||
"default_base_url": "https://api.deepseek.com",
|
||||
"fixed_base_url": True,
|
||||
"platform_url": "https://platform.deepseek.com/api_keys",
|
||||
}
|
||||
|
||||
|
||||
def test_build_from_row_ignores_row_base_url() -> None:
|
||||
row = Mock(
|
||||
api_key="sk-row", provider_fee=1.05, base_url="https://elsewhere.example"
|
||||
)
|
||||
provider = DeepSeekUpstreamProvider._build_from_row(row)
|
||||
assert provider.api_key == "sk-row"
|
||||
assert provider.provider_fee == 1.05
|
||||
assert provider.base_url == "https://api.deepseek.com"
|
||||
|
||||
|
||||
def test_litellm_prefix_is_deepseek() -> None:
|
||||
provider = DeepSeekUpstreamProvider(api_key="sk-test")
|
||||
assert provider.get_litellm_provider_prefix() == "deepseek/"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model_id,expected",
|
||||
[
|
||||
("deepseek/deepseek-v4-flash", "deepseek-v4-flash"),
|
||||
("deepseek-v4-flash", "deepseek-v4-flash"),
|
||||
("deepseek/deepseek-flash", "deepseek-flash"),
|
||||
],
|
||||
)
|
||||
def test_transform_model_name(model_id: str, expected: str) -> None:
|
||||
provider = DeepSeekUpstreamProvider(api_key="sk-test")
|
||||
assert provider.transform_model_name(model_id) == expected
|
||||
|
||||
|
||||
def test_provider_field_names_deepseek_not_host() -> None:
|
||||
provider = DeepSeekUpstreamProvider(api_key="sk-test")
|
||||
payload: dict[str, Any] = {"id": "chatcmpl-1"}
|
||||
provider._apply_provider_field(payload)
|
||||
assert payload["provider"] == "deepseek"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_models_calls_deepseek_models_endpoint_with_key() -> None:
|
||||
_, calls, _ = await _fetch()
|
||||
assert calls == [
|
||||
{
|
||||
"url": "https://api.deepseek.com/models",
|
||||
"headers": {"Authorization": "Bearer sk-test"},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"model_id,prompt,completion,cache_read",
|
||||
[
|
||||
("deepseek-flash", 0.30, 1.20, 0.006),
|
||||
# Retired alias DeepSeek serves and bills as deepseek-flash.
|
||||
("deepseek-v4-flash", 0.30, 1.20, 0.006),
|
||||
("deepseek-v4-pro", 1.32, 3.96, 0.044),
|
||||
],
|
||||
)
|
||||
async def test_table_models_priced_at_peak_rate(
|
||||
model_id: str, prompt: float, completion: float, cache_read: float
|
||||
) -> None:
|
||||
models, _, _ = await _fetch()
|
||||
model = models[model_id]
|
||||
assert model.enabled is True
|
||||
assert model.pricing.prompt == pytest.approx(prompt / 1_000_000)
|
||||
assert model.pricing.completion == pytest.approx(completion / 1_000_000)
|
||||
assert model.pricing.input_cache_read == pytest.approx(cache_read / 1_000_000)
|
||||
assert model.context_length == 1_000_000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vision_follows_the_model() -> None:
|
||||
models, _, _ = await _fetch()
|
||||
assert "image" in models["deepseek-flash"].architecture.input_modalities
|
||||
assert models["deepseek-v4-pro"].architecture.input_modalities == ["text"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unlisted_model_imports_disabled_without_fallback() -> None:
|
||||
"""litellm prices ``deepseek-chat``; the provider must not take that price."""
|
||||
models, _, fallback = await _fetch()
|
||||
model = models["deepseek-chat"]
|
||||
assert model.enabled is False
|
||||
assert model.pricing.prompt == 0.0
|
||||
assert model.pricing.completion == 0.0
|
||||
fallback.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_rate_survives_fee_and_is_not_replaced_by_litellm() -> None:
|
||||
"""litellm's stale ``deepseek-v4-flash`` cache rate (1.4e-08 in the bundled
|
||||
map) must not replace the table's; backfill only fills an absent rate. The
|
||||
fee applies to the cache rate like every other component.
|
||||
|
||||
The litellm entry is pinned here because the remote cost map already
|
||||
carries the table's rate, which would let an overwrite go unnoticed."""
|
||||
models, _, _ = await _fetch()
|
||||
provider = DeepSeekUpstreamProvider(api_key="sk-test", provider_fee=1.05)
|
||||
stale = {"cache_read_input_token_cost": 1.4e-08}
|
||||
with patch("routstr.payment.models.litellm_cost_entry", return_value=stale):
|
||||
priced = provider._apply_provider_fee_to_model(models["deepseek-v4-flash"])
|
||||
assert priced.pricing.input_cache_read == pytest.approx(0.006e-6 * 1.05)
|
||||
assert priced.pricing.prompt == pytest.approx(0.30e-6 * 1.05)
|
||||
# A cache hit costs 2% of a miss, not the full input rate.
|
||||
assert priced.pricing.input_cache_read / priced.pricing.prompt == pytest.approx(
|
||||
0.02
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reasoning_content_in_history_reaches_upstream() -> None:
|
||||
models, _, _ = await _fetch()
|
||||
provider = DeepSeekUpstreamProvider(api_key="sk-test")
|
||||
messages = [
|
||||
{"role": "user", "content": "weather in Paris?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"reasoning_content": "Need the weather tool.",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "18C"},
|
||||
]
|
||||
body = json.dumps(
|
||||
{
|
||||
"model": "deepseek/deepseek-flash",
|
||||
"messages": messages,
|
||||
"tools": [{"type": "function", "function": {"name": "get_weather"}}],
|
||||
}
|
||||
).encode()
|
||||
out = provider.prepare_request_body(body, models["deepseek-flash"])
|
||||
|
||||
assert out is not None
|
||||
sent = json.loads(out)
|
||||
assert sent["model"] == "deepseek-flash"
|
||||
assert sent["messages"] == messages
|
||||
|
||||
|
||||
_ANTHROPIC_SSE = (
|
||||
b"event: message_start\n"
|
||||
b'data: {"type":"message_start","message":{"id":"msg_1","type":"message",'
|
||||
b'"role":"assistant","model":"deepseek-flash","content":[],'
|
||||
b'"stop_reason":null,"usage":{"input_tokens":3,"output_tokens":0}}}\n\n'
|
||||
b"event: message_stop\n"
|
||||
b'data: {"type":"message_stop"}\n\n'
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anthropic_stub() -> Iterator[tuple[str, list[tuple[str, dict[str, Any]]]]]:
|
||||
"""Loopback stand-in for DeepSeek's Anthropic-format endpoint."""
|
||||
seen: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self) -> None:
|
||||
length = int(self.headers["Content-Length"])
|
||||
seen.append((self.path, json.loads(self.rfile.read(length))))
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Content-Length", str(len(_ANTHROPIC_SSE)))
|
||||
self.end_headers()
|
||||
self.wfile.write(_ANTHROPIC_SSE)
|
||||
|
||||
def log_message(self, *args: Any) -> None:
|
||||
return None
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{server.server_address[1]}", seen
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_messages_stream_reaches_deepseek_anthropic_endpoint(
|
||||
anthropic_stub: tuple[str, list[tuple[str, dict[str, Any]]]],
|
||||
) -> None:
|
||||
# litellm sends deepseek/ Messages calls to DeepSeek's /anthropic endpoint;
|
||||
# its stream iterator imports litellm.proxy, which needs ``backoff``.
|
||||
api_base, seen = anthropic_stub
|
||||
stream = await litellm.anthropic.messages.acreate(
|
||||
model=DeepSeekUpstreamProvider.litellm_provider_prefix + "deepseek-flash",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
max_tokens=8,
|
||||
stream=True,
|
||||
api_key="sk-test",
|
||||
api_base=api_base,
|
||||
)
|
||||
chunks = [chunk async for chunk in stream] # type: ignore[union-attr]
|
||||
|
||||
assert b"message_stop" in b"".join(chunks)
|
||||
assert len(seen) == 1
|
||||
assert seen[0][0] == "/anthropic/v1/messages"
|
||||
assert seen[0][1]["model"] == "deepseek-flash"
|
||||
@@ -282,6 +282,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backoff"
|
||||
version = "2.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base58"
|
||||
version = "2.1.1"
|
||||
@@ -2711,6 +2720,7 @@ source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
{ name = "alembic" },
|
||||
{ name = "backoff" },
|
||||
{ name = "cashu" },
|
||||
{ name = "fastapi", extra = ["standard-no-fastapi-cloud-cli"] },
|
||||
{ name = "greenlet" },
|
||||
@@ -2747,6 +2757,7 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "aiosqlite", specifier = ">=0.20" },
|
||||
{ name = "alembic", specifier = ">=1.13" },
|
||||
{ name = "backoff", specifier = ">=2.2" },
|
||||
{ name = "cashu", specifier = ">=0.20" },
|
||||
{ name = "fastapi", extras = ["standard-no-fastapi-cloud-cli"], specifier = ">=0.141" },
|
||||
{ name = "greenlet", specifier = ">=3.2.1" },
|
||||
|
||||
Reference in New Issue
Block a user