diff --git a/AGENTS.md b/AGENTS.md index 9dc6bac..48fd8e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,7 @@ Key flags: | Flag | Effect | | --- | --- | -| `-m, --model` | `deepseek-v4-flash` (default) or `deepseek-v4-pro` | +| `-m, --model` | `deepseek-flash` (default) or `deepseek-v4-pro` | | `--think on\|off` | thinking mode; default is the API's own, which is on | | `-e, --effort low\|high\|max` | reasoning effort | | `--max-tokens N` | cap generated tokens | @@ -106,7 +106,10 @@ deepseek pricing --json # the schedule and the billing period right now ``` Pricing has been time-of-day since 2026-08-16 16:00 UTC: peak hours -01:00–04:00 and 06:00–10:00 UTC bill at twice the off-peak rate. Never +01:00–04:00 and 06:00–10:00 UTC, Monday to Friday, bill at twice the +off-peak rate. Since 2026-09-10 flash is `deepseek-flash` (V4.1) on a +lower card; the retired names `deepseek-v4-flash` and +`deepseek-v4-flash-vision-exp` still work and bill as flash. Never quote a DeepSeek price without saying which period it is — `pricing` computes the current one locally, no network, nothing spent, from the same schedule the cost estimates use. @@ -163,8 +166,7 @@ deepseek free off # forget the enrolment on this machine Free-tier limits, per UTC day: 30 requests, 60K input tokens, 20K output tokens, 3 web searches, 4K output per call, 128KB per request body, -`deepseek-v4-flash` only. A request for pro is **refused, not -downgraded**. `models` and `status` cost no quota; everything that can +flash only. A request for pro is **refused, not downgraded**. `models` and `status` cost no quota; everything that can generate a token does. `respond --web-search` works on the free tier and spends one of the three @@ -265,21 +267,24 @@ Every call prints a usage line to stderr and appends to for a quiet run. - Costs are estimates from the published USD rate card, not billed amounts. Token counts are exact. -- Cached input tokens cost about 30× less than uncached ones. Put the - stable part of a prompt first — the same system prompt and files across +- Cached input tokens cost 50× less than uncached ones on flash, 30× on + pro. Put the stable part of a prompt first — the same system prompt and files across calls — and `deepseek usage` will show the saving. Prompt structure is still the biggest lever on a bill; the hour of the day comes second, and it is worth at most 2×. ## Cautions -- **Text only.** Images, documents and search-result blocks are rejected - by the API in every format. +- **Text only, from this CLI.** Since 2026-09-10 `deepseek-flash` takes + images through chat and Responses, and pro does not; the CLI has no + flag that sends one. - **The thinking surcharge depends on effort**, and not as documented. The template added to your input is fixed per level, constant across - prompt length, measured live on 2026-08-05: + prompt length, measured live on 2026-08-05 on V4 Flash (retired + 2026-09-10; `deepseek-flash` is not re-measured, and one probe read +26 + at `high`): - | `--effort` | flash | pro | + | `--effort` | V4 flash | pro | | --- | --- | --- | | `none` | +0, thinking off | +0, thinking off | | `minimal`, `low` | +0 | +0 | diff --git a/Makefile b/Makefile index 98a7e9d..93434a4 100644 --- a/Makefile +++ b/Makefile @@ -87,7 +87,8 @@ gateway-check: gateway-test # cost estimates, gateway/internal/meter/meter.go for the gateway's # budget — because they are separate modules and neither can import the # other. Drift would mean the gateway believed it had spent a different -# amount than it had. This is what catches that. +# amount than it had. This is what catches that, for the numbers and for +# the dated instants that switch between them. .PHONY: price-check price-check: @pattern='CacheHitInput: [0-9.]+, CacheMissInput: [0-9.]+, Output: [0-9.]+'; \ @@ -100,7 +101,15 @@ price-check: echo "FAIL: the CLI and gateway rate cards have drifted"; \ echo " cli: $$cli"; echo " gateway: $$gw"; exit 1; \ fi; \ - echo "rate cards match:"; echo "$$cli" | sed 's/^/ /' + echo "rate cards match:"; echo "$$cli" | sed 's/^/ /'; \ + when='time\.Date\([^)]*\)'; \ + cli=$$(grep -oE "$$when" internal/deepseek/pricing.go | sort); \ + gw=$$(grep -oE "$$when" gateway/internal/meter/meter.go | sort); \ + if [ "$$cli" != "$$gw" ]; then \ + echo "FAIL: the CLI and gateway switch cards at different instants"; \ + echo " cli: $$cli"; echo " gateway: $$gw"; exit 1; \ + fi; \ + echo "and switch at the same instants:"; echo "$$cli" | sed 's/^/ /' # The site is generated HTML plus one page that is an application. The # generator has its own --check; these cover the parts it cannot see — diff --git a/README.md b/README.md index d11bbc3..140319c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ 302 tests, 70% statement coverage
Implemented against the DeepSeek API docs of 2026-08-05 - Models: deepseek-v4-flash and deepseek-v4-pro + Models: deepseek-flash and deepseek-v4-pro Go MIT License

