diff --git a/CHANGELOG.md b/CHANGELOG.md index ff671fa2d..51f6f5268 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **DeepSeek-V4.1-Flash (`deepseek-flash`), and the DeepSeek line collapses + onto it.** 1M context, 384K max output, thinking on by default — and the + first DeepSeek model that accepts an image, folding in the retired + `deepseek-v4-flash-vision-exp`, so a screenshot no longer needs a + fusion model on this provider. It is now the `deepseek` + provider's default and its whole subagent tier table (opus/sonnet/haiku), + because DeepSeek says V4.1 Flash "has comprehensively surpassed V4 Pro in + performance, cost, speed, and total time" and is retiring Pro onto it. + `deepseek-v4-pro`, `deepseek-v4-flash`, `deepseek-chat` and + `deepseek-reasoner` all still resolve, so a pinned session keeps working. +- **`/cost` follows DeepSeek's re-card and its V4 Pro retirement.** V4.1 Flash + is cheaper than the V4 flash line it replaces — **$0.15 / $0.60 per MTok + off-peak and $0.003 cache-hit**, against $0.22 / $0.66 / $0.007 — and the + retired flash ids are billed at that same card, since DeepSeek serves them + from V4.1 Flash. `deepseek-v4-pro` keeps its own card until 2026-09-14 + 12:00 Beijing (04:00 UTC) and prices as Flash from that instant, on top of + the existing peak/off-peak schedule. Both axes read the request's + timestamp, so re-opening an August session still shows what it actually + cost rather than restating it 4.4× low. + - **Web: the reference's session stats strip.** One centred line under the composer — `2 turns · 106 steps | LLM 6m28s · Tool call 23.7s | TTFT avg 1.3s · 258 tok/s | Cache hit 99% | Input 11.5M tok · Output 65.9K tok` — diff --git a/README.md b/README.md index a7d8a2a4b..37bd65ab5 100644 --- a/README.md +++ b/README.md @@ -64,14 +64,14 @@ prefix nearly free, `/eco` shrinks the fresh suffix every turn actually pays for # Run long agentic coding sessions for *pennies* -### Cache-hit input bills at **`$0.022` / 1M tokens** — about **450× cheaper** than Claude Fable 5 (`$10` / 1M). +### Cache-hit input bills at **`$0.003` / 1M tokens** — about **3,300× cheaper** than Claude Fable 5 (`$10` / 1M). ClawCodex keeps your request prefix **byte-stable**, so DeepSeek's prompt cache covers your whole `system + tools + history` span across turns. **The longer you code, the more you save.** -`deepseek-v4-pro` off-peak, checked 2026-08-25. DeepSeek doubles every rate during peak hours -(01:00–04:00 and 06:00–10:00 UTC, Mon–Fri) — still ~227× cheaper there. `/cost` follows the -schedule. +`deepseek-flash` (DeepSeek-V4.1-Flash) off-peak, checked 2026-09-10. DeepSeek doubles every +rate during peak hours (01:00–04:00 and 06:00–10:00 UTC, Mon–Fri) — still ~1,600× cheaper there. +`/cost` follows the schedule. @@ -151,7 +151,7 @@ The configuration file is saved at `~/.clawcodex/config.json`. Minimal example: "deepseek": { "api_key": "xxx-xxx", "base_url": "https://api.deepseek.com", - "default_model": "deepseek-v4-pro" + "default_model": "deepseek-flash" } }, "env": { @@ -728,7 +728,7 @@ The configuration file is saved in `~/.clawcodex/config.json`. Example structure "deepseek": { "api_key": "your-api-key", "base_url": "https://api.deepseek.com", - "default_model": "deepseek-v4-pro" + "default_model": "deepseek-flash" } }, "session": { diff --git a/docs/guide/SETUP_GUIDE.md b/docs/guide/SETUP_GUIDE.md index 5b2cac0f1..b0ce2e05f 100644 --- a/docs/guide/SETUP_GUIDE.md +++ b/docs/guide/SETUP_GUIDE.md @@ -97,7 +97,9 @@ Create `~/.clawcodex/config.json` (only the providers you actually use are requi | `zai` | `https://api.z.ai/api/coding/paas/v4` | `glm-5.2` (also `glm-5.1`) | | `minimax` | `https://api.minimaxi.com/anthropic` | `MiniMax-M2.7` | | `openrouter` | `https://openrouter.ai/api/v1` | `deepseek/deepseek-v4-pro` | -| `deepseek` | `https://api.deepseek.com` | `deepseek-v4-pro` | +| `deepseek` | `https://api.deepseek.com` | `deepseek-flash` | + +> **DeepSeek:** `deepseek-flash` is DeepSeek-V4.1-Flash — 1M context, 384K max output, thinking on by default, and the first DeepSeek model that accepts images. `deepseek-v4-pro` is being retired: the id still works, but from 2026-09-14 its requests run V4.1 Flash and bill at the Flash price. The older spellings (`deepseek-v4-flash`, `deepseek-chat`, `deepseek-reasoner`) still resolve. > **Z.ai (GLM):** clawcodex uses Z.ai's OpenAI-compatible GLM Coding Plan at `https://api.z.ai/api/coding/paas/v4`, serving `GLM-5.1` (stable) and `GLM-5.2` (preview). The legacy provider name `glm` is still accepted as an alias for `zai`. Get a key at . diff --git a/src/context_system/context_analyzer.py b/src/context_system/context_analyzer.py index 117b32dc3..713d2b716 100644 --- a/src/context_system/context_analyzer.py +++ b/src/context_system/context_analyzer.py @@ -33,8 +33,13 @@ "glm-5.2": 1_000_000, "glm-5.1": 202_752, "glm-4": 128_000, # legacy GLM-4.x fallback - # DeepSeek V4 ships a 1M context window, like glm-5.2 (legacy deepseek-chat/ + # DeepSeek ships a 1M context window, like glm-5.2 (legacy deepseek-chat/ # -reasoner are intentionally NOT matched here — see src/models/configs.py). + # Two rows, not a bare "deepseek": this table matches by SUBSTRING, so the + # family name alone would promote the legacy ids the exclusion above is + # about. ``deepseek-flash`` is DeepSeek-V4.1-Flash, which does not contain + # the ``deepseek-v4`` substring. + "deepseek-flash": 1_000_000, "deepseek-v4": 1_000_000, # MiniMax model-specific windows must precede the family fallback. "minimax-m3": 1_000_000, diff --git a/src/models/configs.py b/src/models/configs.py index 26f0fe4b1..063dc02bf 100644 --- a/src/models/configs.py +++ b/src/models/configs.py @@ -23,6 +23,15 @@ class ModelConfig: cost_output_per_mtok: float = 15.0 cost_cache_create_per_mtok: float = 3.75 cost_cache_read_per_mtok: float = 0.30 + #: Whether this row may be reached by ``get_model_config``'s prefix + #: fallback (which claims ``key.rsplit("-", 1)[0]``). Set ``False`` on a + #: row whose id has too few segments for that base to be specific — e.g. + #: ``deepseek-flash`` claims bare ``deepseek`` and would otherwise swallow + #: ``deepseek-chat``, ``deepseek-reasoner`` and OpenRouter's + #: ``deepseek/…`` ids. Exact-match lookup ignores this flag, so an + #: opted-out row still describes its own id fully; it just stops + #: describing everyone else's. + claims_prefix: bool = True MODEL_CONFIGS: dict[str, ModelConfig] = { @@ -252,14 +261,16 @@ class ModelConfig: cost_cache_read_per_mtok=0.03, ), - # DeepSeek V4 series (OpenAI-compatible; api.deepseek.com). Registered so + # DeepSeek (OpenAI-compatible; api.deepseek.com). Registered so # context-window-aware logic (compaction triggers, token warnings) uses # DeepSeek's real ~1M window instead of the 200K default. Keys are the # bare model ids used ONLY by the ``deepseek`` provider; OpenRouter's # ``deepseek/…`` ids do not prefix-match ``deepseek-v4``, so OpenRouter is # intentionally unaffected. Legacy ``deepseek-chat`` / ``deepseek-reasoner`` # are deliberately NOT registered: their prefix-match base would be the - # broad ``deepseek`` and could capture other ids. + # broad ``deepseek`` and could capture other ids — which is exactly why the + # ``deepseek-flash`` row below carries ``claims_prefix=False`` rather than + # being left out. # # NOTE: ``get_model_config``'s prefix fallback bases these on # ``deepseek-v4`` and ``pro`` precedes ``flash``, so a FUTURE @@ -288,14 +299,36 @@ class ModelConfig: # was briefly suspected of truncating long ``effort=max`` responses on # terminal-bench 2.1, and it cannot, because it never reaches the wire. # - # ``supports_vision=False``: the DeepSeek API rejects any non-text - # content block outright — + # ``supports_vision=False`` on the V4 rows: the DeepSeek API rejected any + # non-text content block outright — # 400 unknown variant `image_url`, expected `text` # — so a pasted screenshot, an ``@image.png`` mention, or a ``Read`` of - # an image kills the turn. Probed against api.deepseek.com 2026-07-30. + # an image killed the turn. Probed against api.deepseek.com 2026-07-30. # A fusion model (``/fusion``, ``providers/fusion_models.py``) is the - # way to use images with these: it borrows vision from a second model + # way to use images with those: it borrows vision from a second model # and hands the base model a text description. + # + # ``deepseek-flash`` (DeepSeek-V4.1-Flash) is the exception and the reason + # to reach for the canonical id: the vendor's capability table marks it + # Vision ✓, folding in the separate ``deepseek-v4-flash-vision-exp`` line + # it retired. The claim is made for THAT id only — the retired flash ids + # are served by the same model, but the table says nothing about them, so + # they keep the conservative ``False`` and a user hitting it is told to + # switch rather than handed a 400 mid-turn. + "deepseek-flash": ModelConfig( + model_id="deepseek-flash", + display_name="DeepSeek V4.1 Flash", + context_window=1_000_000, + max_output_tokens=384_000, + supports_cache=True, + supports_vision=True, + # Bare ``deepseek`` would be this row's claimed prefix. See the field's + # docstring: without the opt-out it captures ``deepseek-chat``, + # ``deepseek-reasoner`` and every ``deepseek/…`` OpenRouter id, the + # last of which ``tests/test_deepseek_prefix_cache.py`` pins at the + # 200K default. + claims_prefix=False, + ), "deepseek-v4-pro": ModelConfig( model_id="deepseek-v4-pro", display_name="DeepSeek V4 Pro", @@ -813,7 +846,9 @@ def get_model_config(model_id: str) -> ModelConfig | None: """Get config for a model, or None if unknown. Exact match, then a prefix fallback for date-variant ids (a row's claimed - prefix is its key minus the last ``-``-segment). + prefix is its key minus the last ``-``-segment). A row with + ``claims_prefix=False`` is skipped by that fallback and reachable only by + its exact id. NOT attempted: stripping a leading ``/`` segment so OpenRouter ids resolve to their bare row. ``get_pricing`` (services/pricing.py) does @@ -833,6 +868,8 @@ def get_model_config(model_id: str) -> ModelConfig | None: return MODEL_CONFIGS[model_id] # Try prefix match (for date-variant models) for key, config in MODEL_CONFIGS.items(): + if not config.claims_prefix: + continue base = key.rsplit("-", 1)[0] if model_id.startswith(base): return config diff --git a/src/providers/__init__.py b/src/providers/__init__.py index 4ad0f4597..71385aced 100644 --- a/src/providers/__init__.py +++ b/src/providers/__init__.py @@ -152,22 +152,29 @@ class ProviderInfo(_ProviderInfoOptional): "deepseek": { "label": "DeepSeek", "default_base_url": "https://api.deepseek.com", - "default_model": "deepseek-v4-pro", - # Subagent defaults: v4-flash is DeepSeek's fast/cheap line, the - # equivalent of the sonnet/haiku work tiers; v4-pro (the session - # default above) stays the opus-tier target. Catalog verified live - # 2026-08-12 (GET /models returns exactly v4-pro and v4-flash). - "subagent_model": "deepseek-v4-flash", + "default_model": "deepseek-flash", + # Every tier is deepseek-flash (DeepSeek-V4.1-Flash), and that is the + # vendor's own arrangement rather than a simplification: DeepSeek says + # V4.1 Flash "has comprehensively surpassed V4 Pro in performance, + # cost, speed, and total time", and from 2026-09-14 routes every + # deepseek-v4-pro request to it, billed at the Flash price. So an + # opus-tier row naming v4-pro buys nothing after that date and costs + # up to 4.4x more before it. v4-pro stays listed below — it is still + # accepted, and a user who pins it keeps working — it just is not what + # anything defaults to. + "subagent_model": "deepseek-flash", "subagent_tier_models": { - "opus": "deepseek-v4-pro", - "sonnet": "deepseek-v4-flash", - "haiku": "deepseek-v4-flash", + "opus": "deepseek-flash", + "sonnet": "deepseek-flash", + "haiku": "deepseek-flash", }, "available_models": [ - # V4 series (current) + # Current line + "deepseek-flash", + # Retiring: routed to V4.1 Flash from 2026-09-14 (still accepted) "deepseek-v4-pro", + # Legacy aliases (retired models; served by V4.1 Flash) "deepseek-v4-flash", - # Legacy aliases (being deprecated; map to v4-flash modes) "deepseek-chat", "deepseek-reasoner", ], diff --git a/src/providers/deepseek_provider.py b/src/providers/deepseek_provider.py index 54abe5baf..61a810fbd 100644 --- a/src/providers/deepseek_provider.py +++ b/src/providers/deepseek_provider.py @@ -1,10 +1,13 @@ """DeepSeek provider implementation. DeepSeek exposes an OpenAI-compatible API at https://api.deepseek.com. -Current production models are ``deepseek-v4-pro`` and ``deepseek-v4-flash``; -the legacy aliases ``deepseek-chat`` / ``deepseek-reasoner`` are being -deprecated and resolve to the non-thinking / thinking modes of -``deepseek-v4-flash`` respectively. +The current line is ``deepseek-flash`` (DeepSeek-V4.1-Flash): 1M context, +384K max output, thinking on by default, and — unlike every DeepSeek model +before it — image input. ``deepseek-v4-pro`` is being retired; from +2026-09-14 the id still resolves but its requests run V4.1 Flash at the Flash +price. ``deepseek-v4-flash``, ``deepseek-v4-flash-vision-exp``, +``deepseek-chat`` and ``deepseek-reasoner`` are legacy spellings the API still +accepts and serves from V4.1 Flash. """ from __future__ import annotations @@ -36,8 +39,8 @@ class DeepSeekProvider(OpenAICompatibleProvider): #: (api-docs.deepseek.com/guides/thinking_mode). Thinking is ON by #: default at ``high``. #: - #: The API does not VALIDATE this field — probed 2026-08-03 against - #: ``deepseek-v4-flash``, every one of ``low / medium / high / xhigh / + #: The API does not VALIDATE this field — probed 2026-08-03 against the + #: then-current flash model, every one of ``low / medium / high / xhigh / #: max / minimal`` returned 200, and so did a value the docs never list. #: So an unsupported level is not an error, it is silently discarded and #: the default (``high``) applies. Without the mapping below, ``xhigh`` @@ -59,12 +62,12 @@ def __init__( Args: api_key: DeepSeek API key (sk-...) base_url: Base URL (optional, defaults to https://api.deepseek.com) - model: Default model (default: deepseek-v4-pro) + model: Default model (default: deepseek-flash) """ super().__init__( api_key, base_url or self.DEFAULT_BASE_URL, - model or "deepseek-v4-pro", + model or "deepseek-flash", ) def _create_client(self) -> Any: @@ -168,16 +171,22 @@ def _field(name: str) -> int: return result def get_available_models(self) -> list[str]: - """Return DeepSeek's current production models. - - ``deepseek-chat`` and ``deepseek-reasoner`` are kept for backward - compatibility but DeepSeek has announced they will be deprecated. + """Return the DeepSeek model ids the API accepts, current first. + + Only ``deepseek-flash`` names a live model. The rest are ids DeepSeek + still routes: ``deepseek-v4-pro`` is being retired onto V4.1 Flash, + and the remaining spellings name models that are already gone. They + stay listed because a session pinned to one keeps working and the + subagent resolver degrades a model missing from this list to inherit — + dropping them would silently re-point existing sessions. """ return [ - # V4 series (current) + # Current line + "deepseek-flash", + # Retiring: routed to V4.1 Flash from 2026-09-14 (still accepted) "deepseek-v4-pro", + # Legacy spellings (retired models; served by V4.1 Flash) "deepseek-v4-flash", - # Legacy aliases (being deprecated; map to v4-flash modes) "deepseek-chat", "deepseek-reasoner", ] diff --git a/src/services/pricing.py b/src/services/pricing.py index 55fc2d7be..8aa4bd4fb 100644 --- a/src/services/pricing.py +++ b/src/services/pricing.py @@ -5,11 +5,12 @@ ``add_to_total_cost_state`` and friends); this module just computes the dollar cost of a usage record. -One exception to "pure", added for DeepSeek's peak/off-peak card: when a -caller omits ``request_time``, ``get_pricing`` reads the wall clock to decide -which side of that schedule a request falls on. Every caller that knows the -real request time should pass it; "now" is only correct because the live path -prices a response the moment it arrives. +One exception to "pure", added for DeepSeek's peak/off-peak card and reused +for its V4 Pro retirement: when a caller omits ``request_time``, +``get_pricing`` reads the wall clock to decide which side of those schedules a +request falls on. Every caller that knows the real request time should pass +it; "now" is only correct because the live path prices a response the moment +it arrives. Pricing mirrors ``typescript/src/utils/modelCost.ts``: published Anthropic list prices per million tokens for first-party direct calls. Proxies @@ -83,7 +84,7 @@ "cache_creation": 0.30 / 1_000_000, "cache_read": 0.03 / 1_000_000, } -# DeepSeek V4 (USD per million tokens) — checked 2026-08-25 against +# DeepSeek (USD per million tokens) — checked 2026-09-10 against # https://api-docs.deepseek.com/quick_start/pricing/ # # DeepSeek's automatic prefix cache bills cache HITS at the low ``cache_read`` @@ -111,20 +112,25 @@ # likely to move again, and a promo card that rots into silent # under-reporting is the same failure the Luna row below was written about. # -# Not registered: ``deepseek-v4-flash-vision-exp``, which shares the flash -# card on the vendor's page but has no row in ``models/configs.py`` and is -# unreachable through the provider (``supports_vision=False``). +# RE-CARDED 2026-09-10: DeepSeek-V4.1-Flash (``deepseek-flash``) replaced the +# V4 flash line at a CHEAPER card — 0.15/0.6/0.003 off-peak against the +# 0.22/0.66/0.007 below it — and the retired ``deepseek-v4-flash`` / +# ``deepseek-v4-flash-vision-exp`` ids are served by V4.1-Flash and billed at +# its price, so they share this card rather than keeping the V4 one. The old +# flash rates are GONE from the vendor page, not merely superseded for new +# ids; leaving them on the legacy ids would over-report every legacy-id +# session by ~1.5x on input and 2.3x on cache read. _TIER_DEEPSEEK_FLASH_OFF_PEAK = { - "input": 0.22 / 1_000_000, - "output": 0.66 / 1_000_000, - "cache_creation": 0.22 / 1_000_000, - "cache_read": 0.007 / 1_000_000, + "input": 0.15 / 1_000_000, + "output": 0.6 / 1_000_000, + "cache_creation": 0.15 / 1_000_000, + "cache_read": 0.003 / 1_000_000, } _TIER_DEEPSEEK_FLASH_PEAK = { - "input": 0.44 / 1_000_000, - "output": 1.32 / 1_000_000, - "cache_creation": 0.44 / 1_000_000, - "cache_read": 0.014 / 1_000_000, + "input": 0.3 / 1_000_000, + "output": 1.2 / 1_000_000, + "cache_creation": 0.3 / 1_000_000, + "cache_read": 0.006 / 1_000_000, } _TIER_DEEPSEEK_PRO_OFF_PEAK = { "input": 0.66 / 1_000_000, @@ -144,12 +150,36 @@ # states the windows to the hour and says nothing finer, so hour granularity # is exact rather than a rounding. _DEEPSEEK_PEAK_WINDOWS_UTC: tuple[tuple[int, int], ...] = ((1, 4), (6, 10)) +# When ``deepseek-v4-pro`` stops being its own model. The vendor: "From 12:00 +# Beijing Time on September 14, 2026, and until V4.1 Pro is released in the +# future, requests to deepseek-v4-pro will all be routed to V4.1 Flash and +# billed at the V4.1 Flash price." Beijing is UTC+8 year-round (no DST), so +# 12:00 there is 04:00 UTC. +# +# A DATE axis rather than a card edit, because both sides are live: a session +# priced before the cutover really did run V4 Pro at the pro card, and +# ``compute_cost`` already carries the request's timestamp for the peak +# schedule, so re-pricing a stored usage record from last week still gets the +# card that was in force when it was billed. Flattening pro onto the flash +# card outright would restate that history 4.4x low; leaving it on the pro +# card would over-report every request after the cutover by the same factor. +_DEEPSEEK_V4_PRO_ROUTES_TO_FLASH_AT = datetime( + 2026, 9, 14, 4, 0, tzinfo=timezone.utc +).timestamp() # Canonical model id -> (off-peak card, peak card). +# +# ``deepseek-flash`` is DeepSeek's current line (DeepSeek-V4.1-Flash). The two +# retired flash ids are listed because the vendor still ACCEPTS them and bills +# them at the flash price — an unpriced id shows no cost at all, which is the +# one outcome worse than a stale one. +_DEEPSEEK_FLASH_CARDS = ( + _TIER_DEEPSEEK_FLASH_OFF_PEAK, + _TIER_DEEPSEEK_FLASH_PEAK, +) _DEEPSEEK_TIERS: dict[str, tuple[dict[str, float], dict[str, float]]] = { - "deepseek-v4-flash": ( - _TIER_DEEPSEEK_FLASH_OFF_PEAK, - _TIER_DEEPSEEK_FLASH_PEAK, - ), + "deepseek-flash": _DEEPSEEK_FLASH_CARDS, + "deepseek-v4-flash": _DEEPSEEK_FLASH_CARDS, + "deepseek-v4-flash-vision-exp": _DEEPSEEK_FLASH_CARDS, "deepseek-v4-pro": ( _TIER_DEEPSEEK_PRO_OFF_PEAK, _TIER_DEEPSEEK_PRO_PEAK, @@ -321,17 +351,21 @@ "claude-opus-4-5": _TIER_5_25, "claude-opus-4-1": _TIER_15_75, "claude-opus-4-20250514": _TIER_15_75, - # DeepSeek V4 (api.deepseek.com). OpenRouter's ``deepseek/…`` ids resolve + # DeepSeek (api.deepseek.com). OpenRouter's ``deepseek/…`` ids resolve # here too via get_pricing's vendor-prefix strip — consistent with how # every proxied model is priced at its upstream rate. - # VALUES UNUSED, same as the gpt-5.6-luna rows below: these two entries - # are membership gates for ``get_pricing``'s ``model in PRICING`` checks, + # VALUES UNUSED, same as the gpt-5.6-luna rows below: these entries are + # membership gates for ``get_pricing``'s ``model in PRICING`` checks, # and the live card is picked by request time in ``_get_exact_pricing``, # which returns before reaching ``PRICING.get(model)``. They point at the # off-peak card so that anything reading the table directly (the legacy # ``services.cost_tracker`` fallback path) gets the rate that covers 133 - # of every 168 hours rather than a number picked for tidiness. + # of every 168 hours rather than a number picked for tidiness. Keep this + # key set identical to ``_DEEPSEEK_TIERS`` — a row present in one and not + # the other either prices at the wrong card or does not price at all. + "deepseek-flash": _TIER_DEEPSEEK_FLASH_OFF_PEAK, "deepseek-v4-flash": _TIER_DEEPSEEK_FLASH_OFF_PEAK, + "deepseek-v4-flash-vision-exp": _TIER_DEEPSEEK_FLASH_OFF_PEAK, "deepseek-v4-pro": _TIER_DEEPSEEK_PRO_OFF_PEAK, "MiniMax-M3": _TIER_MINIMAX_M3_STANDARD, "MiniMax-M2.7": _TIER_MINIMAX_M27, @@ -414,6 +448,22 @@ def is_deepseek_peak(request_time: float | None = None) -> bool: ) +def deepseek_v4_pro_is_routed_to_flash(request_time: float | None = None) -> bool: + """True once ``deepseek-v4-pro`` is served and billed as V4.1 Flash. + + ``request_time`` is POSIX epoch seconds; ``None`` means now. DeepSeek is + retiring V4 Pro: from 2026-09-14 12:00 Beijing (04:00 UTC) the id stays + accepted but every request behind it runs V4.1 Flash at the Flash price. + + Public for the same reason ``is_deepseek_peak`` is — it is the only way to + explain why a ``deepseek-v4-pro`` line costs a quarter of what an older + one did, without re-deriving the vendor's retirement schedule. It says + nothing about ``deepseek-flash``, which was never on the pro card. + """ + ts = time.time() if request_time is None else request_time + return ts >= _DEEPSEEK_V4_PRO_ROUTES_TO_FLASH_AT + + def _get_exact_pricing( model: str, *, @@ -437,6 +487,10 @@ def _get_exact_pricing( # always resolves to "standard"). deepseek = _DEEPSEEK_TIERS.get(model) if deepseek is not None: + if model == "deepseek-v4-pro" and deepseek_v4_pro_is_routed_to_flash( + request_time + ): + deepseek = _DEEPSEEK_FLASH_CARDS off_peak, peak = deepseek return peak if is_deepseek_peak(request_time) else off_peak if model != "MiniMax-M3": @@ -653,6 +707,7 @@ def compute_session_cost( "DEFAULT_PRICING", "get_pricing", "is_deepseek_peak", + "deepseek_v4_pro_is_routed_to_flash", "is_known_pricing", "compute_cost", "compute_session_cost", diff --git a/tests/test_ch08_subagents_round4.py b/tests/test_ch08_subagents_round4.py index 27f14f253..67d9b191d 100644 --- a/tests/test_ch08_subagents_round4.py +++ b/tests/test_ch08_subagents_round4.py @@ -148,7 +148,7 @@ def _anthropic(model="claude-fable-5", **kwargs): return AnthropicProvider(api_key="test-key", model=model, **kwargs) @staticmethod - def _deepseek(model="deepseek-v4-pro"): + def _deepseek(model="deepseek-flash"): from src.providers.deepseek_provider import DeepSeekProvider return DeepSeekProvider(api_key="test-key", model=model) @@ -170,19 +170,25 @@ def test_anthropic_haiku_tier_resolves_to_live_haiku(self): ) def test_deepseek_unspecified_uses_flash(self): - # Goal ask #2: deepseek-v4-flash is the subagent default. + # Goal ask #2: the flash line is the subagent default. p = self._deepseek() - self.assertEqual(get_agent_model(None, None, p), "deepseek-v4-flash") + self.assertEqual(get_agent_model(None, None, p), "deepseek-flash") def test_deepseek_haiku_tier_uses_flash(self): # Previously 'haiku' fell back to inherit → every Explore fan-out - # ran (and billed) the v4-pro session model. - p = self._deepseek() - self.assertEqual(get_agent_model(None, "haiku", p), "deepseek-v4-flash") - - def test_deepseek_opus_tier_uses_pro(self): - p = self._deepseek(model="deepseek-v4-flash") - self.assertEqual(get_agent_model("opus", None, p), "deepseek-v4-pro") + # ran (and billed) the pro session model. + p = self._deepseek(model="deepseek-v4-pro") + self.assertEqual(get_agent_model(None, "haiku", p), "deepseek-flash") + + def test_deepseek_opus_tier_also_uses_flash(self): + # DeepSeek is a one-model line again: V4.1 Flash beats V4 Pro on the + # vendor's own numbers, and from 2026-09-14 a v4-pro request IS a + # flash request. So the opus tier must NOT climb back to v4-pro — + # that spends up to 4.4x for the same or worse model. The assertion + # is load-bearing precisely because it looks like a no-op: it is what + # a re-added pro row in ``subagent_tier_models`` would trip. + p = self._deepseek(model="deepseek-v4-pro") + self.assertEqual(get_agent_model("opus", None, p), "deepseek-flash") def test_explicit_inherit_still_forces_session_model(self): # The Plan/fork agents pin 'inherit' — the provider default must @@ -193,7 +199,7 @@ def test_explicit_inherit_still_forces_session_model(self): ) self.assertEqual( get_agent_model("inherit", None, self._deepseek()), - "deepseek-v4-pro", + "deepseek-flash", ) def test_custom_anthropic_endpoint_inherits(self): @@ -330,7 +336,7 @@ def test_tier_env_pin_does_not_leak_to_other_providers(self): # api.deepseek.com on every Explore spawn (hard 400). os.environ["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = "my-bedrock-haiku" d = self._deepseek() - self.assertEqual(get_agent_model(None, "haiku", d), "deepseek-v4-flash") + self.assertEqual(get_agent_model(None, "haiku", d), "deepseek-flash") def test_known_alias_spellings_never_ship_raw(self): # critic r3 — trust_literal must only trust ids NO alias table @@ -366,7 +372,7 @@ def test_alias_unservable_on_provider_inherits(self): # A known alias whose canonical target the session provider does # not serve degrades to inherit (never the raw spelling, never a # foreign id that would 400 louder). - d = self._deepseek() + d = self._deepseek(model="deepseek-v4-pro") self.assertEqual( get_agent_model("claude-haiku", None, d), "deepseek-v4-pro", ) @@ -405,10 +411,10 @@ def test_general_purpose_and_explore_defs_route_as_designed(self): ) self.assertEqual( get_agent_model(None, GENERAL_PURPOSE_AGENT.model, d), - "deepseek-v4-flash", + "deepseek-flash", ) self.assertEqual( - get_agent_model(None, EXPLORE_AGENT.model, d), "deepseek-v4-flash", + get_agent_model(None, EXPLORE_AGENT.model, d), "deepseek-flash", ) diff --git a/tests/test_deepseek_peak_pricing.py b/tests/test_deepseek_peak_pricing.py index 5337fc03c..4ed2388bc 100644 --- a/tests/test_deepseek_peak_pricing.py +++ b/tests/test_deepseek_peak_pricing.py @@ -1,4 +1,5 @@ -"""DeepSeek V4's peak/off-peak rate schedule (issue #904). +"""DeepSeek's peak/off-peak rate schedule (issue #904), and V4 Pro's +retirement onto V4.1 Flash. Since 2026-08-16 DeepSeek publishes a peak and an off-peak card: every rate doubles during 01:00-04:00 and 06:00-10:00 UTC, Monday through Friday. That @@ -17,6 +18,12 @@ cache_creation) and still 3x low. Only an external number catches that, which is the same lesson the gpt-5.6-luna row in ``services/pricing.py`` records. + +A second time axis joined them on 2026-09-10: DeepSeek is retiring +``deepseek-v4-pro`` onto DeepSeek-V4.1-Flash, so from a fixed instant that id +prices at the Flash card. It composes with the schedule rather than replacing +it, and — like the schedule — it reads the REQUEST's timestamp, so a session +that really did run V4 Pro is never restated at the cheaper card. """ from __future__ import annotations @@ -26,13 +33,22 @@ import pytest from src.services.pricing import ( + _TIER_DEEPSEEK_FLASH_OFF_PEAK, + _TIER_DEEPSEEK_FLASH_PEAK, + _TIER_DEEPSEEK_PRO_OFF_PEAK, + _TIER_DEEPSEEK_PRO_PEAK, compute_cost, + deepseek_v4_pro_is_routed_to_flash, get_pricing, is_deepseek_peak, ) -MODELS = ("deepseek-v4-flash", "deepseek-v4-pro") +MODELS = ("deepseek-flash", "deepseek-v4-pro") + +# Ids DeepSeek retired but still accepts, all served by — and billed as — +# DeepSeek-V4.1-Flash. +LEGACY_FLASH_IDS = ("deepseek-v4-flash", "deepseek-v4-flash-vision-exp") def _utc(year: int, month: int, day: int, hour: int, minute: int = 0, @@ -47,10 +63,20 @@ def _utc(year: int, month: int, day: int, hour: int, minute: int = 0, MON, FRI, SAT, SUN = 24, 28, 29, 30 # One instant on each side of the schedule, for the tests that care which -# card is in force rather than where the boundaries are. +# card is in force rather than where the boundaries are. Both are in August +# 2026, BEFORE the 2026-09-14 cutover below, so ``deepseek-v4-pro`` is still +# priced as its own model here — the schedule tests and the retirement tests +# stay independent. OFF_PEAK_TS = _utc(2026, 8, MON, 12) PEAK_TS = _utc(2026, 8, MON, 2) +# DeepSeek retires V4 Pro onto V4.1 Flash at 12:00 Beijing (UTC+8) on +# 2026-09-14 — 04:00 UTC, which is itself off-peak (the 01:00-04:00 window is +# half-open). 2026-09-14 is a Monday and 2026-09-15 a Tuesday. +CUTOVER_TS = _utc(2026, 9, 14, 4) +AFTER_CUTOVER_OFF_PEAK_TS = _utc(2026, 9, 15, 12) +AFTER_CUTOVER_PEAK_TS = _utc(2026, 9, 15, 2) + # --------------------------------------------------------------------------- # # The schedule @@ -148,12 +174,15 @@ def test_omitting_request_time_reads_the_clock(monkeypatch) -> None: # The card # --------------------------------------------------------------------------- # -# Published USD per 1M tokens, read 2026-08-25 from -# https://api-docs.deepseek.com/quick_start/pricing/ +# Published USD per 1M tokens, read 2026-09-10 from +# https://api-docs.deepseek.com/quick_start/pricing/. The flash column is +# DeepSeek-V4.1-Flash's, which is CHEAPER than the V4 flash card it replaced +# (0.22 / 0.66 / 0.007) — a re-card in the user's favour is exactly as silent +# as one against them, and only an external number catches either. PUBLISHED = { - "deepseek-v4-flash": { - "off_peak": {"input": 0.22, "output": 0.66, "cache_read": 0.007}, - "peak": {"input": 0.44, "output": 1.32, "cache_read": 0.014}, + "deepseek-flash": { + "off_peak": {"input": 0.15, "output": 0.6, "cache_read": 0.003}, + "peak": {"input": 0.3, "output": 1.2, "cache_read": 0.006}, }, "deepseek-v4-pro": { "off_peak": {"input": 0.66, "output": 1.98, "cache_read": 0.022}, @@ -182,25 +211,120 @@ def test_peak_is_exactly_double_off_peak(model: str) -> None: assert peak[field] == pytest.approx(2 * off[field], rel=1e-12), field -def test_pro_is_three_times_flash_except_on_cache_read() -> None: - """The vendor prices pro at exactly 3x flash on input and output — but NOT - on cache read, where $0.022 against $0.007 is 22/7, not 3. +def test_no_field_of_the_pro_card_derives_from_the_flash_one() -> None: + """Every field has to be read off the page. - Pinned because that is exactly the kind of near-ratio that invites - deriving one row from the other. The cache-read rate has to be read off - the page, and at ~96% of agentic input tokens it is the field that moves - the bill most. + The V4 cards were pro = 3x flash on input and output but 22/7 on cache + read, which was already enough to make "derive one row from the other" + wrong. Against V4.1 Flash the ratios are 4.4x, 3.3x and 7.33x — no two + alike. Pinned as "no single multiplier fits" rather than as three magic + numbers, because the claim worth defending is that the rows are + independent, not what today's quotients happen to be. """ for ts in (OFF_PEAK_TS, PEAK_TS): - flash = get_pricing("deepseek-v4-flash", request_time=ts) + flash = get_pricing("deepseek-flash", request_time=ts) pro = get_pricing("deepseek-v4-pro", request_time=ts) - for field in ("input", "output", "cache_creation"): - assert pro[field] == pytest.approx(3 * flash[field], rel=1e-9), field - assert pro["cache_read"] == pytest.approx( - flash["cache_read"] * 22 / 7, rel=1e-9 - ) - assert pro["cache_read"] != pytest.approx( - 3 * flash["cache_read"], rel=1e-9 + ratios = [pro[f] / flash[f] for f in ("input", "output", "cache_read")] + assert len(set(round(r, 9) for r in ratios)) == 3, ratios + # And the one that moves an agentic bill most is the odd one out. + assert ratios[2] > max(ratios[0], ratios[1]) + + +def test_retired_flash_ids_are_billed_at_the_flash_card() -> None: + """DeepSeek still accepts ``deepseek-v4-flash`` and + ``deepseek-v4-flash-vision-exp``; both are served by V4.1 Flash "and + billed at the Flash price". + + So they must carry V4.1 Flash's card, not the V4 one they shipped with. + Keeping the old rates on them would over-report a legacy-id session by + 1.5x on input and 2.3x on cache read — the same silent-drift failure + issue #904 was about, pointing the other way. + """ + for model in LEGACY_FLASH_IDS: + for ts in (OFF_PEAK_TS, PEAK_TS, AFTER_CUTOVER_OFF_PEAK_TS): + assert get_pricing(model, request_time=ts) == get_pricing( + "deepseek-flash", request_time=ts + ), model + + +# --------------------------------------------------------------------------- # +# V4 Pro's retirement onto V4.1 Flash +# --------------------------------------------------------------------------- # + +def _card(tiers: tuple[dict[str, float], dict[str, float]], ts: float) -> dict: + """The peak-schedule half of the expectation, so the retirement tests can + state the other half without re-deriving which window ``ts`` is in.""" + off_peak, peak = tiers + return peak if is_deepseek_peak(ts) else off_peak + + +PRO_TIERS = (_TIER_DEEPSEEK_PRO_OFF_PEAK, _TIER_DEEPSEEK_PRO_PEAK) +FLASH_TIERS = (_TIER_DEEPSEEK_FLASH_OFF_PEAK, _TIER_DEEPSEEK_FLASH_PEAK) + + +def test_pro_prices_as_itself_before_the_cutover() -> None: + # One second before, which is 03:59:59 UTC — inside a peak window, so this + # also pins that the two axes compose rather than one overriding the other. + ts = CUTOVER_TS - 1 + assert deepseek_v4_pro_is_routed_to_flash(ts) is False + assert get_pricing("deepseek-v4-pro", request_time=ts) == _card(PRO_TIERS, ts) + + +def test_pro_prices_as_flash_from_the_cutover_instant() -> None: + """Half-open, like the peak windows: the stated instant is already the new + regime.""" + assert deepseek_v4_pro_is_routed_to_flash(CUTOVER_TS) is True + assert get_pricing("deepseek-v4-pro", request_time=CUTOVER_TS) == get_pricing( + "deepseek-flash", request_time=CUTOVER_TS + ) + + +def test_cutover_is_1200_beijing_not_1200_utc() -> None: + """Beijing is UTC+8 with no DST, so the vendor's noon is 04:00 UTC. An + off-by-eight-hours here mis-prices a whole business day.""" + assert deepseek_v4_pro_is_routed_to_flash(_utc(2026, 9, 14, 3, 59, 59)) is False + assert deepseek_v4_pro_is_routed_to_flash(_utc(2026, 9, 14, 4, 0, 0)) is True + + +def test_pro_still_follows_the_peak_schedule_after_the_cutover() -> None: + """The retirement swaps which CARD applies; it does not exempt the id from + the clock. Both axes have to compose, and the peak one is applied last.""" + for ts, window in ( + (AFTER_CUTOVER_OFF_PEAK_TS, "off_peak"), + (AFTER_CUTOVER_PEAK_TS, "peak"), + ): + card = PUBLISHED["deepseek-flash"][window] + pricing = get_pricing("deepseek-v4-pro", request_time=ts) + for field, dollars in card.items(): + assert pricing[field] == dollars / 1_000_000, (window, field) + + +def test_cutover_does_not_restate_history_at_the_new_card() -> None: + """A request that really did run V4 Pro is priced by ITS timestamp, not by + the clock at display time. ``compute_cost`` already carries the timestamp + for the peak schedule; the retirement rides the same value, so re-reading + an August session from October must still show what it cost. + """ + usage = _agent_mix() + before = compute_cost("deepseek-v4-pro", usage, request_time=OFF_PEAK_TS) + after = compute_cost( + "deepseek-v4-pro", usage, request_time=AFTER_CUTOVER_OFF_PEAK_TS + ) + assert before == pytest.approx(0.0536, abs=5e-5) + assert after == pytest.approx( + compute_cost("deepseek-flash", usage, + request_time=AFTER_CUTOVER_OFF_PEAK_TS) + ) + assert before > 4 * after + + +def test_flash_is_untouched_by_the_pro_cutover() -> None: + """Scope gate: ``deepseek-flash`` was never on the pro card, so no instant + of the retirement may move it.""" + for ts in (CUTOVER_TS - 1, CUTOVER_TS, AFTER_CUTOVER_OFF_PEAK_TS, + AFTER_CUTOVER_PEAK_TS): + assert get_pricing("deepseek-flash", request_time=ts) == _card( + FLASH_TIERS, ts ) diff --git a/tests/test_deepseek_prefix_cache.py b/tests/test_deepseek_prefix_cache.py index e1d9ab2f7..3388f8f3a 100644 --- a/tests/test_deepseek_prefix_cache.py +++ b/tests/test_deepseek_prefix_cache.py @@ -50,7 +50,8 @@ def test_is_deepseek_flag_scoped_to_deepseek_provider(): # Context-window registry # --------------------------------------------------------------------------- # -def test_deepseek_v4_context_windows_registered(): +def test_deepseek_context_windows_registered(): + assert get_context_window_for_model("deepseek-flash") == 1_000_000 assert get_context_window_for_model("deepseek-v4-pro") == 1_000_000 assert get_context_window_for_model("deepseek-v4-flash") == 1_000_000 # DeepSeek's documented ceiling. Was 8_192 — a placeholder that @@ -64,6 +65,7 @@ def test_deepseek_v4_context_windows_registered(): # (20_000). Effective input therefore moves 991_808 -> 980_000 only. # Pinned so nobody "fixes" a timeout by editing this number: it cannot # truncate a response, because it never reaches the request. + assert get_model_max_output_tokens("deepseek-flash") == 384_000 assert get_model_max_output_tokens("deepseek-v4-pro") == 384_000 assert get_model_max_output_tokens("deepseek-v4-flash") == 384_000 @@ -77,6 +79,34 @@ def test_other_providers_context_window_unchanged(): assert get_context_window_for_model("some-unknown-model") == 200_000 # Legacy aliases intentionally NOT registered (broad prefix-match risk). assert get_context_window_for_model("deepseek-chat") == 200_000 + assert get_context_window_for_model("deepseek-reasoner") == 200_000 + + +def test_deepseek_flash_row_does_not_claim_the_family_prefix(): + """``deepseek-flash``'s prefix base is the bare family name, so the row + opts out of ``get_model_config``'s prefix fallback (``claims_prefix``). + Without that, every id above would inherit its 1M window — including the + legacy aliases the previous test pins at the default, and OpenRouter's + ``deepseek/…`` ids, which decision #1 keeps out of scope. + """ + from src.models.configs import MODEL_CONFIGS, get_model_config + + assert MODEL_CONFIGS["deepseek-flash"].claims_prefix is False + assert get_model_config("deepseek-flash").model_id == "deepseek-flash" + for other in ("deepseek-chat", "deepseek-reasoner", + "deepseek/deepseek-flash", "deepseek-anything-else"): + assert get_model_config(other) is None, other + + +def test_deepseek_flash_is_the_one_deepseek_model_that_sees(): + """DeepSeek-V4.1-Flash folds in the retired ``*-vision-exp`` line; every + other DeepSeek id still 400s on an image content block, which is what the + fusion-model path exists for.""" + from src.models.capabilities import supports_vision + + assert supports_vision("deepseek-flash") is True + assert supports_vision("deepseek-v4-pro") is False + assert supports_vision("deepseek-v4-flash") is False # --------------------------------------------------------------------------- # @@ -181,28 +211,32 @@ def test_other_provider_usage_unchanged_by_cache_fields(): def test_deepseek_pricing_registered(): """Published rates, pinned as absolutes on both sides of the schedule. - Checked 2026-08-25 against api-docs.deepseek.com/quick_start/pricing/. + Checked 2026-09-10 against api-docs.deepseek.com/quick_start/pricing/. Pinning absolutes (rather than ratios) is what catches a stale card — the pre-2026-08-16 values in issue #904 were internally consistent and still 3x low. + + Both instants predate the 2026-09-14 retirement of ``deepseek-v4-pro`` + onto V4.1 Flash, so the pro card here is still V4 Pro's own; that axis is + pinned in ``tests/test_deepseek_peak_pricing.py``. """ from src.services.pricing import get_pricing - flash = get_pricing("deepseek-v4-flash", request_time=_OFF_PEAK) + flash = get_pricing("deepseek-flash", request_time=_OFF_PEAK) pro = get_pricing("deepseek-v4-pro", request_time=_OFF_PEAK) assert flash is not None and pro is not None - assert flash["input"] == 0.22 / 1_000_000 - assert flash["output"] == 0.66 / 1_000_000 - assert flash["cache_read"] == 0.007 / 1_000_000 + assert flash["input"] == 0.15 / 1_000_000 + assert flash["output"] == 0.6 / 1_000_000 + assert flash["cache_read"] == 0.003 / 1_000_000 assert pro["input"] == 0.66 / 1_000_000 assert pro["output"] == 1.98 / 1_000_000 assert pro["cache_read"] == 0.022 / 1_000_000 - flash_peak = get_pricing("deepseek-v4-flash", request_time=_PEAK) + flash_peak = get_pricing("deepseek-flash", request_time=_PEAK) pro_peak = get_pricing("deepseek-v4-pro", request_time=_PEAK) - assert flash_peak["input"] == 0.44 / 1_000_000 - assert flash_peak["output"] == 1.32 / 1_000_000 - assert flash_peak["cache_read"] == 0.014 / 1_000_000 + assert flash_peak["input"] == 0.3 / 1_000_000 + assert flash_peak["output"] == 1.2 / 1_000_000 + assert flash_peak["cache_read"] == 0.006 / 1_000_000 assert pro_peak["input"] == 1.32 / 1_000_000 assert pro_peak["output"] == 3.96 / 1_000_000 assert pro_peak["cache_read"] == 0.044 / 1_000_000 @@ -215,7 +249,7 @@ def test_deepseek_cache_creation_mirrors_input_on_both_cards(): from src.services.pricing import get_pricing for ts in (_OFF_PEAK, _PEAK): - for model in ("deepseek-v4-flash", "deepseek-v4-pro"): + for model in ("deepseek-flash", "deepseek-v4-flash", "deepseek-v4-pro"): p = get_pricing(model, request_time=ts) assert p["cache_creation"] == p["input"] @@ -244,14 +278,14 @@ def test_deepseek_cost_credits_cache_hit_end_to_end(): prompt_tokens=1_000_000, completion_tokens=0, total_tokens=1_000_000, prompt_cache_hit_tokens=900_000, prompt_cache_miss_tokens=100_000, )) - cost = compute_cost("deepseek-v4-flash", usage, request_time=_OFF_PEAK) - expected = 100_000 * 0.22 / 1_000_000 + 900_000 * 0.007 / 1_000_000 + cost = compute_cost("deepseek-flash", usage, request_time=_OFF_PEAK) + expected = 100_000 * 0.15 / 1_000_000 + 900_000 * 0.003 / 1_000_000 assert abs(cost - expected) < 1e-12 # ~9x cheaper than pricing the whole prompt as uncached input. - full = 1_000_000 * 0.22 / 1_000_000 + full = 1_000_000 * 0.15 / 1_000_000 assert cost < full / 5 # The same response costs exactly twice as much inside a peak window. - peak = compute_cost("deepseek-v4-flash", usage, request_time=_PEAK) + peak = compute_cost("deepseek-flash", usage, request_time=_PEAK) assert abs(peak - 2 * expected) < 1e-12