Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 15 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
13 changes: 11 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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.]+'; \
Expand All @@ -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 —
Expand Down
21 changes: 12 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<a href="#development"><img src="https://img.shields.io/badge/tests-302%20%C2%B7%2070%25%20covered-brightgreen" alt="302 tests, 70% statement coverage"></a>
<br>
<a href="https://api-docs.deepseek.com"><img src="https://img.shields.io/badge/DeepSeek%20API%20docs-2026--08--05-8a2be2" alt="Implemented against the DeepSeek API docs of 2026-08-05"></a>
<a href="https://api-docs.deepseek.com/quick_start/pricing"><img src="https://img.shields.io/badge/models-v4--flash%20%7C%20v4--pro-0ea5e9" alt="Models: deepseek-v4-flash and deepseek-v4-pro"></a>
<a href="https://api-docs.deepseek.com/quick_start/pricing"><img src="https://img.shields.io/badge/models-flash%20%7C%20v4--pro-0ea5e9" alt="Models: deepseek-flash and deepseek-v4-pro"></a>
<a href="https://go.dev"><img src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white" alt="Go"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="MIT License"></a>
</p>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -470,20 +470,23 @@ 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** |
| `medium`, `high`, `xhigh` | +79 | +0 |
| `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.
Expand Down
27 changes: 14 additions & 13 deletions gateway/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
82 changes: 59 additions & 23 deletions gateway/internal/meter/meter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -126,24 +154,21 @@ 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)
}
return p
}

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]
}
}

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion gateway/internal/meter/meter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
46 changes: 36 additions & 10 deletions gateway/internal/meter/weekend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
2 changes: 1 addition & 1 deletion internal/cli/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion internal/cli/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
Loading
Loading