@@ -54,7 +54,7 @@ It also does three things `curl` will not: on every later request. `--continue` handles that; without tools it strips the same field, because replaying it there just burns tokens. - **Prices every call.** DeepSeek's disk KV-cache makes a cached input - token **~30× cheaper** than an uncached one, and since 2026-08-16 the + token **30–50× cheaper** than an uncached one, and since 2026-08-16 the hour of the day doubles the bill on top. Both splits are invisible unless something reads `prompt_cache_hit_tokens`, knows the schedule and does the arithmetic. This does, on every call, and keeps a local @@ -171,7 +171,7 @@ is the key, the balance, the network, a proxy, or one endpoint: $ deepseek check https://api.deepseek.com -ok GET /models 141ms deepseek-v4-flash, deepseek-v4-pro +ok GET /models 141ms deepseek-flash, deepseek-v4-pro ok GET /user/balance 126ms 18.48 CNY ok POST /chat/completions 651ms 5 in / 1 out ok POST /anthropic/v1/messages 590ms 5 in / 1 out @@ -376,8 +376,8 @@ context cache saved ~$0.23 (1.7M of 2.2M prompt tokens replayed) costs are estimates from the published USD rate card, not billed amounts ``` -That last line is the point. Cached input costs $0.007/M against -$0.22/M for a miss in an off-peak hour — structuring prompts so the +That last line is the point. On flash, cached input costs $0.003/M +against $0.15/M for a miss in an off-peak hour — structuring prompts so the stable part comes first is worth real money, and this is how you see whether it worked. @@ -470,7 +470,7 @@ rather than hides: the same number at every level — and at low effort it is not there at all, while the model still reasons: - | `--effort` | flash | pro | + | `--effort` | V4 flash | pro | | --- | --- | --- | | `none` | +0 (thinking off) | +0 (thinking off) | | `minimal`, `low` | **+0** | **+0** | @@ -478,12 +478,15 @@ rather than hides: | `max` | +92 | +79 | Measured against the live API on 2026-08-05 at two prompt lengths, - twice each. `deepseek tokens -e low` will show you the same thing for + twice each, on V4 Flash. That model was retired on 2026-09-10 and + `deepseek-flash` has not been re-measured: one probe on 2026-09-18 + read +26 at `high`. `deepseek tokens -e low` will show you the same thing for your own text. Two of those levels — `none` and `minimal` — appear in no DeepSeek documentation at all; `none` disables thinking exactly as `--think off` does. -- **Text only.** DeepSeek rejects image, document and search-result - content blocks in every format. +- **Text only, from this CLI.** Since 2026-09-10 `deepseek-flash` takes + images through chat and Responses, and pro does not; the CLI has no + flag that sends one. - **The Responses endpoint takes both models** since V4-Pro's official release (2026-08-12); it refused pro before that. - **FIM caps output at 4K tokens** and ignores thinking entirely. diff --git a/gateway/DESIGN.md b/gateway/DESIGN.md index ef9f854..a6ccfd5 100644 --- a/gateway/DESIGN.md +++ b/gateway/DESIGN.md @@ -281,25 +281,26 @@ scarce operation and gets the conservative boundary. ## Limits, and why these numbers Worst-case cost per anonymous user per day, at flash off-peak rates -($0.22/M input, $0.66/M output): +($0.15/M input, $0.60/M output): ``` -60,000 input × $0.22/M = $0.0132 -20,000 output × $0.66/M = $0.0132 +60,000 input × $0.15/M = $0.0090 +20,000 output × $0.60/M = $0.0120 ------- - $0.0264 / user / day at full burn, off-peak - $0.0528 / user / day at full burn, peak + $0.0210 / user / day at full burn, off-peak + $0.0420 / user / day at full burn, peak ``` -So a $1/day budget serves ~38 users burning *everything* off-peak, ~19 at +So a $1/day budget serves ~47 users burning *everything* off-peak, ~23 at peak, or several hundred normal ones — a normal turn is a few hundred -input and a few hundred output tokens, about $0.0006. Roughly **1,700 -ordinary turns per dollar off-peak, 850 at peak.** - -These are the numbers of the card that took effect 2026-08-16 16:00 UTC, -and they roughly halved the free tier's reach at an unchanged budget: the -old flat card ($0.14/$0.28) made the same worst case $0.014/user/day. The -quotas below have not been retuned for it — the budget breaker is what +input and a few hundred output tokens, about $0.0005. Roughly **1,900 +ordinary turns per dollar off-peak, 950 at peak.** + +These are the numbers of the V4.1 Flash card of 2026-09-10. The card of +2026-08-16 16:00 UTC ($0.22/$0.66) made the same worst case +$0.0264/user/day, which had roughly halved the free tier's reach against +the flat card before it ($0.14/$0.28, $0.014/user/day). The quotas below +have not been retuned for either — the budget breaker is what actually bounds spend, and it does its job at any card — but the reach per dollar is now a time-of-day figure, and the peak windows are the expensive seven hours. diff --git a/gateway/internal/meter/meter.go b/gateway/internal/meter/meter.go index bca50a7..9ac51e7 100644 --- a/gateway/internal/meter/meter.go +++ b/gateway/internal/meter/meter.go @@ -55,29 +55,57 @@ var RepriceAt = time.Date(2026, time.August, 16, 16, 0, 0, 0, time.UTC) // The drift here runs the other way to the repricing's: over-charging our // own budget is safe for the credit pool but it is still wrong, and it // makes /economics report a cost-per-task the account never paid. The -// same date gates the CLI's copy; `make price-check` guards the numbers, -// and this comment is the only thing guarding the clock. +// same date gates the CLI's copy; `make price-check` guards the numbers +// and, since the V4.1 cut added a third instant, the dates too. var weekendOffPeakAt = time.Date(2026, time.August, 22, 16, 0, 0, 0, time.UTC) +// v41At is when the Flash card dropped with the DeepSeek-V4.1-Flash +// release of 2026-09-10; Pro's card did not move. The instant is +// INFERRED, as in the CLI's copy (internal/deepseek/pricing.go, V41At, +// which records how): upstream published a date and no time. A cut +// cannot drain the pool the way the repricing could, but it can make +// /economics report a cost nobody paid. +var v41At = time.Date(2026, time.September, 10, 11, 0, 0, 0, time.UTC) + // beijing is the vendor's clock; no daylight saving since 1991. var beijing = time.FixedZone("CST", 8*60*60) +// The cards are keyed by tier, not by model name. The API has accepted +// three flash names (deepseek-v4-flash, deepseek-v4-flash-vision-exp, +// deepseek-flash) and bills them all as flash, so cardFor maps a name +// onto its tier and each card says what that tier cost in its era. +const ( + flashTier = "flash" + proTier = "pro" +) + // ratesFlat is the card published 2026-08-02, in force before RepriceAt. var ratesFlat = map[string]Price{ - "deepseek-v4-flash": {CacheHitInput: 0.0028, CacheMissInput: 0.14, Output: 0.28}, - "deepseek-v4-pro": {CacheHitInput: 0.003625, CacheMissInput: 0.435, Output: 0.87}, + flashTier: {CacheHitInput: 0.0028, CacheMissInput: 0.14, Output: 0.28}, + proTier: {CacheHitInput: 0.003625, CacheMissInput: 0.435, Output: 0.87}, +} + +// ratesV4OffPeak is the base card from RepriceAt until v41At; during +// peakWindows every billing item costs peakMultiplier times these numbers. +var ratesV4OffPeak = map[string]Price{ + flashTier: {CacheHitInput: 0.007, CacheMissInput: 0.22, Output: 0.66}, + proTier: {CacheHitInput: 0.022, CacheMissInput: 0.66, Output: 1.98}, +} + +// ratesV41OffPeak is the base card from v41At on: Flash cut on every +// item, Pro unchanged. Same peak rule. +var ratesV41OffPeak = map[string]Price{ + flashTier: {CacheHitInput: 0.003, CacheMissInput: 0.15, Output: 0.6}, + proTier: {CacheHitInput: 0.022, CacheMissInput: 0.66, Output: 1.98}, } -// ratesOffPeak is the base card from RepriceAt on; during peakWindows -// every billing item costs peakMultiplier times these numbers. -var ratesOffPeak = map[string]Price{ - "deepseek-v4-flash": {CacheHitInput: 0.007, CacheMissInput: 0.22, Output: 0.66}, - // Released 2026-08-21, after the switchover, so it has no flat row - // above. Priced identically to flash. Carried here even though the - // policy allowlist does not admit it, so that a metering gap can never - // be the reason it gets served for free. - "deepseek-v4-flash-vision-exp": {CacheHitInput: 0.007, CacheMissInput: 0.22, Output: 0.66}, - "deepseek-v4-pro": {CacheHitInput: 0.022, CacheMissInput: 0.66, Output: 1.98}, +// offPeakCardAt is the time-of-day base card in force at t, which must +// not be before RepriceAt. +func offPeakCardAt(t time.Time) map[string]Price { + if t.Before(v41At) { + return ratesV4OffPeak + } + return ratesV41OffPeak } const peakMultiplier = 2.0 @@ -126,7 +154,7 @@ func PriceAt(model string, t time.Time) Price { if t.Before(RepriceAt) { return cardFor(ratesFlat, model) } - p := cardFor(ratesOffPeak, model) + p := cardFor(offPeakCardAt(t), model) if inPeak(t) { p = scale(p, peakMultiplier) } @@ -134,16 +162,13 @@ func PriceAt(model string, t time.Time) Price { } func cardFor(cards map[string]Price, model string) Price { - if p, ok := cards[model]; ok { - return p - } switch { case strings.Contains(model, "pro") || model == "": - return cards["deepseek-v4-pro"] + return cards[proTier] case strings.Contains(model, "flash"): - return cards["deepseek-v4-flash"] + return cards[flashTier] default: - return cards["deepseek-v4-pro"] + return cards[proTier] } } @@ -228,10 +253,21 @@ func ceilingAt(model string, t time.Time) Price { if t.Add(inFlight).Before(RepriceAt) { return cardFor(ratesFlat, model) } + // The base card is the admission instant's. The one change since + // the repricing, v41At, was a cut, so a request admitted before it is + // already reserved at the dearer card; a card that ever goes UP needs + // the same look-ahead the flip gets above. if !t.Before(RepriceAt) && !peakTouches(t, inFlight) { - return cardFor(ratesOffPeak, model) + return cardFor(offPeakCardAt(t), model) + } + return scale(cardFor(offPeakCardAt(maxTime(t, RepriceAt)), model), peakMultiplier) +} + +func maxTime(a, b time.Time) time.Time { + if a.After(b) { + return a } - return scale(cardFor(ratesOffPeak, model), peakMultiplier) + return b } // peakTouches reports whether any instant of [t, t+d] falls in a peak diff --git a/gateway/internal/meter/meter_test.go b/gateway/internal/meter/meter_test.go index 7013667..bf5a0ae 100644 --- a/gateway/internal/meter/meter_test.go +++ b/gateway/internal/meter/meter_test.go @@ -233,7 +233,7 @@ func TestEstimateCeilingCoversTheNextPeriod(t *testing.T) { // And far from any boundary, off-peak reserves at the off-peak card, // not at a permanent doubling. quiet := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC) - offPeak := costWith(cardFor(ratesOffPeak, model), Usage{InputTokens: 401, OutputTokens: 1000 + reasoningAllowance}) + offPeak := costWith(cardFor(ratesV4OffPeak, model), Usage{InputTokens: 401, OutputTokens: 1000 + reasoningAllowance}) if est := EstimateAt(model, 400, 1000, false, quiet); math.Abs(est-offPeak) > 1e-12 { t.Errorf("quiet off-peak reservation %v, want the off-peak card's %v", est, offPeak) } diff --git a/gateway/internal/meter/weekend_test.go b/gateway/internal/meter/weekend_test.go index 6d31795..88dcd39 100644 --- a/gateway/internal/meter/weekend_test.go +++ b/gateway/internal/meter/weekend_test.go @@ -48,16 +48,42 @@ func TestWeekendIsReadOnTheVendorClock(t *testing.T) { } } -func TestVisionVariantIsMetered(t *testing.T) { +func TestEveryFlashNameIsMeteredAsFlash(t *testing.T) { // A model priced upstream but missing from this card would be served - // for free out of the budget pool. - at := time.Date(2026, 8, 26, 12, 0, 0, 0, time.UTC) - vision := PriceAt("deepseek-v4-flash-vision-exp", at) - flash := PriceAt("deepseek-v4-flash", at) - if vision != flash { - t.Errorf("vision %+v != flash %+v", vision, flash) - } - if vision.CacheMissInput == 0 { - t.Error("vision meters at zero") + // for free out of the budget pool, or, the other way, at pro rates + // that /economics then reports as spent. The API has billed three + // names as flash; each must land on the flash card of its instant. + for _, at := range []time.Time{ + time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC), // flat + time.Date(2026, 8, 26, 12, 0, 0, 0, time.UTC), // V4, off-peak + time.Date(2026, 9, 14, 2, 0, 0, 0, time.UTC), // V4.1, peak + } { + flash := PriceAt("deepseek-v4-flash", at) + for _, name := range []string{"deepseek-flash", "deepseek-v4-flash-vision-exp", "deepseek-v4-flash-free"} { + if got := PriceAt(name, at); got != flash { + t.Errorf("%s at %v = %+v, want flash's %+v", name, at, got, flash) + } + } + if flash.CacheMissInput == 0 { + t.Errorf("flash meters at zero at %v", at) + } + } +} + +func TestV41CutFlashAndLeftPro(t *testing.T) { + before := v41At.Add(-time.Minute) // 10:59 UTC Thursday, off-peak + if got := PriceAt("deepseek-flash", before); got != (Price{CacheHitInput: 0.007, CacheMissInput: 0.22, Output: 0.66}) { + t.Errorf("flash just before V4.1 = %+v, want the V4 card", got) + } + if got := PriceAt("deepseek-flash", v41At); got != (Price{CacheHitInput: 0.003, CacheMissInput: 0.15, Output: 0.6}) { + t.Errorf("flash at V4.1 = %+v, want the V4.1 card", got) + } + if a, b := PriceAt("deepseek-v4-pro", before), PriceAt("deepseek-v4-pro", v41At); a != b { + t.Errorf("pro moved at V4.1: %+v -> %+v", a, b) + } + // A reservation made before the cut must still cover a settlement + // after it: the ceiling may not be beaten by the clock. + if est, after := EstimateAt("deepseek-flash", 400, 1000, false, before), EstimateAt("deepseek-flash", 400, 1000, false, v41At); est < after { + t.Errorf("admitted before the cut the reservation %v is under the post-cut %v", est, after) } } diff --git a/internal/cli/anthropic.go b/internal/cli/anthropic.go index cb911cf..cff3de0 100644 --- a/internal/cli/anthropic.go +++ b/internal/cli/anthropic.go @@ -48,7 +48,7 @@ check how your DeepSeek key behaves for those tools before pointing them at it. Claude model names are accepted and remapped server-side: claude-opus* to -deepseek-v4-pro, claude-sonnet*/claude-haiku* to deepseek-v4-flash, and +deepseek-v4-pro, claude-sonnet*/claude-haiku* to deepseek-flash, and anything unrecognised to flash. The usage line shows both names so the cost is traceable to the model that actually ran. diff --git a/internal/cli/chat.go b/internal/cli/chat.go index 9ec8a67..82c6dd4 100644 --- a/internal/cli/chat.go +++ b/internal/cli/chat.go @@ -64,7 +64,7 @@ under reasoning_content. } fl := cmd.Flags() - fl.StringVarP(&f.model, "model", "m", deepseek.ModelFlash, "model: deepseek-v4-flash or deepseek-v4-pro") + fl.StringVarP(&f.model, "model", "m", deepseek.ModelFlash, "model: deepseek-flash or deepseek-v4-pro") fl.StringVarP(&f.system, "system", "s", "", "system prompt, or @file") fl.StringVar(&f.think, "think", "", "thinking mode: on or off (default: the API's own default, on)") fl.StringVarP(&f.effort, "effort", "e", "", "reasoning effort: low, high, max (pro promotes low to high)") diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index abe8650..4775a36 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -44,9 +44,16 @@ func TestMoneyKeepsSmallFiguresLegible(t *testing.T) { func TestShortModelShowsAnthropicRemapping(t *testing.T) { // When the endpoint silently swaps the model, the status line has to // show both names or the cost looks unattributable. - if got := shortModel("deepseek-v4-flash"); got != "flash" { + if got := shortModel(deepseek.ModelFlash); got != "flash" { t.Errorf("got %q, want %q", got, "flash") } + if got := shortModel(deepseek.ModelPro); got != "pro" { + t.Errorf("got %q, want %q", got, "pro") + } + // A retired name is served by its successor since 2026-09-10. + if got := shortModel("deepseek-v4-flash"); got != "deepseek-v4-flash→flash" { + t.Errorf("got %q, want both names", got) + } if got := shortModel("claude-opus-4-1"); got != "claude-opus-4-1→pro" { t.Errorf("got %q, want both names", got) } diff --git a/internal/cli/e2e_test.go b/internal/cli/e2e_test.go index 3dee18a..a1d2704 100644 --- a/internal/cli/e2e_test.go +++ b/internal/cli/e2e_test.go @@ -405,18 +405,19 @@ func TestFIMSendsPromptAndSuffix(t *testing.T) { } func TestModelsJoinsThePublishedRateCard(t *testing.T) { - got := runCLI(t, serve(`{"object":"list","data":[{"id":"deepseek-v4-flash","object":"model","owned_by":"deepseek"}]}`), "models") + got := runCLI(t, serve(`{"object":"list","data":[{"id":"deepseek-flash","object":"model","owned_by":"deepseek"}]}`), "models") if got.err != nil { t.Fatal(got.err) } - if !strings.Contains(got.stdout, "deepseek-v4-flash") { + if !strings.Contains(got.stdout, "deepseek-flash") { t.Errorf("stdout = %q", got.stdout) } - // Which figure depends on the hour: flash cache-miss input is $0.22 - // off-peak and $0.44 peak. Both are published numbers, so asserting - // on either keeps this a real check without making it a time bomb - // that fails whenever the suite runs inside a peak window. - if !strings.Contains(got.stdout, "0.22") && !strings.Contains(got.stdout, "0.44") { + // Which figure depends on the hour: flash cache-miss input is $0.15 + // off-peak and $0.3 peak on the V4.1 card. Both are published + // numbers, so asserting on either keeps this a real check without + // making it a time bomb that fails whenever the suite runs inside a + // peak window. + if !strings.Contains(got.stdout, "$0.15") && !strings.Contains(got.stdout, "$0.3 ") { t.Errorf("the price should sit next to the model, got %q", got.stdout) } } @@ -490,7 +491,7 @@ func TestLedgerRecordsTheCallAndUsageReportsIt(t *testing.T) { exec("chat", "hi", "--stream=false") report := exec("usage", "--since", "all") - if !strings.Contains(report, "deepseek-v4-flash") { + if !strings.Contains(report, "deepseek-flash") { t.Errorf("usage did not report the call: %s", report) } // 64 of 100 prompt tokens came from cache. diff --git a/internal/cli/fim.go b/internal/cli/fim.go index cff5c2c..cb5eb54 100644 --- a/internal/cli/fim.go +++ b/internal/cli/fim.go @@ -48,7 +48,7 @@ non-thinking mode regardless of any effort setting. } fl := cmd.Flags() - fl.StringVarP(&f.model, "model", "m", deepseek.ModelPro, "model: deepseek-v4-pro or deepseek-v4-flash") + fl.StringVarP(&f.model, "model", "m", deepseek.ModelPro, "model: deepseek-v4-pro or deepseek-flash") fl.StringVar(&f.prefix, "prefix", "", "text before the gap, or @file (defaults to the positional argument or stdin)") fl.StringVar(&f.suffix, "suffix", "", "text after the gap, or @file") fl.IntVar(&f.maxTokens, "max-tokens", 0, "cap generated tokens (the endpoint's own ceiling is 4096)") diff --git a/internal/cli/output.go b/internal/cli/output.go index af4c3f9..a91b45c 100644 --- a/internal/cli/output.go +++ b/internal/cli/output.go @@ -152,15 +152,25 @@ func (o *Options) stats(api, requested string, u deepseek.Usage, dur time.Durati // is always in --json and in the ledger; this is for the human glance. func shortModel(model string) string { resolved := deepseek.ResolveModel(model) - short := strings.TrimPrefix(resolved, "deepseek-v4-") + short := trimModelPrefix(resolved) if resolved != model { - // The Anthropic endpoint remapped a Claude name; show both so the - // cost is traceable to the model that actually ran. + // The Anthropic endpoint remapped a Claude name, or a retired name + // was served by its successor; show both so the cost is traceable + // to the model that actually ran. return model + "→" + short } return short } +// trimModelPrefix drops the vendor prefix, and the generation where the +// name carries one: deepseek-v4-pro reads pro, deepseek-flash reads flash. +func trimModelPrefix(model string) string { + if short, ok := strings.CutPrefix(model, "deepseek-v4-"); ok { + return short + } + return strings.TrimPrefix(model, "deepseek-") +} + // humanTokens renders a token count compactly: 940, 1.2k, 34k, 1.1M. func humanTokens(n int) string { switch { diff --git a/internal/cli/pricing.go b/internal/cli/pricing.go index 969aebc..ec569fa 100644 --- a/internal/cli/pricing.go +++ b/internal/cli/pricing.go @@ -34,11 +34,13 @@ type pricingResult struct { NextChange string `json:"next_change"` RepriceAt string `json:"reprice_at"` + CardSince string `json:"card_since"` PeakWindowsUTC []string `json:"peak_windows_utc"` PeakMultiplier float64 `json:"peak_multiplier"` // Current is the card in effect at now_utc; OffPeak and Peak are the - // dated cards that apply from reprice_at. + // full time-of-day card in force since card_since (before reprice_at, + // the dated card that is coming). Current map[string]pricingPrice `json:"current"` OffPeak map[string]pricingPrice `json:"off_peak"` Peak map[string]pricingPrice `json:"peak"` @@ -53,12 +55,14 @@ func newPricingCmd(o *Options) *cobra.Command { Long: strings.TrimSpace(` Answer what a token costs at this instant, and when that changes. -DeepSeek's repricing of 2026-08-13 is dated: until 16:00 UTC on -2026-08-16 every hour bills at the flat card of 2026-08-02, and from -that instant billing is peak/off-peak on a new, higher card — peak hours -01:00-04:00 and 06:00-10:00 UTC on weekdays at twice the off-peak -rate. Since 2026-08-22 weekends bill off-peak all day, on the Beijing -calendar, so peak is 35 hours a week rather than 49. +Billing has been peak/off-peak since 16:00 UTC on 2026-08-16 (before +it, every hour billed at the flat card of 2026-08-02): peak hours +01:00-04:00 and 06:00-10:00 UTC on weekdays, at twice the off-peak rate. +Since 2026-08-22 weekends bill off-peak all day, on the Beijing +calendar, so peak is 35 hours a week rather than 49. Since the V4.1 +Flash release of 2026-09-10 the Flash card is lower; Pro's is unchanged. +The retired names deepseek-v4-flash and deepseek-v4-flash-vision-exp +bill as deepseek-flash. This command reads no network and spends nothing: the schedule is the same one the cost estimates use, so what it prints is what the usage @@ -82,6 +86,7 @@ func pricingAt(now time.Time) *pricingResult { Multiplier: period.Multiplier, NextChange: deepseek.NextChange(now).Format(time.RFC3339), RepriceAt: deepseek.RepriceAt.Format(time.RFC3339), + CardSince: deepseek.CardSince(fullCardAt(now)).Format(time.RFC3339), PeakMultiplier: deepseek.PeakMultiplier, Current: map[string]pricingPrice{}, OffPeak: map[string]pricingPrice{}, @@ -96,7 +101,7 @@ func pricingAt(now time.Time) *pricingResult { if p, ok := deepseek.PriceAt(m, now); ok { res.Current[m] = pricingPrice{p.CacheHitInput, p.CacheMissInput, p.Output} } - if p, ok := deepseek.PriceAt(m, deepseek.RepriceAt); ok { + if p, ok := deepseek.BasePriceAt(m, fullCardAt(now)); ok { res.OffPeak[m] = pricingPrice{p.CacheHitInput, p.CacheMissInput, p.Output} res.Peak[m] = pricingPrice{ p.CacheHitInput * deepseek.PeakMultiplier, @@ -146,17 +151,18 @@ func formatPricing(now time.Time) string { for _, win := range deepseek.PeakWindows { windows = append(windows, fmtMinutes(win.Start)+"-"+fmtMinutes(win.End)) } + since := deepseek.CardSince(fullCardAt(now)).Format("2006-01-02 15:04 UTC") if now.Before(deepseek.RepriceAt) { fmt.Fprintf(&b, "\nfrom %s — peak hours %s UTC, Mon-Fri; all other hours, and all weekend, off-peak at half of peak:\n", - deepseek.RepriceAt.Format("2006-01-02 15:04 UTC"), strings.Join(windows, " and ")) + since, strings.Join(windows, " and ")) } else { - fmt.Fprintf(&b, "\nthe full card — peak hours %s UTC, Mon-Fri; all other hours, and all weekend, off-peak at half of peak:\n", - strings.Join(windows, " and ")) + fmt.Fprintf(&b, "\nthe full card, since %s — peak hours %s UTC, Mon-Fri; all other hours, and all weekend, off-peak at half of peak:\n", + since, strings.Join(windows, " and ")) } w = tabwriter.NewWriter(&b, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "MODEL\tPERIOD\tIN (CACHED)\tIN (MISS)\tOUT") for _, m := range deepseek.Models { - p, ok := deepseek.PriceAt(m, deepseek.RepriceAt) + p, ok := deepseek.BasePriceAt(m, fullCardAt(now)) if !ok { continue } @@ -166,10 +172,21 @@ func formatPricing(now time.Time) string { } w.Flush() + fmt.Fprintf(&b, "\nthe retired names %s and %s bill as %s.", deepseek.ModelFlashV4, deepseek.ModelFlashVision, deepseek.ModelFlash) fmt.Fprint(&b, "\ncost estimates switch cards on the effective instant automatically.\nupstream copy, offline: deepseek docs show quick_start/pricing") return b.String() } +// fullCardAt is the instant whose era supplies the full time-of-day card +// shown beside the current price: now, or the repricing instant while the +// flat card is still in force, so a reader sees the card that is coming. +func fullCardAt(now time.Time) time.Time { + if now.Before(deepseek.RepriceAt) { + return deepseek.RepriceAt + } + return now +} + func fmtMinutes(m int) string { return fmt.Sprintf("%02d:%02d", m/60, m%60) } diff --git a/internal/cli/pricing_test.go b/internal/cli/pricing_test.go index a6a44a0..40cb5a7 100644 --- a/internal/cli/pricing_test.go +++ b/internal/cli/pricing_test.go @@ -22,15 +22,15 @@ func TestPricingBeforeTheFlip(t *testing.T) { if res.NextChange != deepseek.RepriceAt.Format(time.RFC3339) { t.Errorf("next change = %s, want the repricing instant", res.NextChange) } - if got := res.Current["deepseek-v4-flash"]; got.CacheMissInput != 0.14 { + if got := res.Current[deepseek.ModelFlash]; got.CacheMissInput != 0.14 { t.Errorf("current flash miss = %v, want the flat 0.14", got.CacheMissInput) } // The dated card rides along so a script can see the future without // a second source. - if got := res.OffPeak["deepseek-v4-pro"]; got.Output != 1.98 { + if got := res.OffPeak[deepseek.ModelPro]; got.Output != 1.98 { t.Errorf("off-peak pro output = %v, want 1.98", got.Output) } - if got, want := res.Peak["deepseek-v4-flash"].CacheMissInput, 0.44; got != want { + if got, want := res.Peak[deepseek.ModelFlash].CacheMissInput, 0.44; got != want { t.Errorf("peak flash miss = %v, want %v (2x off-peak)", got, want) } @@ -49,7 +49,7 @@ func TestPricingInsideAPeakWindow(t *testing.T) { if res.Period != "peak" || res.Multiplier != deepseek.PeakMultiplier { t.Errorf("period = %s at %gx, want peak at %gx", res.Period, res.Multiplier, deepseek.PeakMultiplier) } - if got := res.Current["deepseek-v4-flash"]; got.CacheMissInput != 0.44 { + if got := res.Current[deepseek.ModelFlash]; got.CacheMissInput != 0.44 { t.Errorf("current flash miss = %v, want the peak 0.44", got.CacheMissInput) } if res.NextChange != time.Date(2026, 8, 17, 4, 0, 0, 0, time.UTC).Format(time.RFC3339) { @@ -62,6 +62,36 @@ func TestPricingInsideAPeakWindow(t *testing.T) { } } +func TestPricingAfterV41ShowsTheNewFlashCard(t *testing.T) { + now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) // Thursday, off-peak + res := pricingAt(now) + + if res.CardSince != deepseek.V41At.Format(time.RFC3339) { + t.Errorf("card_since = %s, want the V4.1 instant", res.CardSince) + } + // The full card follows the era in force, not the repricing instant: + // reading it at RepriceAt showed the V4 card for a week after it died. + if got := res.OffPeak[deepseek.ModelFlash]; got.CacheMissInput != 0.15 { + t.Errorf("off-peak flash miss = %v, want the V4.1 0.15", got.CacheMissInput) + } + if got := res.Peak[deepseek.ModelFlash]; got.Output != 1.2 { + t.Errorf("peak flash output = %v, want 1.2", got.Output) + } + if got := res.Peak[deepseek.ModelPro]; got.Output != 3.96 { + t.Errorf("peak pro output = %v, want the unchanged 3.96", got.Output) + } + if _, listed := res.Current[deepseek.ModelFlashV4]; listed { + t.Error("a retired name has its own row; it should bill as deepseek-flash") + } + + text := formatPricing(now) + for _, want := range []string{"since 2026-09-10 11:00 UTC", "$0.15", "$0.6", "deepseek-v4-flash-vision-exp bill as deepseek-flash"} { + if !strings.Contains(text, want) { + t.Errorf("text output is missing %q:\n%s", want, text) + } + } +} + func TestPricingJSONAgreesWithTheCostEstimator(t *testing.T) { // The command and Cost() must read the same table: the card `pricing` // prints for an instant is the card a call at that instant is priced @@ -70,6 +100,8 @@ func TestPricingJSONAgreesWithTheCostEstimator(t *testing.T) { time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC), time.Date(2026, 8, 17, 2, 0, 0, 0, time.UTC), time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC), + time.Date(2026, 9, 14, 2, 0, 0, 0, time.UTC), + time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC), } { res := pricingAt(now) for _, m := range []string{deepseek.ModelFlash, deepseek.ModelPro} { diff --git a/internal/cli/respond.go b/internal/cli/respond.go index 53f6652..9ef4ece 100644 --- a/internal/cli/respond.go +++ b/internal/cli/respond.go @@ -55,7 +55,7 @@ Both models are accepted since V4-Pro's official release } fl := cmd.Flags() - fl.StringVarP(&f.model, "model", "m", deepseek.ModelFlash, "model: deepseek-v4-flash or deepseek-v4-pro") + fl.StringVarP(&f.model, "model", "m", deepseek.ModelFlash, "model: deepseek-flash or deepseek-v4-pro") fl.StringVarP(&f.instructions, "instructions", "s", "", "system-level instructions, or @file") fl.StringVarP(&f.effort, "effort", "e", "", "reasoning effort: none, low, high, or max (none disables thinking)") fl.IntVar(&f.maxTokens, "max-tokens", 0, "cap generated tokens, reasoning included") diff --git a/internal/cli/status.go b/internal/cli/status.go index e5b25f0..81766ac 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -116,7 +116,7 @@ func formatStatus(res *statusResult) string { func shortModels(ids []string) []string { out := make([]string, len(ids)) for i, id := range ids { - out[i] = strings.TrimPrefix(id, "deepseek-v4-") + out[i] = trimModelPrefix(id) } return out } diff --git a/internal/deepseek/client.go b/internal/deepseek/client.go index 75b8214..0521ed9 100644 --- a/internal/deepseek/client.go +++ b/internal/deepseek/client.go @@ -26,13 +26,25 @@ import ( const DefaultBaseURL = "https://api.deepseek.com" // Models. DeepSeek exposes exactly two; everything else the API accepts -// (Claude model names on the Anthropic endpoint) is mapped server-side. +// (Claude model names on the Anthropic endpoint, retired model names) is +// mapped server-side. +// +// ModelFlash is a floating alias: since 2026-09-10 it serves +// DeepSeek-V4.1-Flash, and whichever Flash comes next will answer to it +// too. That is the name upstream tells callers to use. const ( - ModelFlash = "deepseek-v4-flash" + ModelFlash = "deepseek-flash" ModelPro = "deepseek-v4-pro" - // ModelFlashVision is the experimental multimodal variant released - // 2026-08-21. It takes image input and bills at exactly the Flash - // rates; it does not support FIM completion. +) + +// Retired names. The models behind them were retired on 2026-09-10; the +// API still accepts both, serves them with V4.1 Flash and bills them at +// the Flash price, so they resolve to ModelFlash. Kept as constants +// because ledger rows written before that date carry them. +const ( + ModelFlashV4 = "deepseek-v4-flash" + // ModelFlashVision was the experimental multimodal variant of + // 2026-08-21. V4.1 Flash takes images natively. ModelFlashVision = "deepseek-v4-flash-vision-exp" ) diff --git a/internal/deepseek/playground_card_test.go b/internal/deepseek/playground_card_test.go new file mode 100644 index 0000000..327ef7f --- /dev/null +++ b/internal/deepseek/playground_card_test.go @@ -0,0 +1,39 @@ +package deepseek + +import ( + "os" + "regexp" + "strconv" + "testing" + "time" +) + +// The playground prices calls in the browser from its own copy of the +// Flash card, because the site is static and cannot ask the CLI. That copy +// went unchecked through two changes: it read the dead card for a day +// after the 2026-08-16 repricing, and it billed weekends at peak for +// weeks after 2026-08-22. So it is pinned here to the newest Flash card +// this package encodes. +// +// Newest, not current: reading the card at time.Now() would turn red on +// the day a dated card takes effect, whoever happened to push that day. +func TestPlaygroundCarriesTheNewestFlashCard(t *testing.T) { + src, err := os.ReadFile("../../site/playground.js") + if err != nil { + t.Fatal(err) + } + m := regexp.MustCompile(`var RATES = \{ cacheHit: ([0-9.]+), cacheMiss: ([0-9.]+), output: ([0-9.]+) \};`).FindSubmatch(src) + if m == nil { + t.Fatal("site/playground.js: no RATES line; if it moved, move this test with it") + } + var got [3]float64 + for i := range got { + if got[i], err = strconv.ParseFloat(string(m[i+1]), 64); err != nil { + t.Fatal(err) + } + } + want, _ := BasePriceAt(ModelFlash, time.Date(9999, 1, 1, 0, 0, 0, 0, time.UTC)) + if got != [3]float64{want.CacheHitInput, want.CacheMissInput, want.Output} { + t.Errorf("playground RATES = %v, newest flash card = %+v", got, want) + } +} diff --git a/internal/deepseek/pricing.go b/internal/deepseek/pricing.go index 9e1c690..57c2282 100644 --- a/internal/deepseek/pricing.go +++ b/internal/deepseek/pricing.go @@ -72,34 +72,84 @@ var RepriceAt = time.Date(2026, time.August, 16, 16, 0, 0, 0, time.UTC) // 2026-08-17 or Saturday 2026-08-22 really did bill peak. var WeekendOffPeakAt = time.Date(2026, time.August, 22, 16, 0, 0, 0, time.UTC) +// V41At is when the Flash card dropped with the DeepSeek-V4.1-Flash +// release. The changelog dates the release 2026-09-10 and says only that +// "API prices have been reduced accordingly"; the pricing page carries +// the new card and no effective instant. Pro's card did not move. +// +// The instant is INFERRED, not published. Our docs mirror fetched the +// pricing page at 04:50 UTC on 2026-09-10 and got the old card, and at +// 11:27 UTC the same day and got the new one. 11:00 UTC is the last whole +// hour inside that bracket, which errs toward the old, dearer card: an +// estimate for a call in the bracket can overstate what it cost, never +// understate it. Move it if DeepSeek publishes the real instant. +var V41At = time.Date(2026, time.September, 10, 11, 0, 0, 0, time.UTC) + // beijing is the vendor's clock. China has observed no daylight saving // since 1991, so a fixed offset is exact and needs no tzdata. var beijing = time.FixedZone("CST", 8*60*60) +// The cards are keyed by ModelFlash and ModelPro as the two tiers, and +// ResolveModel maps every name the API accepts onto one of them. Each +// card is what that tier cost in its era, so ModelFlash's flat row is +// what deepseek-v4-flash cost then. +// +// Superseded cards stay because the ledger stores token counts rather +// than dollars, and a call must reprice under the card it was actually +// billed at. + // pricesFlat is the card published 2026-08-02, in force before RepriceAt. -// That instant has passed, so nothing live prices against it any more; it -// stays because the ledger stores token counts rather than dollars, and a -// call made before the flip must still reprice under the card it was -// actually billed at. var pricesFlat = map[string]Price{ ModelFlash: {CacheHitInput: 0.0028, CacheMissInput: 0.14, Output: 0.28}, ModelPro: {CacheHitInput: 0.003625, CacheMissInput: 0.435, Output: 0.87}, } -// pricesOffPeak is the base card from RepriceAt on. During PeakWindows -// every billing item costs PeakMultiplier times these numbers; DeepSeek -// publishes the peak figures rather than the rule, and they are exactly -// double, so the multiplier is data, not interpretation. -var pricesOffPeak = map[string]Price{ - ModelFlash: {CacheHitInput: 0.007, CacheMissInput: 0.22, Output: 0.66}, - ModelFlashVision: {CacheHitInput: 0.007, CacheMissInput: 0.22, Output: 0.66}, - ModelPro: {CacheHitInput: 0.022, CacheMissInput: 0.66, Output: 1.98}, +// pricesV4OffPeak is the base card from RepriceAt until V41At. During +// PeakWindows every billing item costs PeakMultiplier times these +// numbers; DeepSeek publishes the peak figures rather than the rule, and +// they are exactly double, so the multiplier is data, not interpretation. +var pricesV4OffPeak = map[string]Price{ + ModelFlash: {CacheHitInput: 0.007, CacheMissInput: 0.22, Output: 0.66}, + ModelPro: {CacheHitInput: 0.022, CacheMissInput: 0.66, Output: 1.98}, +} + +// pricesV41OffPeak is the base card from V41At on: Flash cut on every +// item, Pro unchanged. Same peak rule. +var pricesV41OffPeak = map[string]Price{ + ModelFlash: {CacheHitInput: 0.003, CacheMissInput: 0.15, Output: 0.6}, + ModelPro: {CacheHitInput: 0.022, CacheMissInput: 0.66, Output: 1.98}, } // Models are the priced models, in the order the rate card lists them. -// One list so a new model reaches every table at once: the vision variant -// was published upstream and absent here, which meters it at zero. -var Models = []string{ModelFlash, ModelFlashVision, ModelPro} +// One list so a new model reaches every table at once. The retired names +// are not listed: they bill as ModelFlash, and the card says so once. +var Models = []string{ModelFlash, ModelPro} + +// cardAt is the base card of the era in force at t. +func cardAt(t time.Time) map[string]Price { + switch { + case t.Before(RepriceAt): + return pricesFlat + case t.Before(V41At): + return pricesV4OffPeak + default: + return pricesV41OffPeak + } +} + +// CardSince is the instant the base card in force at t took effect, and +// the zero time for the flat card, which has no start this schedule +// knows of. +func CardSince(t time.Time) time.Time { + switch { + case t.Before(RepriceAt): + return time.Time{} + case t.Before(V41At): + return RepriceAt + default: + return V41At + } +} // PeakMultiplier scales the off-peak card during PeakWindows. const PeakMultiplier = 2.0 @@ -160,7 +210,7 @@ func isBeijingWeekend(t time.Time) bool { // NextChange is the next instant after t at which the price of a call // changes: the repricing instant while the flat card is in force, then -// the nearest peak-window boundary of the UTC day. +// the nearest peak-window boundary or card change. func NextChange(t time.Time) time.Time { if t.Before(RepriceAt) { return RepriceAt @@ -169,12 +219,13 @@ func NextChange(t time.Time) time.Time { // turnover at 16:00 UTC, and the policy start dates — lands on a UTC // hour, so walking hours finds the next change exactly. Scanning a // week of them covers the longest run of one period, Friday 10:00 - // UTC to Monday 01:00 UTC, with room to spare. + // UTC to Monday 01:00 UTC, with room to spare. A new card is a change + // even when the period's label is not. u := t.UTC() - here := PeriodAt(u).Label + here, since := PeriodAt(u).Label, CardSince(u) at := u.Truncate(time.Hour).Add(time.Hour) for i := 0; i < 8*24; i++ { - if PeriodAt(at).Label != here { + if PeriodAt(at).Label != here || !CardSince(at).Equal(since) { return at } at = at.Add(time.Hour) @@ -182,14 +233,18 @@ func NextChange(t time.Time) time.Time { return at } +// BasePriceAt is a model's base card in the era in force at t, before +// any peak multiplier: the off-peak row once time-of-day billing began, +// the flat row before it. +func BasePriceAt(model string, t time.Time) (Price, bool) { + p, ok := cardAt(t)[ResolveModel(model)] + return p, ok +} + // PriceAt returns the effective rate card for a model at one instant: // that era's base card, scaled by the period's multiplier. func PriceAt(model string, t time.Time) (Price, bool) { - cards := pricesFlat - if !t.Before(RepriceAt) { - cards = pricesOffPeak - } - p, ok := cards[ResolveModel(model)] + p, ok := BasePriceAt(model, t) if !ok { return Price{}, false } @@ -213,11 +268,14 @@ func PriceFor(model string) (Price, bool) { // // The Anthropic-format endpoint accepts Claude model names and remaps // them: claude-opus* becomes pro, claude-haiku*/claude-sonnet* become -// flash, and anything else unrecognised falls back to flash. +// flash, and anything else unrecognised falls back to flash. The retired +// V4 Flash names are served and billed as flash. func ResolveModel(model string) string { switch { case model == ModelFlash || model == ModelPro: return model + case model == ModelFlashV4 || model == ModelFlashVision: + return ModelFlash case hasPrefix(model, "claude-opus"): return ModelPro case hasPrefix(model, "claude-haiku"), hasPrefix(model, "claude-sonnet"): diff --git a/internal/deepseek/usage_test.go b/internal/deepseek/usage_test.go index 5b72bf7..2f080d1 100644 --- a/internal/deepseek/usage_test.go +++ b/internal/deepseek/usage_test.go @@ -294,17 +294,65 @@ func TestNextChangeCrossesTheWeekend(t *testing.T) { } } -func TestVisionModelIsPricedAtFlashRates(t *testing.T) { - // Released 2026-08-21, priced identically to Flash on every bucket in - // both published currencies. Absent from the card it would meter at zero. - at := time.Date(2026, 8, 26, 12, 0, 0, 0, time.UTC) // Wednesday, off-peak - vision, ok := PriceAt(ModelFlashVision, at) - if !ok { - t.Fatal("deepseek-v4-flash-vision-exp has no published rate") +func TestV41FlashCardSwitchesOnItsInstant(t *testing.T) { + // 2026-09-10: the Flash card dropped with V4.1, Pro's did not. 11:00 + // UTC is off-peak on a Thursday, so the flip lands on the off-peak + // rows of both cards. + u := Usage{InputTokens: 2_000_000, CacheHitTokens: 1_000_000, CacheMissTokens: 1_000_000, OutputTokens: 1_000_000} + + before, _ := CostAt(ModelFlash, u, V41At.Add(-time.Nanosecond)) + if want := 0.007 + 0.22 + 0.66; math.Abs(before-want) > 1e-9 { + t.Errorf("one instant before V4.1: got %v, want the V4 card's %v", before, want) + } + after, _ := CostAt(ModelFlash, u, V41At) + if want := 0.003 + 0.15 + 0.6; math.Abs(after-want) > 1e-9 { + t.Errorf("at V4.1: got %v, want the V4.1 card's %v", after, want) + } + + // Peak is still exactly double, on the new card. + peak := time.Date(2026, 9, 14, 2, 0, 0, 0, time.UTC) // Monday, 01:00-04:00 UTC + p, _ := PriceAt(ModelFlash, peak) + if (p != Price{CacheHitInput: 0.006, CacheMissInput: 0.3, Output: 1.2}) { + t.Errorf("flash at peak on the V4.1 card = %+v, want 0.006/0.3/1.2", p) + } + + for _, at := range []time.Time{V41At.Add(-time.Nanosecond), V41At, peak} { + pro, _ := CostAt(ModelPro, Usage{OutputTokens: 1_000_000}, at) + want := 1.98 * PeriodAt(at).Multiplier + if math.Abs(pro-want) > 1e-9 { + t.Errorf("pro output at %v = %v, want %v: V4.1 did not reprice pro", at, pro, want) + } + } +} + +func TestRetiredFlashNamesBillAsFlashInEveryEra(t *testing.T) { + // deepseek-v4-flash and deepseek-v4-flash-vision-exp are served by + // V4.1 Flash since 2026-09-10 and billed at the Flash price; before it + // they WERE flash (vision priced identically from its release). Either + // way the right answer is the flash card of the instant. + for _, at := range []time.Time{atFlat, atOffPeak, atPeak, V41At, V41At.Add(24 * time.Hour)} { + flash, _ := PriceAt(ModelFlash, at) + for _, name := range []string{ModelFlashV4, ModelFlashVision} { + got, ok := PriceAt(name, at) + if !ok || got != flash { + t.Errorf("%s at %v = %+v (ok=%v), want flash's %+v", name, at, got, ok, flash) + } + } + } +} + +func TestNextChangeSeesTheCardChange(t *testing.T) { + // 10:00 to 11:00 UTC on 2026-09-10 is off-peak on both sides of the + // flip, so only the card changes; a label-only walk would sail past it. + at := V41At.Add(-30 * time.Minute) + if got := NextChange(at); !got.Equal(V41At) { + t.Errorf("NextChange(%v) = %v, want the card change at %v", at, got, V41At) + } + if got := CardSince(V41At.Add(time.Hour)); !got.Equal(V41At) { + t.Errorf("CardSince after V4.1 = %v, want %v", got, V41At) } - flash, _ := PriceAt(ModelFlash, at) - if vision != flash { - t.Errorf("vision %+v != flash %+v", vision, flash) + if got := CardSince(atOffPeak); !got.Equal(RepriceAt) { + t.Errorf("CardSince in the V4 era = %v, want %v", got, RepriceAt) } } @@ -333,6 +381,8 @@ func TestResolveModel(t *testing.T) { cases := map[string]string{ ModelFlash: ModelFlash, ModelPro: ModelPro, + ModelFlashV4: ModelFlash, // retired 2026-09-10, served by V4.1 Flash + ModelFlashVision: ModelFlash, "claude-opus-4-1": ModelPro, "claude-sonnet-4-5": ModelFlash, "claude-haiku-4-5": ModelFlash, diff --git a/internal/docs/corpus.tar.gz b/internal/docs/corpus.tar.gz index e0d5972..1b2bb90 100644 Binary files a/internal/docs/corpus.tar.gz and b/internal/docs/corpus.tar.gz differ diff --git a/site/bench/index.html b/site/bench/index.html index 5c95a90..1097dfd 100644 --- a/site/bench/index.html +++ b/site/bench/index.html @@ -176,16 +176,17 @@

The kill line (斩杀线)

no reason to be chosen. The lever is price, and the gap is not small:

- + - - - + + +
Per 1M tokensv4-flashv4-proGPT-5.6 Solpro is cheaper by
Per 1M tokensflashv4-proGPT-5.6 Solpro is cheaper by
input, cache miss$0.22 / $0.44$0.66 / $1.32$5.007.6x / 3.8x
input, cache hit$0.007 / $0.014$0.022 / $0.044$0.5023x / 11x
output$0.66 / $1.32$1.98 / $3.96$30.0015x / 7.6x
input, cache miss$0.15 / $0.30$0.66 / $1.32$5.007.6x / 3.8x
input, cache hit$0.003 / $0.006$0.022 / $0.044$0.5023x / 11x
output$0.60 / $1.20$1.98 / $3.96$30.0015x / 7.6x
-

DeepSeek cells read off-peak / peak, on the -card in force since 2026-08-16 16:00 UTC; they are a conversion of the RMB +

DeepSeek cells read off-peak / peak: pro on the +card in force since 2026-08-16 16:00 UTC, flash (deepseek-flash, +V4.1) on the lower card of 2026-09-10. They are a conversion of the RMB card (pro: ¥4.5 / ¥0.15 / ¥13.5 per 1M off-peak, double at peak) at one consistent rate. GPT-5.6 Sol prices are from OpenAI's own listing. The pricing page has the full schedule.

diff --git a/site/build.py b/site/build.py index 173f4ea..722f2e9 100644 --- a/site/build.py +++ b/site/build.py @@ -540,7 +540,7 @@ def jstr(s): slug="", title="deepseek-cli: the whole DeepSeek API from the terminal", description="A single Go binary for every DeepSeek API: chat completions in OpenAI, Anthropic and Responses formats, FIM, models and balance, with multi-turn that survives the reasoning round-trip and per-call cost accounting.", - keywords="deepseek cli, deepseek api, deepseek command line, deepseek-v4-flash, deepseek-v4-pro, deepseek anthropic api, deepseek responses api, deepseek context cache, deepseek pricing, llm cli", + keywords="deepseek cli, deepseek api, deepseek command line, deepseek-flash, deepseek-v4-pro, deepseek-v4-flash, deepseek anthropic api, deepseek responses api, deepseek context cache, deepseek pricing, llm cli", jsonld=SOFTWARE_JSONLD, body="""
@@ -555,7 +555,7 @@ def jstr(s): API coverage: 6 of 6 endpoints 302 tests, 70 percent covered Built against the DeepSeek API docs of 2026-08-05 -Models: deepseek-v4-flash and deepseek-v4-pro +Models: deepseek-flash and deepseek-v4-pro

@@ -670,7 +670,7 @@ def jstr(s):
ds check
https://api.deepseek.com
 
-ok    GET /models                  141ms  deepseek-v4-flash, deepseek-v4-pro
+ok    GET /models                  141ms  deepseek-flash, deepseek-v4-pro
 ok    GET /user/balance            126ms  18.48 CNY
 ok    POST /chat/completions       651ms  5 in / 1 out
 ok    POST /anthropic/v1/messages  590ms  5 in / 1 out
@@ -744,9 +744,10 @@ def jstr(s):
 
  • Costs are estimates. Computed from DeepSeek's published USD rate card, not from your invoice. Token counts are exact, and they are what gets stored, so old calls can be repriced when the card changes.
  • -
  • Text only. DeepSeek rejects image, document and -search-result content blocks in every format – that is the API, not a -gap here.
  • +
  • Text only. The CLI sends text. Since 2026-09-10 +deepseek-flash also takes images through chat completions and +Responses, and deepseek-v4-pro does not; the CLI has no flag +that sends one.
  • """, )) @@ -874,7 +875,7 @@ def jstr(s): - + @@ -1101,14 +1102,14 @@ def jstr(s): ("Does DeepSeek's Anthropic endpoint count tokens the same way?", "No. On /anthropic/v1/messages the usage.input_tokens field excludes cache reads, so the full prompt is input_tokens plus cache_read_input_tokens. The OpenAI chat and Responses formats use the opposite convention, where the input count already includes cached tokens. Treating them the same misprices every cached call."), ("What happens if I send a Claude model name to DeepSeek?", - "It is remapped server-side. Model names starting with claude-opus map to deepseek-v4-pro; claude-sonnet and claude-haiku map to deepseek-v4-flash; anything unrecognised also falls back to deepseek-v4-flash."), + "It is remapped server-side. Model names starting with claude-opus map to deepseek-v4-pro; claude-sonnet and claude-haiku map to deepseek-flash; anything unrecognised also falls back to deepseek-flash."), ("Can DeepSeek accept images?", - "No. DeepSeek is text only. Image, document and search-result content blocks are rejected or replaced with placeholder text in every format."), + "deepseek-flash does, since the V4.1 Flash release of 2026-09-10: JPEG, PNG, GIF and WebP, through the chat completions and Responses formats. deepseek-v4-pro does not. The deepseek CLI sends text only."), ]), body="""

    Four wire formats

    DeepSeek exposes the same two models – -deepseek-v4-flash and deepseek-v4-pro – through +deepseek-flash and deepseek-v4-pro – through four different request shapes, so that existing ecosystems can point at it without code changes. They are not interchangeable.

    @@ -1184,8 +1185,8 @@ def jstr(s): - - + +
    FlagEffect
    -m, --modeldeepseek-v4-flash (default) or deepseek-v4-pro
    -m, --modeldeepseek-flash (default) or deepseek-v4-pro
    -s, --systemSystem prompt, inline or @file
    --think on|offThinking mode. Default is the API's own, which is on
    -e, --effortlow, high or max
    You sendYou get
    claude-opus-*deepseek-v4-pro
    claude-sonnet-*, claude-haiku-*deepseek-v4-flash
    anything unrecogniseddeepseek-v4-flash
    claude-sonnet-*, claude-haiku-*deepseek-flash
    anything unrecogniseddeepseek-flash
    @@ -1209,12 +1210,14 @@ def jstr(s):

    Shared limits