diff --git a/AGENTS.md b/AGENTS.md
index 48fd8e1..71b588f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -88,13 +88,18 @@ deepseek fim "def f():" --suffix " return x"
Reach for `anthropic` or `respond` when the task is specifically about
those wire formats. For a plain answer, use `chat`.
-Only `respond` has JSON Schema output and server-side web search:
+Only `respond` has JSON Schema output:
```bash
deepseek respond "Berlin" -s "Return city and country." --schema @city.json
-deepseek respond "what shipped in Go 1.26" --web-search
```
+There is no server-side web search any more. DeepSeek removed the
+Responses API's `web_search` tool on 2026-09-10 and now ignores it, so
+`respond --web-search` exits 1 with that reason and sends nothing. To
+ground an answer, search yourself and pass the results with `--file` or
+stdin.
+
### Account and cost
```bash
@@ -165,15 +170,12 @@ 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,
-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
-daily searches. It is rationed that tightly because DeepSeek reads whole
-pages into the prompt — one measured search request billed 40K input
-tokens, about ten ordinary turns — so treat it as a few lookups a day, not
-a research loop. Other server-side tools are still refused.
+tokens, 4K output per call, 128KB per request body, `deepseek-flash`
+only (the retired names `deepseek-v4-flash` and
+`deepseek-v4-flash-vision-exp` are the same model). A request for pro is
+**refused, not downgraded**. `models` and `status` cost no quota;
+everything that can generate a token does. Server-side tools are refused,
+`web_search` with the reason that DeepSeek removed it.
Errors from the gateway carry `"type":"free_tier_*"` and a message that
already contains the next step — do not append DeepSeek's own advice to
diff --git a/README.md b/README.md
index 140319c..84fc6bb 100644
--- a/README.md
+++ b/README.md
@@ -134,7 +134,7 @@ The free tier relays your prompts to DeepSeek through a gateway run by
this project. No account, no API key.
gateway https://freeseek.1lm.io
- model deepseek-v4-flash
+ model deepseek-flash
per day 30 requests · 60k input · 20k output tokens
privacy prompts and completions are relayed to DeepSeek and are not
stored or logged by this gateway; only token counts and cost
@@ -189,7 +189,7 @@ One command per endpoint, named for what it does.
| --- | --- | --- |
| `chat` | `POST /chat/completions` | The default. OpenAI format, the one most tools speak. |
| `anthropic` | `POST /anthropic/v1/messages` | The format Claude Code and the Anthropic SDKs speak. |
-| `respond` | `POST /responses` | The format Codex speaks. JSON Schema output and server-side `web_search` live only here. |
+| `respond` | `POST /responses` | The format Codex speaks. JSON Schema output lives only here. |
| `fim` | `POST /beta/completions` | Fill in the middle — the shape editors use for inline completion. |
| `models` | `GET /models` | Available models, joined with the published rate card. |
| `balance` | `GET /user/balance` | What is left, per currency. |
@@ -209,7 +209,7 @@ One command per endpoint, named for what it does.
deepseek chat "why is the sky blue"
git diff | deepseek chat "write a commit message"
deepseek chat "explain" --file server.go --file server_test.go
-deepseek chat "review this" --model deepseek-v4-pro --effort max
+deepseek chat "review this" --effort max
deepseek chat "summarise" --system @house-style.md
```
@@ -322,11 +322,11 @@ page keeps the upstream URL it was converted from.
```bash
# Anthropic Messages. Claude model names are accepted and remapped
# server-side; the usage line shows both so cost stays traceable.
-deepseek anthropic "hello" --model claude-opus-4-1
-# · claude-opus-4-1→pro · 10 in · 8 out · ~$0.000011 · 0.9s
+deepseek anthropic "hello" --model claude-sonnet-4-5
+# · claude-sonnet-4-5→flash · 10 in · 8 out · ~$0.000006 · 0.9s
-# Responses: JSON Schema output, and a web_search tool DeepSeek runs
-deepseek respond "what shipped in Go 1.26" --web-search
+# Responses: JSON Schema output. Its server-side web_search tool was
+# removed upstream on 2026-09-10; --web-search now exits with that reason.
deepseek respond "Berlin" -s "Return city and country." --schema @city.json
# FIM: prefix in, suffix optional, the middle comes back
@@ -366,13 +366,13 @@ JSONL ledger:
```console
$ deepseek usage --since 7d
- CALLS IN CACHED OUT COST
-deepseek-v4-flash 184 2.1M 78% 94k $0.19
-deepseek-v4-pro 12 88k 41% 11k $0.03
-total 196 2.2M 77% 105k $0.22
+ CALLS IN CACHED OUT COST
+deepseek-flash 184 2.1M 78% 94k $0.13
+deepseek-v4-pro 12 88k 41% 11k $0.06
+total 196 2.2M 77% 105k $0.19
by format: chat 170, anthropic 14, responses 8, fim 4
-context cache saved ~$0.23 (1.7M of 2.2M prompt tokens replayed)
+context cache saved ~$0.26 (1.7M of 2.2M prompt tokens replayed)
costs are estimates from the published USD rate card, not billed amounts
```
diff --git a/TASTE.md b/TASTE.md
index 7e6d1f8..5c0a4ac 100644
--- a/TASTE.md
+++ b/TASTE.md
@@ -75,8 +75,9 @@ return genuinely different objects — `choices[].message` versus
with opposite cache conventions. A unified command has two ways out and
both are bad: normalize, and `--json` lies about what the API sent; or
leak, and one command emits three shapes depending on a flag. Meanwhile
-format-specific parameters (`web_search` and JSON Schema only exist on
-Responses, `prefix` only on beta chat) have to hide behind conditional
+format-specific parameters (`web_search` — until DeepSeek removed it on
+2026-09-10 — and JSON Schema only exist on Responses, `prefix` only on
+beta chat) have to hide behind conditional
validation. One verb, four behaviours, and the user still has to know
which format they are in — the surface got smaller while the task got
harder.
@@ -472,3 +473,23 @@ the ration are both sized to a single measurement and should be re-measured
when the tool changes. If a search request is ever observed above 256k
input tokens in production, that is the signal to raise the allowance
rather than to quietly accept the overshoot.
+
+**Expired 2026-09-18, by upstream.** DeepSeek removed `web_search` from
+the Responses API on 2026-09-10, with V4.1 Flash: the guide now lists it
+among the built-in tools that are *ignored*, and its streaming events are
+gone. A probe on 2026-09-18 confirmed it — the tool was accepted and
+echoed, no search ran, and the request billed 21 input tokens where the
+measured search above billed 40,260. The ration and the allowance
+therefore priced searches that could no longer happen, and the flag sent
+a request that answered from the model's memory to a caller who believed
+it had searched.
+
+What replaced it: the gateway refuses the tool with a 400 that says why,
+`respond --web-search` exits 1 with the same reason and sends nothing,
+and `DailySearches` and `searchInputAllowance` are deleted. The flag is
+kept, hidden, so a script that still passes it learns the reason instead
+of reading "unknown flag" as a bug here. Refusing rather than stripping
+the tool and forwarding is the same judgement as refusing pro rather than
+downgrading it: a caller who asked for something must not be handed less
+without being told. If DeepSeek brings a server-side search back, this
+entry and the one above are the arithmetic to redo, not to restore.
diff --git a/gateway/DESIGN.md b/gateway/DESIGN.md
index a6ccfd5..0bf3447 100644
--- a/gateway/DESIGN.md
+++ b/gateway/DESIGN.md
@@ -310,7 +310,6 @@ expensive seven hours.
| `DSGATE_ANON_DAILY_REQUESTS` | 30 | enough to be useful for a day's work, not enough to script against |
| `DSGATE_ANON_DAILY_INPUT_TOKENS` | 60000 | ~150 pages of context per day |
| `DSGATE_ANON_DAILY_OUTPUT_TOKENS` | 20000 | the expensive side; the real cap |
-| `DSGATE_ANON_DAILY_SEARCHES` | 3 | a `web_search` request costs ~10 ordinary turns; the request counter alone would let one caller take a quarter of the day |
| `DSGATE_ANON_MAX_TOKENS` | 4096 | clamps a single response, bounding overshoot |
| `DSGATE_MAX_BODY_BYTES` | 131072 | ~32K tokens; bounds the input side of overshoot |
| `DSGATE_DAILY_BUDGET_USD` | 1.00 | the circuit breaker; the number that actually protects us |
@@ -323,22 +322,29 @@ expensive seven hours.
| `DSGATE_TOKEN_TTL_DAYS` | 7 | identities age out instead of accumulating |
| `DSGATE_BALANCE_CHECK_MINUTES` | 15 | the ledger's "we have credit" is checked against the real account |
-**`web_search` is carried, and rationed.** Measured on 2026-08-07, one
-search request made eleven server-side calls and billed 40,260 input
-tokens with no separate per-search fee — so its whole cost arrives as
-input tokens the meter already reads. What it breaks is the *reservation*,
-which bounded input at one token per body byte: DeepSeek chooses how many
-pages to read, so a search's input is upstream-controlled. Hence a 256k
-input allowance at admission (about 6x the observed case) plus the daily
-ration above. Within that allowance the budget is still a hard ceiling;
-past it a search can overshoot by the difference, bounded by how many
-distinct callers can be mid-search at once. Every other server-side tool
-stays refused: unknown work at an unknown price, spent from donated
-credit. Reasoning and the expiry condition are in `TASTE.md`.
-
-**Free tier is flash only.** Pro is 3x the price and the request is
-*rejected*, not silently downgraded — a user who asked for pro and got
-flash without being told would draw wrong conclusions and blame the model.
+**No server-side tool is carried.** From 2026-08-07 to 2026-09-18 the
+free tier carried `web_search`, with a 3/day ration and a 256k input
+allowance at admission, because one measured search billed 40,260 input
+tokens. DeepSeek removed the tool from the Responses API on 2026-09-10,
+with V4.1 Flash: the guide now lists `web_search` among the built-in
+tools that are *ignored*, and a probe on 2026-09-18 billed 21 input
+tokens where a search used to bill ~40K. Forwarding it would bill an
+answer from the model's memory to a caller who believes it searched, so
+it is refused with that reason, and the ration and the allowance are
+gone. Every other server-side tool stays refused: unknown work at an
+unknown price, spent from donated credit. The history is in `TASTE.md`.
+
+**Free tier is `deepseek-flash` only.** It is DeepSeek's current Flash
+model (V4.1, 2026-09-10); `deepseek-v4-flash` and
+`deepseek-v4-flash-vision-exp` are retired names upstream serves as the
+same model, so the gateway accepts them as that model and pins the
+request to the name upstream lists. A `DSGATE_MODEL` that still says
+`deepseek-v4-flash` is served as `deepseek-flash` for the same reason —
+otherwise `/models`, which upstream now answers with the new name only,
+would filter to nothing. Pro is 3.3x flash's price per output token and
+the request is *rejected*, not silently downgraded — a user who asked for
+pro and got flash without being told would draw wrong conclusions about
+the model.
---
@@ -408,7 +414,7 @@ stylesheet; the donation path is a private message to a human.
The key pool answers "what if our key runs out". It does not answer the
prior question — why is a chat request costing us anything at all, when
-OpenCode Zen serves `deepseek-v4-flash-free` for nothing.
+OpenCode Zen served `deepseek-v4-flash-free` for nothing.
So there are two upstreams now, tried in order. The indirection is not
"a list of interchangeable backends"; a second lane earns its keep only
@@ -419,7 +425,7 @@ the model by, what it costs, and how often it says no.
Measured against Zen on 2026-08-12:
- `/chat/completions` works, reporting usage in both streamed and
- buffered form. `/responses` answers but rejects a server-side
+ buffered form. `/responses` answers but rejected a server-side
`web_search` tool. `/anthropic/v1/messages`, `/beta/completions` and
`/user/balance` are 404.
- About one sequential request in five comes back
@@ -428,11 +434,23 @@ Measured against Zen on 2026-08-12:
- The model is `deepseek-v4-flash-free` there. Our callers never learn
that: `policy.Retarget` renames the field on the way out, and the
allowlist, the `/models` list and the client contract all keep saying
- `deepseek-v4-flash`.
+ the served name — `deepseek-flash` since 2026-09-18.
- Zen's own docs say free-lane data **may be used to improve the model**.
That is a different promise from the paid path's and belongs in the
user-facing copy, not just here.
+**Re-measured 2026-09-18: the lane is dead.** Zen still lists
+`deepseek-v4-flash-free` and answers every request with
+`Model is unavailable` (V4 Flash was retired upstream on 2026-09-10).
+The fallback keeps the service correct — every chat still lands on the
+DeepSeek key — but each one pays a refused round trip, and its prompt
+still reaches Zen. So the operator step is to unset `OPENCODE_API_KEY`
+until Zen serves a free V4.1 model. The rename above is why that check
+cannot be skipped: callers are told `deepseek-flash` whatever the lane
+runs, so a revived free lane has to be shown to serve V4.1 (the 53-token
+billed prompt offset between the V4 and V4.1 families is one test)
+before it goes back on.
+
A 20% refusal rate is why the lane is first-choice rather than the whole
service, and why the fallback must happen before a single byte reaches
the client — after `WriteHeader` the status line is spent. Any non-2xx
diff --git a/gateway/README.md b/gateway/README.md
index 5e4f31d..94da222 100644
--- a/gateway/README.md
+++ b/gateway/README.md
@@ -37,9 +37,10 @@ What it changes about a request, and nothing else:
| | |
|---|---|
-| `model` | pinned to flash; a pro request is **refused**, not downgraded |
+| `model` | pinned to `deepseek-flash`; the retired flash names are the same model; a pro request is **refused**, not downgraded |
| `max_tokens` | clamped to the free-tier ceiling |
| `n`, `best_of` | refused above 1 — they multiply the cost of one admitted request |
+| server-side tools | refused; `web_search` with the reason: DeepSeek removed it on 2026-09-10 and now ignores it |
| user identity | overwritten with the token's subject |
That last one is not a nicety. DeepSeek documents `user_id` as the
@@ -63,15 +64,25 @@ Measured against Zen on 2026-08-12, which is why the lane is this narrow:
|---|---|
| refusal rate | ~20% of sequential requests, `429 FreeUsageLimitError` |
| `/chat/completions` | works; usage reported streamed and buffered |
-| `/responses` | answers, but rejects a server-side `web_search` tool |
+| `/responses` | answers, but rejected a server-side `web_search` tool |
| `/anthropic/v1/messages`, `/beta/completions`, `/user/balance` | 404 |
| the model's name there | `deepseek-v4-flash-free`, aliased at the last moment |
| privacy | Zen says free-lane data **may be used to improve the model** |
So it carries `chat` and nothing else. FIM, the Anthropic and Responses
-formats, web search and the model list all go straight to DeepSeek, and
-the caller's contract does not change: they ask for `deepseek-v4-flash`,
-by that name, on every route.
+formats and the model list all go straight to DeepSeek, and the caller's
+contract does not change: they ask for `deepseek-flash`, by that name, on
+every route.
+
+**Measured 2026-09-18: the lane carries nothing right now.** Zen still
+lists `deepseek-v4-flash-free`, but answers every request with
+`Model is unavailable` — V4 Flash was retired upstream on 2026-09-10 and
+Zen has no free V4.1 model. Every chat request therefore pays one refused
+round trip and still lands on the DeepSeek key, and its prompt still
+reaches Zen on the way. Leave `OPENCODE_API_KEY` unset until Zen serves a
+free `deepseek-flash`, and check that the model it serves is V4.1 before
+turning it back on: the gateway renames the model on the way out, so
+callers would be told `deepseek-flash` whatever Zen actually runs.
The interesting consequence is what happens when the money runs out.
A request that the free lane can serve is admitted **past** the daily
diff --git a/gateway/cmd/dsgate/main.go b/gateway/cmd/dsgate/main.go
index 7673593..63f0547 100644
--- a/gateway/cmd/dsgate/main.go
+++ b/gateway/cmd/dsgate/main.go
@@ -76,7 +76,7 @@ behaves exactly as it did before this existed.
DSGATE_ADDR listen address (:8787)
DSGATE_STATE_DIR journal, secret, revocations (./state)
DSGATE_SECRET token signing secret, hex (generated and persisted)
- DSGATE_MODEL the only model served (deepseek-v4-flash)
+ DSGATE_MODEL the only model served (deepseek-flash)
DSGATE_ANNOUNCE public URL, shown to clients
Per-user daily limits:
@@ -84,7 +84,6 @@ Per-user daily limits:
DSGATE_ANON_DAILY_REQUESTS (30)
DSGATE_ANON_DAILY_INPUT_TOKENS (60000)
DSGATE_ANON_DAILY_OUTPUT_TOKENS (20000)
- DSGATE_ANON_DAILY_SEARCHES (3) server-side web searches per user
DSGATE_ANON_MAX_TOKENS (4096) per-request output cap
DSGATE_MAX_BODY_BYTES (131072) per-request body cap
DSGATE_REQUESTS_PER_MINUTE (20) per-address burst
@@ -152,7 +151,6 @@ func run() error {
DailyRequests: envInt("DSGATE_ANON_DAILY_REQUESTS", 30),
DailyInputTokens: envInt("DSGATE_ANON_DAILY_INPUT_TOKENS", 60000),
DailyOutputTokens: envInt("DSGATE_ANON_DAILY_OUTPUT_TOKENS", 20000),
- DailySearches: envInt("DSGATE_ANON_DAILY_SEARCHES", 3),
DailyBudgetUSD: envFloat("DSGATE_DAILY_BUDGET_USD", 1.00),
TotalBudgetUSD: envFloat("DSGATE_TOTAL_BUDGET_USD", 20.00),
}
@@ -180,7 +178,7 @@ func run() error {
UpstreamBaseURL: env("DSGATE_UPSTREAM_BASE_URL", "https://api.deepseek.com"),
UpstreamKeys: keys,
KeyStatePath: filepath.Join(stateDir, "donated-keys.json"),
- Model: env("DSGATE_MODEL", "deepseek-v4-flash"),
+ Model: env("DSGATE_MODEL", "deepseek-flash"),
FreeBaseURL: env("DSGATE_FREE_BASE_URL", "https://opencode.ai/zen/v1"),
FreeKeys: freeKeys,
FreeModel: env("DSGATE_FREE_MODEL", "deepseek-v4-flash-free"),
diff --git a/gateway/deploy/README.md b/gateway/deploy/README.md
index 64a3a08..f7a4b50 100644
--- a/gateway/deploy/README.md
+++ b/gateway/deploy/README.md
@@ -14,7 +14,7 @@ DSGATE_UPSTREAM_KEY=sk-... dsgate
```
That listens on `:8787`, writes state to `./state`, serves
-`deepseek-v4-flash`, and stops spending at **$1.00 a day / $20.00
+`deepseek-flash` (DeepSeek-V4.1-Flash), and stops spending at **$1.00 a day / $20.00
total**. Point a CLI at it:
```bash
@@ -39,8 +39,18 @@ anything. `GET /v1/status` reports the share it is carrying under
Two things to know before turning it on. Zen's free lane says the prompts
it sees **may be used to improve the model**, which is a different promise
from the one the paid path makes, so say so wherever you tell users where
-their prompts go. And it only carries `chat`: FIM, the Anthropic and
-Responses formats, and web search still spend real credit.
+their prompts go. And it only carries `chat`: FIM and the Anthropic and
+Responses formats still spend real credit.
+
+**As of 2026-09-18 Zen answers its free DeepSeek model with
+`Model is unavailable` on every request** (V4 Flash was retired upstream
+on 2026-09-10), so the lane saves nothing and adds a refused round trip
+to every chat. Leave `OPENCODE_API_KEY` unset until that changes; see the
+gateway README.
+
+A `DSGATE_MODEL` written before 2026-09-10 (`deepseek-v4-flash`) still
+works: the gateway serves it under the name upstream uses today,
+`deepseek-flash`, and says so in its boot log.
## Docker
diff --git a/gateway/internal/meter/meter.go b/gateway/internal/meter/meter.go
index 9ac51e7..d339830 100644
--- a/gateway/internal/meter/meter.go
+++ b/gateway/internal/meter/meter.go
@@ -60,12 +60,12 @@ var RepriceAt = time.Date(2026, time.August, 16, 16, 0, 0, 0, time.UTC)
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)
+// release of 2026-09-10; Pro's card did not move. 04:00 UTC is the instant
+// DeepSeek's release note publishes (see the CLI's copy,
+// internal/deepseek/pricing.go, V41At). 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, 4, 0, 0, 0, time.UTC)
// beijing is the vendor's clock; no daylight saving since 1991.
var beijing = time.FixedZone("CST", 8*60*60)
@@ -216,26 +216,19 @@ func costWith(p Price, u Usage) float64 {
// tokens respect max_tokens — they are billed as output either way,
// so the bound assumes they do not.
//
-// A search request breaks the first rule: the pages DeepSeek reads on the
-// caller's behalf arrive as input tokens the body never contained, so
-// searchInputAllowance is added to the input bound instead.
// A third rule joined them with the dated repricing: the reservation is
// priced at the dearest card the request could settle under, not the
// card of the admission instant. A request admitted just before a peak
// window (or just before the repricing flip) can settle inside it, and
// an estimate the clock can outrun is not a ceiling.
-func Estimate(model string, requestBytes, maxTokens int, search bool) float64 {
- return EstimateAt(model, requestBytes, maxTokens, search, time.Now())
+func Estimate(model string, requestBytes, maxTokens int) float64 {
+ return EstimateAt(model, requestBytes, maxTokens, time.Now())
}
// EstimateAt is Estimate at a chosen instant.
-func EstimateAt(model string, requestBytes, maxTokens int, search bool, t time.Time) float64 {
- input := requestBytes + 1
- if search {
- input += searchInputAllowance
- }
+func EstimateAt(model string, requestBytes, maxTokens int, t time.Time) float64 {
return costWith(ceilingAt(model, t), Usage{
- InputTokens: input,
+ InputTokens: requestBytes + 1,
OutputTokens: maxTokens + reasoningAllowance,
Found: false,
})
@@ -297,24 +290,6 @@ func peakTouches(t time.Time, d time.Duration) bool {
// a cent, so over-reserving costs headroom, not money.
const reasoningAllowance = 32 << 10
-// searchInputAllowance is the input headroom reserved for a server-side
-// web search, whose page reads land in input_tokens without ever passing
-// through the request body.
-//
-// 256k is a judgement, not a proof. A search request measured live on
-// 2026-08-07 reported 40,260 input tokens after eleven server-side calls,
-// so this is roughly six times the observed case; the model's 1M context
-// is the only true bound, and reserving 1M would price a single search at
-// more than half a day's budget and make the feature unofferable.
-//
-// The honest statement of the trade: within this allowance the budget is
-// still a hard ceiling, and beyond it a search request can overshoot by
-// the difference. Two things keep that survivable — the per-subject
-// in-flight cap means one caller cannot stack such requests, and searches
-// are rationed per user per day, so the overshoot is bounded by the few
-// distinct callers who can be mid-search at the same moment.
-const searchInputAllowance = 256 << 10
-
// rawUsage is permissive on purpose: it decodes the usage object of every
// format at once, using pointers so "absent" and "zero" stay distinct.
// Which fields are present is what identifies the format.
diff --git a/gateway/internal/meter/meter_test.go b/gateway/internal/meter/meter_test.go
index bf5a0ae..0c0a2bf 100644
--- a/gateway/internal/meter/meter_test.go
+++ b/gateway/internal/meter/meter_test.go
@@ -184,7 +184,7 @@ func TestEstimateExceedsATypicalRealCharge(t *testing.T) {
const body = 4000
const maxTokens = 4096
- est := Estimate("deepseek-v4-flash", body, maxTokens, false)
+ est := Estimate("deepseek-v4-flash", body, maxTokens)
real := Cost("deepseek-v4-flash", Usage{InputTokens: body / 3, OutputTokens: 800, Found: true})
if est <= real {
t.Errorf("estimate %v is not above a realistic charge %v; unbillable would be cheaper than billable", est, real)
@@ -220,13 +220,13 @@ func TestEstimateCeilingCoversTheNextPeriod(t *testing.T) {
const model = "deepseek-v4-flash"
justBeforePeak := time.Date(2026, 8, 17, 5, 30, 0, 0, time.UTC)
insidePeak := time.Date(2026, 8, 17, 6, 5, 0, 0, time.UTC)
- if est, peak := EstimateAt(model, 400, 1000, false, justBeforePeak), EstimateAt(model, 400, 1000, false, insidePeak); est < peak {
+ if est, peak := EstimateAt(model, 400, 1000, justBeforePeak), EstimateAt(model, 400, 1000, insidePeak); est < peak {
t.Errorf("admitted at 05:30 UTC the reservation %v is under the peak-priced %v it could settle at", est, peak)
}
justBeforeFlip := RepriceAt.Add(-10 * time.Minute)
- afterFlip := EstimateAt(model, 400, 1000, false, RepriceAt)
- if est := EstimateAt(model, 400, 1000, false, justBeforeFlip); est < afterFlip {
+ afterFlip := EstimateAt(model, 400, 1000, RepriceAt)
+ if est := EstimateAt(model, 400, 1000, justBeforeFlip); est < afterFlip {
t.Errorf("admitted before the flip the reservation %v is under the post-flip %v", est, afterFlip)
}
@@ -234,7 +234,7 @@ func TestEstimateCeilingCoversTheNextPeriod(t *testing.T) {
// not at a permanent doubling.
quiet := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC)
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 {
+ if est := EstimateAt(model, 400, 1000, quiet); math.Abs(est-offPeak) > 1e-12 {
t.Errorf("quiet off-peak reservation %v, want the off-peak card's %v", est, offPeak)
}
}
@@ -274,23 +274,3 @@ func itoa(n int) string {
}
return string(b)
}
-
-// A search request's input is chosen by DeepSeek, not by the caller: the
-// pages it reads are billed as input tokens that never passed through the
-// body. So the reservation cannot be derived from the body alone, and a
-// search must hold materially more than the same bytes without one.
-func TestSearchReservesBeyondTheBody(t *testing.T) {
- const model = "deepseek-v4-flash"
- plain := Estimate(model, 400, 1000, false)
- search := Estimate(model, 400, 1000, true)
-
- if search <= plain {
- t.Fatalf("a search reserved %v, no more than the %v an ordinary request holds", search, plain)
- }
- // The measured case on 2026-08-07 was 40k input tokens; the reservation
- // has to cover that with room, or the ceiling leaks on every search.
- measured := Cost(model, Usage{InputTokens: 40_260, CacheHitTokens: 32_000, OutputTokens: 3_100})
- if search < measured {
- t.Errorf("reservation %v is under the %v a real measured search cost", search, measured)
- }
-}
diff --git a/gateway/internal/meter/weekend_test.go b/gateway/internal/meter/weekend_test.go
index 88dcd39..936f83a 100644
--- a/gateway/internal/meter/weekend_test.go
+++ b/gateway/internal/meter/weekend_test.go
@@ -71,19 +71,21 @@ func TestEveryFlashNameIsMeteredAsFlash(t *testing.T) {
}
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)
+ // 04:00 UTC Thursday: the card changes as the 01:00-04:00 peak window
+ // ends, so the minute before is the V4 card at peak.
+ before := v41At.Add(-time.Minute)
+ if got := PriceAt("deepseek-flash", before); got != (Price{CacheHitInput: 0.014, CacheMissInput: 0.44, Output: 1.32}) {
+ t.Errorf("flash just before V4.1 = %+v, want the V4 card at peak", 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)
+ if a, b := PriceAt("deepseek-v4-pro", before), scale(PriceAt("deepseek-v4-pro", v41At), peakMultiplier); a != b {
+ t.Errorf("pro moved at V4.1: %+v at peak, want its unchanged card doubled, %+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 {
+ if est, after := EstimateAt("deepseek-flash", 400, 1000, before), EstimateAt("deepseek-flash", 400, 1000, v41At); est < after {
t.Errorf("admitted before the cut the reservation %v is under the post-cut %v", est, after)
}
}
diff --git a/gateway/internal/policy/policy.go b/gateway/internal/policy/policy.go
index 4d770dc..582c435 100644
--- a/gateway/internal/policy/policy.go
+++ b/gateway/internal/policy/policy.go
@@ -88,12 +88,6 @@ type Decision struct {
// estimate if the response turns out to be unmeterable.
MaxTokens int
Stream bool
- // Search is set when the request asks for DeepSeek's server-side web
- // search. It travels because such a request costs a multiple of an
- // ordinary one: the server injects the pages it read as input tokens,
- // so neither the body's size nor MaxTokens predicts the bill. The
- // reservation and the per-user ration both key off this.
- Search bool
}
// Reject is a request refused before it cost anything.
@@ -137,7 +131,7 @@ func Apply(route Route, body []byte, subject string, lim Limits) (*Decision, err
if err := forbidFanOut(obj); err != nil {
return nil, err
}
- if err := checkServerTools(obj, route.Format, d); err != nil {
+ if err := checkServerTools(obj, route.Format); err != nil {
return nil, err
}
setIdentity(obj, route.Format, subject)
@@ -193,31 +187,46 @@ func decodeObject(body []byte) (map[string]any, error) {
// It refuses rather than silently downgrading. A user who asked for pro
// and got flash without being told would compare the answer against pro's
// reputation and conclude the model is worse than it is.
+//
+// Both sides are resolved, so a retired flash name on either end — a
+// client still asking for deepseek-v4-flash, or an operator whose
+// DSGATE_MODEL predates the rename — means the one model upstream serves
+// under all of those names.
func checkModel(obj map[string]any, free string) error {
asked, _ := obj["model"].(string)
- if asked == "" || asked == free {
- return nil
- }
- if resolve(asked) == free {
- // A Claude name the Anthropic endpoint maps onto flash anyway.
+ if asked == "" || resolve(asked) == resolve(free) {
return nil
}
return &Reject{
- Message: fmt.Sprintf("the free tier serves %s only, not %q", free, asked),
- Hint: "bring your own key for " + strings.TrimSpace(strings.Replace(asked, free, "", 1)) + ": https://platform.deepseek.com/api_keys",
+ Message: fmt.Sprintf("the free tier serves %s only, not %q", resolve(free), asked),
+ Hint: "bring your own key for " + asked + ": https://platform.deepseek.com/api_keys",
}
}
-// resolve mirrors the CLI's model resolution: the Anthropic endpoint
-// accepts Claude names and remaps them server-side.
+// Canonical is the name upstream serves a model under today. The gateway
+// uses it wherever it compares against what upstream reports — the
+// /models list only carries the new names.
+func Canonical(model string) string { return resolve(model) }
+
+// resolve mirrors upstream's model resolution, as the Anthropic API guide
+// states it (read 2026-09-18): claude-opus* runs as deepseek-v4-pro, and
+// every other name the endpoint does not know — claude-sonnet*,
+// claude-haiku*, anything else — runs as deepseek-flash. The two names
+// retired on 2026-09-10 are served and billed as deepseek-flash on every
+// format.
+//
+// The OpenAI formats reject an unknown name upstream instead of mapping
+// it; the gateway never forwards one, because Apply pins the model.
func resolve(model string) string {
switch {
- case model == "deepseek-v4-flash" || model == "deepseek-v4-pro":
+ case model == "deepseek-flash" || model == "deepseek-v4-pro":
return model
+ case model == "deepseek-v4-flash" || model == "deepseek-v4-flash-vision-exp":
+ return "deepseek-flash"
case strings.HasPrefix(model, "claude-opus"):
return "deepseek-v4-pro"
default:
- return "deepseek-v4-flash"
+ return "deepseek-flash"
}
}
@@ -261,27 +270,22 @@ func forbidFanOut(obj map[string]any) error {
// checkServerTools decides which tools that run on DeepSeek's side the
// free tier will carry. Client tools ("function") only declare a schema
-// and cost nothing extra. Only the Responses format offers server-side
-// ones at all.
+// and cost nothing extra. Only the Responses format ever offered
+// server-side ones.
//
-// web_search is allowed, and the reason is a measurement rather than a
-// guess. Against the live API on 2026-08-07, one search request made 11
-// server-side calls (searches, page opens, an in-page find) and reported
-// 40,260 input tokens, 32,000 of them cache hits — and the account
-// balance moved by nothing beyond those tokens. So DeepSeek charges no
-// per-search fee: the whole cost of a search arrives as input tokens in
-// the usage object, which is exactly what this gateway already meters.
-// Eleven searches at a frontier vendor's $10-per-1,000 rate would have
-// been $0.11 and unmistakable in the balance; it was not there.
-//
-// What that measurement does change is the reservation. A search
-// request's input is chosen by the server, not by the caller, so the
-// request body no longer bounds it — see meter.Estimate.
+// None are carried. web_search was, from 2026-08-07, and was rationed
+// because one search billed ~40K input tokens. DeepSeek removed it from
+// the Responses API with V4.1 Flash on 2026-09-10: the guide now lists
+// web_search among the built-in tools that are ignored, and a probe on
+// 2026-09-18 billed 21 input tokens where a search used to bill ~40K. A
+// request carrying it is refused with that reason rather than forwarded,
+// because forwarding it would answer from the model's memory while the
+// caller believes it searched.
//
// Every other server-side tool stays refused: an unknown tool is unknown
// work at an unknown price, and the honest default for spending someone
// else's donated credit is no.
-func checkServerTools(obj map[string]any, f Format, d *Decision) error {
+func checkServerTools(obj map[string]any, f Format) error {
if f != FormatResponses {
return nil
}
@@ -292,19 +296,25 @@ func checkServerTools(obj map[string]any, f Format, d *Decision) error {
switch {
case kind == "" || kind == "function":
case isWebSearch(kind):
- d.Search = true
+ return &Reject{
+ Message: webSearchRemoved,
+ Hint: "drop the web_search tool; to ground an answer, search on your side and put the results in the prompt",
+ }
default:
return &Reject{
Message: fmt.Sprintf("the free tier does not serve server-side tools (%q)", kind),
- Hint: "web_search works here; for anything else bring your own key: https://platform.deepseek.com/api_keys",
+ Hint: "declare function tools and run them yourself, or bring your own key: https://platform.deepseek.com/api_keys",
}
}
}
return nil
}
-// isWebSearch matches the tool DeepSeek documents under two names, the
-// bare one and the dated one their Responses API also accepts.
+// webSearchRemoved is the refusal for a web_search tool.
+const webSearchRemoved = "DeepSeek removed server-side web_search from the Responses API on 2026-09-10; it would be ignored, not run"
+
+// isWebSearch matches the tool under both names DeepSeek documented, the
+// bare one and the dated one.
func isWebSearch(kind string) bool {
return kind == "web_search" || strings.HasPrefix(kind, "web_search_")
}
diff --git a/gateway/internal/policy/policy_test.go b/gateway/internal/policy/policy_test.go
index e120444..40b7356 100644
--- a/gateway/internal/policy/policy_test.go
+++ b/gateway/internal/policy/policy_test.go
@@ -7,7 +7,7 @@ import (
"testing"
)
-func limits() Limits { return Limits{MaxTokens: 4096, Model: "deepseek-v4-flash"} }
+func limits() Limits { return Limits{MaxTokens: 4096, Model: "deepseek-flash"} }
func apply(t *testing.T, routeKey, body string) map[string]any {
t.Helper()
@@ -90,17 +90,19 @@ func TestProIsRefusedNotDowngraded(t *testing.T) {
func TestModelIsPinnedEvenWhenAbsent(t *testing.T) {
got := apply(t, "POST /chat/completions", `{"messages":[]}`)
- if got["model"] != "deepseek-v4-flash" {
+ if got["model"] != "deepseek-flash" {
t.Errorf("model = %v, want the free model pinned in", got["model"])
}
}
-// Claude names are remapped server-side by the Anthropic endpoint. The
-// ones that land on flash have to keep working, or `deepseek anthropic`
-// breaks against the free tier for no reason.
+// Claude names are remapped server-side by the Anthropic endpoint, exactly
+// as the Anthropic API guide states it: claude-opus* is pro, claude-haiku*,
+// claude-sonnet* and any unknown name are deepseek-flash. The ones that land
+// on flash have to keep working, or `deepseek anthropic` breaks against the
+// free tier for no reason.
func TestClaudeNamesThatMapToFlashAreAllowed(t *testing.T) {
route, _ := Lookup("POST", "/anthropic/v1/messages")
- for _, model := range []string{"claude-sonnet-4-5", "claude-haiku-4-5"} {
+ for _, model := range []string{"claude-sonnet-4-5", "claude-haiku-4-5", "claude-fable-5"} {
if _, err := Apply(route, []byte(`{"model":"`+model+`","messages":[]}`), "S", limits()); err != nil {
t.Errorf("%s was refused: %v", model, err)
}
@@ -110,6 +112,37 @@ func TestClaudeNamesThatMapToFlashAreAllowed(t *testing.T) {
}
}
+// The names retired on 2026-09-10 are the same model upstream, served and
+// billed as deepseek-flash. A client that still asks for one must be served,
+// and an operator whose DSGATE_MODEL predates the rename must not start
+// refusing the new name the CLI sends by default.
+func TestRetiredFlashNamesAreTheSameModel(t *testing.T) {
+ route, _ := Lookup("POST", "/chat/completions")
+ for _, free := range []string{"deepseek-flash", "deepseek-v4-flash"} {
+ lim := Limits{MaxTokens: 4096, Model: free}
+ for _, asked := range []string{"deepseek-flash", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp"} {
+ if _, err := Apply(route, []byte(`{"model":"`+asked+`","messages":[]}`), "S", lim); err != nil {
+ t.Errorf("serving %s, a request for %s was refused: %v", free, asked, err)
+ }
+ }
+ if _, err := Apply(route, []byte(`{"model":"deepseek-v4-pro","messages":[]}`), "S", lim); err == nil {
+ t.Errorf("serving %s, a request for pro was accepted", free)
+ }
+ }
+ for in, want := range map[string]string{
+ "deepseek-flash": "deepseek-flash",
+ "deepseek-v4-flash": "deepseek-flash",
+ "deepseek-v4-flash-vision-exp": "deepseek-flash",
+ "deepseek-v4-pro": "deepseek-v4-pro",
+ "claude-opus-4-8": "deepseek-v4-pro",
+ "claude-sonnet-5": "deepseek-flash",
+ } {
+ if got := Canonical(in); got != want {
+ t.Errorf("Canonical(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
func TestOutputCapIsClamped(t *testing.T) {
cases := []struct {
name, route, body, field string
@@ -276,39 +309,37 @@ func asReject(err error, target **Reject) bool {
return ok
}
-// web_search is carried, because measurement showed its whole cost
-// arrives as input tokens this gateway already meters. Every other
-// server-side tool is unknown work at an unknown price and stays refused.
-// Client function tools only declare a schema and were never in question.
+// No server-side tool is carried. web_search is refused with the reason:
+// DeepSeek removed it on 2026-09-10 and now ignores it, so forwarding it
+// would answer from memory while the caller believes it searched. Every
+// other server-side tool is unknown work at an unknown price. Client
+// function tools only declare a schema and were never in question.
func TestServerSideToolsAreRefused(t *testing.T) {
route, _ := Lookup("POST", "/responses")
- lim := Limits{MaxTokens: 100, Model: "deepseek-v4-flash"}
+ lim := Limits{MaxTokens: 100, Model: "deepseek-flash"}
for _, kind := range []string{"web_search", "web_search_2025_08_26"} {
- d, err := Apply(route, []byte(`{"input":"hi","tools":[{"type":"`+kind+`"}]}`), "sub", lim)
- if err != nil {
- t.Fatalf("%s was refused: %v", kind, err)
+ var rej *Reject
+ _, err := Apply(route, []byte(`{"input":"hi","tools":[{"type":"`+kind+`"}]}`), "sub", lim)
+ if !asReject(err, &rej) {
+ t.Fatalf("%s passed policy: %v", kind, err)
}
- if !d.Search {
- t.Errorf("%s did not set Decision.Search, so it would be reserved and rationed as an ordinary request", kind)
+ if !strings.Contains(rej.Message, "removed") || !strings.Contains(rej.Message, "2026-09-10") {
+ t.Errorf("%s refusal does not say it was removed upstream, and when: %q", kind, rej.Message)
}
}
- // An unknown server-side tool is still a refusal, and the message has
- // to point at the one that does work rather than only at the exit.
var rej *Reject
_, err := Apply(route, []byte(`{"input":"hi","tools":[{"type":"code_interpreter"}]}`), "sub", lim)
if !asReject(err, &rej) {
t.Fatalf("an unknown server-side tool passed policy: %v", err)
}
- if !strings.Contains(rej.Hint, "web_search") {
- t.Errorf("the refusal does not mention the tool that works: %q", rej.Hint)
+ if strings.Contains(rej.Hint, "web_search") {
+ t.Errorf("the refusal still points at web_search: %q", rej.Hint)
}
- if d, err := Apply(route, []byte(`{"input":"hi","tools":[{"type":"function","name":"f"}]}`), "sub", lim); err != nil {
+ if _, err := Apply(route, []byte(`{"input":"hi","tools":[{"type":"function","name":"f"}]}`), "sub", lim); err != nil {
t.Errorf("a client function tool was refused: %v", err)
- } else if d.Search {
- t.Error("a function tool was counted as a search")
}
// The other formats have no server-side tools; their tools stay open.
diff --git a/gateway/internal/quota/quota.go b/gateway/internal/quota/quota.go
index 126f954..9e9fb67 100644
--- a/gateway/internal/quota/quota.go
+++ b/gateway/internal/quota/quota.go
@@ -31,7 +31,6 @@ type Limits struct {
DailyRequests int
DailyInputTokens int
DailyOutputTokens int
- DailySearches int
// DailyBudgetUSD is the circuit breaker: the total this service may
// spend across all users in one UTC day. This is the number that
@@ -49,7 +48,6 @@ type Account struct {
Requests int `json:"requests"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
- Searches int `json:"searches"`
SpentUSD float64 `json:"spent_usd"`
}
@@ -73,22 +71,15 @@ type UserCaps struct {
Requests int `json:"requests"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
- // Searches rations requests that use DeepSeek's server-side web
- // search. It exists because such a request costs roughly ten times an
- // ordinary turn — the pages it reads are billed as input tokens — so
- // the request count alone would let one caller take a large share of
- // the day's budget while looking like a normal user.
- Searches int `json:"searches"`
}
// Admission is what a request asks the ledger for before it is forwarded.
-// It is a struct rather than another positional argument because the two
-// fields answer different questions — how much money to hold, and which
-// per-user ration to spend — and a bare `true` at a call site would say
+// It is a struct rather than positional arguments because the two fields
+// answer different questions — how much money to hold, and whether the
+// money ceilings apply at all — and a bare `true` at a call site would say
// neither.
type Admission struct {
ReserveUSD float64
- Search bool
// Free means this request is bound to an upstream that costs the
// credit pool nothing, and may not fall back to one that does. It is
// the only thing that passes the service-wide money ceilings — which
@@ -107,7 +98,6 @@ const (
ReasonRequests Reason = "daily_requests"
ReasonInputTokens Reason = "daily_input_tokens"
ReasonOutputTokens Reason = "daily_output_tokens"
- ReasonSearches Reason = "daily_searches"
ReasonDailyBudget Reason = "daily_budget"
ReasonCredits Reason = "credits_exhausted"
ReasonRevoked Reason = "revoked"
@@ -142,8 +132,6 @@ func (e *LimitError) Error() string {
return "you have used today's input-token allowance"
case ReasonOutputTokens:
return "you have used today's output-token allowance"
- case ReasonSearches:
- return "you have used today's web-search allowance"
default:
return fmt.Sprintf("daily %s limit reached", string(e.Reason))
}
@@ -585,17 +573,9 @@ func (l *Ledger) Admit(subject string, req Admission) error {
return &LimitError{Reason: ReasonInputTokens, ResetsAt: reset}
case a.OutputTokens >= l.limits.DailyOutputTokens:
return &LimitError{Reason: ReasonOutputTokens, ResetsAt: reset}
- case req.Search && a.Searches >= l.limits.DailySearches:
- return &LimitError{Reason: ReasonSearches, ResetsAt: reset}
}
a.Requests++
- if req.Search {
- // Counted at admission rather than at settlement, because the
- // ration has to bind before the money is spent: a search that
- // failed still cost us the pages DeepSeek read.
- a.Searches++
- }
l.reserved += reserveUSD
return nil
}
@@ -613,9 +593,6 @@ func (l *Ledger) Refund(subject string, req Admission) {
if a.Requests > 0 {
a.Requests--
}
- if req.Search && a.Searches > 0 {
- a.Searches--
- }
}
l.releaseLocked(req.ReserveUSD)
}
@@ -697,7 +674,6 @@ func (l *Ledger) Status(subject, tier string) Status {
Requests: l.limits.DailyRequests,
InputTokens: l.limits.DailyInputTokens,
OutputTokens: l.limits.DailyOutputTokens,
- Searches: l.limits.DailySearches,
},
ResetsAt: midnight(l.now()),
Exhausted: l.priorSpend+l.daySpend >= l.limits.TotalBudgetUSD,
diff --git a/gateway/internal/quota/quota_test.go b/gateway/internal/quota/quota_test.go
index 11c4b04..082a5e3 100644
--- a/gateway/internal/quota/quota_test.go
+++ b/gateway/internal/quota/quota_test.go
@@ -435,54 +435,6 @@ func TestReplaySkipsACorruptLine(t *testing.T) {
}
}
-// A search costs roughly ten times an ordinary turn, so it has its own
-// ration. Two properties matter and neither is obvious: running out of
-// searches must not touch the rest of the tier, and a search that never
-// reached the model must give the ration back.
-func TestSearchesAreRationedWithoutBlockingOrdinaryRequests(t *testing.T) {
- lim := testLimits()
- lim.DailyRequests = 100
- lim.DailySearches = 2
- l, done := open(t, t.TempDir(), lim)
- defer done()
-
- for i := 0; i < 2; i++ {
- if err := l.Admit("alice", Admission{Search: true}); err != nil {
- t.Fatalf("search %d refused: %v", i+1, err)
- }
- }
-
- err := l.Admit("alice", Admission{Search: true})
- if err == nil {
- t.Fatal("a third search was admitted against a ration of two")
- }
- if got := reasonOf(t, err); got != ReasonSearches {
- t.Errorf("reason = %q, want %q", got, ReasonSearches)
- }
-
- // The point of a separate ration: everything else still works.
- if err := l.Admit("alice", Admission{}); err != nil {
- t.Errorf("an ordinary request was refused because searches ran out: %v", err)
- }
-
- // And a refunded search is not a spent one.
- l.Refund("alice", Admission{Search: true})
- if err := l.Admit("alice", Admission{Search: true}); err != nil {
- t.Errorf("a refunded search ration was not returned: %v", err)
- }
-}
-
-func TestSearchRationIsPublishedWithTheOtherLimits(t *testing.T) {
- lim := testLimits()
- lim.DailySearches = 3
- l, done := open(t, t.TempDir(), lim)
- defer done()
-
- if got := l.Status("alice", "anon").Limits.Searches; got != 3 {
- t.Errorf("published search limit = %d, want 3 — a limit a caller cannot read is one they can only discover by hitting it", got)
- }
-}
-
// A free request passes the money ceilings and nothing else. This is the
// whole security argument for Admission.Free: it is sound only because
// such a request cannot reach an upstream that charges.
diff --git a/gateway/internal/server/claims_test.go b/gateway/internal/server/claims_test.go
new file mode 100644
index 0000000..98d79a3
--- /dev/null
+++ b/gateway/internal/server/claims_test.go
@@ -0,0 +1,96 @@
+package server
+
+import (
+ "io/fs"
+ "os"
+ "regexp"
+ "strings"
+ "testing"
+
+ "github.com/thevibeworks/deepseek-cli/gateway/internal/policy"
+)
+
+// The pages this gateway serves about itself name the model it serves
+// and say what it carries. Both are decided in code, and on 2026-09-10
+// both changed upstream while every test stayed green: the model got a
+// new name, and web_search stopped running. These pin the copy to the
+// code so the next such change turns this red instead of the page stale.
+
+// defaultModel is DSGATE_MODEL's default, read from the binary's source
+// so the test and the flag cannot disagree.
+func defaultModel(t *testing.T) string {
+ t.Helper()
+ src, err := os.ReadFile("../../cmd/dsgate/main.go")
+ if err != nil {
+ t.Fatal(err)
+ }
+ m := regexp.MustCompile(`env\("DSGATE_MODEL", "([^"]+)"\)`).FindSubmatch(src)
+ if m == nil {
+ t.Fatal(`main.go no longer reads env("DSGATE_MODEL", "...") — update this test with it`)
+ }
+ return string(m[1])
+}
+
+func TestDefaultModelIsTheNameUpstreamServes(t *testing.T) {
+ model := defaultModel(t)
+ if canon := policy.Canonical(model); canon != model {
+ t.Errorf("DSGATE_MODEL defaults to %s, a retired name; upstream serves it as %s", model, canon)
+ }
+}
+
+// Every place a page names the served model, it names the default. The
+// patterns are the phrasings the pages use to say "this is what runs
+// here"; a retired name kept as history does not match them.
+func TestPagesNameTheServedModel(t *testing.T) {
+ model := defaultModel(t)
+ claims := map[string][]*regexp.Regexp{
+ "index.html": {
+ regexp.MustCompile(`gateway to (deepseek-[a-z0-9.-]+)`),
+ regexp.MustCompile(`(deepseek-[a-z0-9.-]+)`),
+ },
+ "pages/terms.html": {
+ regexp.MustCompile(`the (deepseek-[a-z0-9.-]+) model only`),
+ },
+ "pages/economics.html": {
+ regexp.MustCompile(`runs on (deepseek-[a-z0-9.-]+)`),
+ },
+ }
+ for page, res := range claims {
+ body, err := fs.ReadFile(webFS, "web/"+page)
+ if err != nil {
+ t.Fatalf("%s: %v", page, err)
+ }
+ n := 0
+ for _, re := range res {
+ for _, m := range re.FindAllStringSubmatch(string(body), -1) {
+ n++
+ if m[1] != model {
+ t.Errorf("%s names %s as the served model; the gateway serves %s", page, m[1], model)
+ }
+ }
+ }
+ if n == 0 {
+ t.Errorf("%s no longer names the served model in a form this test reads", page)
+ }
+ }
+}
+
+// DeepSeek removed web_search on 2026-09-10 and the gateway refuses it.
+// A page may say so, and only so: every paragraph or list item that
+// mentions web search has to say it was removed.
+func TestNoPagePromisesWebSearch(t *testing.T) {
+ blocks := regexp.MustCompile(`(?s)<(p|li|td)\b.*?(p|li|td)>`)
+ fs.WalkDir(webFS, "web", func(path string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() || !strings.HasSuffix(path, ".html") {
+ return err
+ }
+ body, _ := fs.ReadFile(webFS, path)
+ for _, b := range blocks.FindAllString(string(body), -1) {
+ low := strings.ToLower(b)
+ if (strings.Contains(low, "web_search") || strings.Contains(low, "web search")) && !strings.Contains(low, "removed") {
+ t.Errorf("%s promises web search, which DeepSeek removed on 2026-09-10: %.160s", path, b)
+ }
+ }
+ return nil
+ })
+}
diff --git a/gateway/internal/server/freelane_test.go b/gateway/internal/server/freelane_test.go
index b1731ed..ca4b411 100644
--- a/gateway/internal/server/freelane_test.go
+++ b/gateway/internal/server/freelane_test.go
@@ -32,7 +32,7 @@ func withFree(free *upstream) func(*Config, *quota.Limits) {
}
func chatBody() string {
- return `{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"hi"}]}`
+ return `{"model":"deepseek-flash","messages":[{"role":"user","content":"hi"}]}`
}
// The whole point: a chat request the free lane accepts never reaches the
@@ -62,7 +62,7 @@ func TestFreeLaneServesChatForNothing(t *testing.T) {
if got.Headers.Get("Authorization") != "Bearer "+freeKey {
t.Error("the free lane was not authenticated with its own key")
}
- // The client asked for deepseek-v4-flash and the allowlist approved
+ // The client asked for deepseek-flash and the allowlist approved
// that name; only the lane knows the alias.
if m, _ := got.Body["model"].(string); m != "deepseek-v4-flash-free" {
t.Errorf("model reaching the free upstream = %q, want the alias", m)
@@ -104,7 +104,7 @@ func TestFreeLaneFallsBackOnRefusal(t *testing.T) {
if paid.count() != 1 {
t.Fatalf("paid upstream saw %d requests, want 1", paid.count())
}
- if m, _ := paid.last(t).Body["model"].(string); m != "deepseek-v4-flash" {
+ if m, _ := paid.last(t).Body["model"].(string); m != "deepseek-flash" {
t.Errorf("model reaching the paid upstream = %q; the alias leaked", m)
}
if h.ledger.Today().SpentUSD <= 0 {
@@ -120,7 +120,7 @@ func TestFreeLaneOnlyCarriesChat(t *testing.T) {
cases := []struct{ name, path, body string }{
{"anthropic", "/v1/anthropic/v1/messages", `{"model":"deepseek-v4-flash","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}`},
{"fim", "/v1/completions", `{"model":"deepseek-v4-flash","prompt":"def f():"}`},
- {"search", "/v1/responses", `{"model":"deepseek-v4-flash","input":"hi","tools":[{"type":"web_search"}]}`},
+ {"responses", "/v1/responses", `{"model":"deepseek-flash","input":"hi"}`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
diff --git a/gateway/internal/server/interop_test.go b/gateway/internal/server/interop_test.go
index 948eac9..268e129 100644
--- a/gateway/internal/server/interop_test.go
+++ b/gateway/internal/server/interop_test.go
@@ -111,7 +111,7 @@ func TestTheCLIEnrolsAndChatsThroughTheGateway(t *testing.T) {
t.Errorf("enrolment did not report success: %s", stderr)
}
// The disclosure is the consent. It has to actually be printed.
- for _, want := range []string{"relays your prompts", "deepseek-v4-flash", "per day"} {
+ for _, want := range []string{"relays your prompts", "deepseek-flash", "per day"} {
if !strings.Contains(stderr, want) {
t.Errorf("enrolment output is missing %q:\n%s", want, stderr)
}
diff --git a/gateway/internal/server/proxy.go b/gateway/internal/server/proxy.go
index 69d0164..1055e8e 100644
--- a/gateway/internal/server/proxy.go
+++ b/gateway/internal/server/proxy.go
@@ -116,7 +116,7 @@ func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) {
// `deepseek status` free and safe in a loop against the free tier,
// exactly as it is against the real API.
billable := route.Format != policy.FormatNone
- adm := quota.Admission{Search: decision.Search}
+ adm := quota.Admission{}
if billable {
// Whether this particular request could be carried for nothing.
// It decides two things below: whether an empty credit pool is
@@ -140,7 +140,7 @@ func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) {
// reservation, every in-flight request is unbilled and the breaker
// only notices after the money is spent.
if !adm.Free {
- adm.ReserveUSD = meter.Estimate(decision.Model, len(decision.Body), decision.MaxTokens, decision.Search)
+ adm.ReserveUSD = meter.Estimate(decision.Model, len(decision.Body), decision.MaxTokens)
}
if err := s.ledger.Admit(subject, adm); err != nil {
// Out of money, but this request need not cost any: retry the
@@ -151,7 +151,7 @@ func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) {
s.writeLimit(w, err)
return
}
- adm = quota.Admission{Search: decision.Search, Free: true}
+ adm = quota.Admission{Free: true}
if err := s.ledger.Admit(subject, adm); err != nil {
s.writeLimit(w, err)
return
@@ -672,12 +672,6 @@ func (s *Server) writeLimit(w http.ResponseWriter, err error) {
w.Header().Set("Retry-After", "30")
writeError(w, http.StatusServiceUnavailable, typeInternal,
"the free tier is temporarily unavailable; retry shortly")
- case quota.ReasonSearches:
- // A distinct message because the fix is distinct: the rest of the
- // tier still works, so "come back tomorrow" would be wrong.
- retryAfter(w, lim.RetryAfter(time.Now()))
- writeError(w, http.StatusTooManyRequests, typeQuota,
- "you have used today's web-search allowance. Ordinary requests still work — searches reset at 00:00 UTC, or bring your own key for unlimited search: https://platform.deepseek.com/api_keys")
case quota.ReasonDailyBudget:
retryAfter(w, lim.RetryAfter(time.Now()))
writeError(w, http.StatusTooManyRequests, typeQuota,
diff --git a/gateway/internal/server/server.go b/gateway/internal/server/server.go
index 45f7c6c..b2fbe04 100644
--- a/gateway/internal/server/server.go
+++ b/gateway/internal/server/server.go
@@ -166,6 +166,15 @@ type Server struct {
}
func New(cfg Config, signer *token.Signer, m *mint.Mint, ledger *quota.Ledger) *Server {
+ // Serve the model under the name upstream uses today. A DSGATE_MODEL
+ // written before 2026-09-10 says deepseek-v4-flash, which upstream
+ // still answers but no longer lists — so without this the /models
+ // filter would keep nothing, and the status page would advertise a
+ // retired name.
+ if canon := policy.Canonical(cfg.Model); canon != cfg.Model {
+ log.Printf("model %s is served upstream as %s; using that name", cfg.Model, canon)
+ cfg.Model = canon
+ }
origins := map[string]bool{}
for _, o := range cfg.Origins {
origins[strings.TrimSuffix(strings.TrimSpace(o), "/")] = true
@@ -263,15 +272,15 @@ func (l *lane) label() string {
// The free lane is deliberately narrow. Measured against OpenCode Zen on
// 2026-08-12: /chat/completions works and reports usage in both streamed
// and buffered form; /anthropic/v1/messages, /beta/completions and
-// /user/balance are 404; /responses answers, but rejects a server-side
-// web_search tool outright. So chat is the one route it is trusted with,
+// /user/balance are 404; /responses is a translation layer we have not
+// measured beyond that. So chat is the one route it is trusted with,
// which is also where nearly all of the volume is. Everything else goes
// to DeepSeek, exactly as before.
func (l *lane) serves(route policy.Route, d *policy.Decision) bool {
if !l.free {
return true
}
- return route.Name == "chat" && !d.Search
+ return route.Name == "chat"
}
// lanesFor is the order to try upstreams in for one request.
diff --git a/gateway/internal/server/server_test.go b/gateway/internal/server/server_test.go
index 1e8c585..c85d9ad 100644
--- a/gateway/internal/server/server_test.go
+++ b/gateway/internal/server/server_test.go
@@ -89,14 +89,13 @@ func newHarness(t *testing.T, up *upstream, tune func(*Config, *quota.Limits)) *
DailyRequests: 5,
DailyInputTokens: 10000,
DailyOutputTokens: 5000,
- DailySearches: 2,
DailyBudgetUSD: 1,
TotalBudgetUSD: 10,
}
cfg := Config{
UpstreamBaseURL: up.server.URL,
UpstreamKeys: []string{upstreamKey},
- Model: "deepseek-v4-flash",
+ Model: "deepseek-flash",
MaxBodyBytes: 4096,
MaxTokens: 256,
MaxInflight: 4,
@@ -516,7 +515,7 @@ func TestProIsRefused(t *testing.T) {
// health check eat the day's allowance.
func TestModelsIsNotCharged(t *testing.T) {
up := newUpstream(t, func(w http.ResponseWriter, r *http.Request) {
- io.WriteString(w, `{"object":"list","data":[{"id":"deepseek-v4-flash"}]}`)
+ io.WriteString(w, `{"object":"list","data":[{"id":"deepseek-flash"}]}`)
})
h := newHarness(t, up, nil)
tok := h.enrol(t)
@@ -537,29 +536,34 @@ func TestModelsIsNotCharged(t *testing.T) {
// /models answers "what can I use here". Through the free tier that is
// one model, and any client picking off an unfiltered list would have
// even odds of choosing the one that is then refused.
+//
+// Upstream has listed only deepseek-flash and deepseek-v4-pro since
+// 2026-09-10. An operator whose DSGATE_MODEL still says the retired
+// deepseek-v4-flash must get the model it now is, not an empty list.
func TestModelsListsOnlyWhatIsServed(t *testing.T) {
- up := newUpstream(t, func(w http.ResponseWriter, r *http.Request) {
- io.WriteString(w, `{"object":"list","data":[
- {"id":"deepseek-v4-flash","object":"model","owned_by":"deepseek"},
- {"id":"deepseek-v4-pro","object":"model","owned_by":"deepseek"}]}`)
- })
- h := newHarness(t, up, nil)
- tok := h.enrol(t)
-
- resp := h.do(t, "GET", "/models", tok, "")
- defer resp.Body.Close()
- raw, _ := io.ReadAll(resp.Body)
-
- if strings.Contains(string(raw), "deepseek-v4-pro") {
- t.Errorf("the free tier advertised a model it refuses to serve: %s", raw)
- }
- if !strings.Contains(string(raw), "deepseek-v4-flash") {
- t.Errorf("the served model was filtered out too: %s", raw)
- }
- // Still a real upstream call, so `deepseek status` keeps answering
- // whether DeepSeek itself is reachable.
- if up.count() != 1 {
- t.Errorf("upstream saw %d model requests, want 1", up.count())
+ for _, configured := range []string{"deepseek-flash", "deepseek-v4-flash"} {
+ t.Run(configured, func(t *testing.T) {
+ up := newUpstream(t, func(w http.ResponseWriter, r *http.Request) {
+ io.WriteString(w, `{"object":"list","data":[
+ {"id":"deepseek-flash","object":"model","owned_by":"deepseek"},
+ {"id":"deepseek-v4-pro","object":"model","owned_by":"deepseek"}]}`)
+ })
+ h := newHarness(t, up, func(c *Config, _ *quota.Limits) { c.Model = configured })
+ tok := h.enrol(t)
+
+ resp := h.do(t, "GET", "/models", tok, "")
+ defer resp.Body.Close()
+ raw, _ := io.ReadAll(resp.Body)
+
+ if strings.Contains(string(raw), "deepseek-v4-pro") {
+ t.Errorf("the free tier advertised a model it refuses to serve: %s", raw)
+ }
+ if !strings.Contains(string(raw), `"deepseek-flash"`) {
+ t.Errorf("the served model was filtered out too: %s", raw)
+ }
+ // Still a real upstream call, so `deepseek status` keeps answering
+ // whether DeepSeek itself is reachable.
+ })
}
}
@@ -844,7 +848,7 @@ func TestExpiredTokenIsRefused(t *testing.T) {
func TestModelsIsCached(t *testing.T) {
up := newUpstream(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
- io.WriteString(w, `{"object":"list","data":[{"id":"deepseek-v4-flash","object":"model"}]}`)
+ io.WriteString(w, `{"object":"list","data":[{"id":"deepseek-flash","object":"model"}]}`)
})
h := newHarness(t, up, nil)
tok := h.enrol(t)
@@ -853,7 +857,7 @@ func TestModelsIsCached(t *testing.T) {
resp := h.do(t, "GET", "/models", tok, "")
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
- if resp.StatusCode != 200 || !strings.Contains(string(raw), "deepseek-v4-flash") {
+ if resp.StatusCode != 200 || !strings.Contains(string(raw), "deepseek-flash") {
t.Fatalf("models call %d: HTTP %d: %s", i, resp.StatusCode, raw)
}
}
@@ -903,54 +907,36 @@ func TestUpstreamDryBalanceStopsAdmissions(t *testing.T) {
}
}
-// web_search has to work end to end on the free tier — it is the reason
-// `deepseek respond --web-search` exists — and it has to stay rationed,
-// because one search costs about what ten ordinary turns cost.
-func TestWebSearchIsCarriedAndRationed(t *testing.T) {
+// DeepSeek removed server-side web_search from the Responses API on
+// 2026-09-10 and now ignores the tool. Forwarding it would bill a request
+// that answers from memory while the caller believes it searched, so the
+// gateway refuses it with the reason, before any quota or money moves.
+func TestWebSearchIsRefusedWithTheReason(t *testing.T) {
up := newUpstream(t, func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, chatReply(100, 50))
})
- h := newHarness(t, up, nil) // DailySearches: 2
+ h := newHarness(t, up, nil)
tok := h.enrol(t)
- const search = `{"input":"who won","tools":[{"type":"web_search"}]}`
- for i := 0; i < 2; i++ {
- resp := h.do(t, "POST", "/responses", tok, search)
- resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- t.Fatalf("search %d: status = %d, want 200", i+1, resp.StatusCode)
- }
- h.settle(t)
- }
- // The tool must reach DeepSeek intact — a gateway that quietly dropped
- // it would return a confidently unsourced answer.
- tools, _ := up.last(t).Body["tools"].([]any)
- if len(tools) != 1 {
- t.Fatalf("upstream saw %d tools, want the one that was sent", len(tools))
- }
- if kind, _ := tools[0].(map[string]any)["type"].(string); kind != "web_search" {
- t.Errorf("upstream saw tool type %q, want web_search", kind)
- }
-
- resp := h.do(t, "POST", "/responses", tok, search)
+ resp := h.do(t, "POST", "/responses", tok, `{"input":"who won","tools":[{"type":"web_search"}]}`)
defer resp.Body.Close()
- if resp.StatusCode != http.StatusTooManyRequests {
- t.Fatalf("a third search past a ration of two: status = %d, want 429", resp.StatusCode)
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", resp.StatusCode)
}
var body struct {
Error struct {
- Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
}
json.NewDecoder(resp.Body).Decode(&body)
- if !strings.Contains(body.Error.Message, "search") {
- t.Errorf("the refusal does not say searches ran out: %q", body.Error.Message)
- }
- // Ordinary requests must survive an exhausted search ration.
- plain := h.do(t, "POST", "/responses", tok, `{"input":"hi"}`)
- defer plain.Body.Close()
- if plain.StatusCode != http.StatusOK {
- t.Errorf("an ordinary request was refused after searches ran out: status = %d", plain.StatusCode)
+ if !strings.Contains(body.Error.Message, "removed") {
+ t.Errorf("the refusal does not say the tool was removed upstream: %q", body.Error.Message)
+ }
+ if up.count() != 0 {
+ t.Error("a web_search request was forwarded")
+ }
+ h.settle(t)
+ if used := h.ledger.Status(subjectOf(t, tok), "anon").Used.Requests; used != 0 {
+ t.Errorf("a refused web_search consumed %d requests of quota", used)
}
}
diff --git a/gateway/internal/server/web/app.js b/gateway/internal/server/web/app.js
index dbab430..7efa9d4 100644
--- a/gateway/internal/server/web/app.js
+++ b/gateway/internal/server/web/app.js
@@ -514,7 +514,6 @@
setText("lim-req", fmtInt(lim.requests));
setText("lim-in", fmtCompact(lim.input_tokens));
setText("lim-out", fmtCompact(lim.output_tokens));
- setText("lim-search", fmtInt(lim.searches));
var rAt = Date.parse(d.resets_at);
var sNow = Date.parse(d.now);
diff --git a/gateway/internal/server/web/index.html b/gateway/internal/server/web/index.html
index 590de3e..b686470 100644
--- a/gateway/internal/server/web/index.html
+++ b/gateway/internal/server/web/index.html
@@ -4,17 +4,17 @@
A community-run, OpenAI-compatible gateway to
- deepseek-v4-flash. Your client solves a one-second
+ deepseek-flash, DeepSeek-V4.1-Flash. Your client solves a one-second
proof-of-work puzzle — that is the whole signup.
@@ -229,13 +229,11 @@
Per-user daily limits
30 requests
60k input tokens
20k output tokens
-
3 web searches
-
Server-side web_search works here:
- deepseek respond "…" --web-search, or the web_search
- tool on /responses from any OpenAI client. It has its own small
- ration because one search reads whole pages into the prompt and costs about
- what ten ordinary turns cost — see economics.
+
Server-side web_search is gone: DeepSeek
+ removed it from the Responses API on 2026-09-10, with V4.1 Flash, and now
+ ignores the tool. A request that carries it is refused here with that
+ reason rather than answered from memory as if it had searched.
This service resells nothing and marks up nothing, so its existence hangs on a single number: what a token costs. That number has quietly become one of the widest price spreads in software. As of August 2026, a million output tokens from deepseek-v4-flash cost $0.28. The same million from a frontier flagship cost $25 to $50 — $180 from the pro tiers. The models are not equivalent, but on everyday work they are far closer in capability than they are in price.
+
This service resells nothing and marks up nothing, so its existence hangs on a single number: what a token costs. That number has quietly become one of the widest price spreads in software. As of September 2026, a million output tokens from deepseek-flash (DeepSeek-V4.1-Flash) cost $0.60 off-peak and $1.20 at peak. The same million from a frontier flagship cost $25 to $50 — $180 from the pro tiers, at the list prices read on 2026-08-06. The models are not equivalent, but on everyday work they are far closer in capability than they are in price.
The rate cards
-
Published list prices, USD per million tokens, checked against each vendor's own pricing page on 2026-08-06:
+
DeepSeek's card today, USD per million tokens, read from its pricing page on 2026-09-18. Peak is 01:00–04:00 and 06:00–10:00 UTC, Monday to Friday; every other hour is off-peak.
+
+
model
period
input
cached input
output
+
+
deepseek-flash
off-peak
$0.15
$0.003
$0.60
+
peak
$0.30
$0.006
$1.20
+
deepseek-v4-pro
off-peak
$0.66
$0.022
$1.98
+
peak
$1.32
$0.044
$3.96
+
+
+
The cross-vendor snapshot below was read on 2026-08-06, against each vendor's own pricing page. DeepSeek sold one flat card then, and its two rows are that card, kept as history: V4 Flash was retired on 2026-09-10 and the flat card ended on 2026-08-16.
model
input
cached input
output
-
deepseek-v4-flash
$0.14
$0.0028
$0.28
-
deepseek-v4-pro
$0.435
$0.003625
$0.87
+
deepseek-v4-flash (flat card, retired)
$0.14
$0.0028
$0.28
+
deepseek-v4-pro (flat card)
$0.435
$0.003625
$0.87
GPT-5.2
$1.75
$0.175
$14.00
Claude Sonnet 5*
$2.00
$0.20
$10.00
GPT-5.4
$2.50
$0.25
$15.00
@@ -39,27 +49,28 @@
The rate cards
*Introductory pricing through 2026-08-31; $3 / $15 after. Sources: DeepSeek, Anthropic, OpenAI. Prices change monthly; when this table and a vendor's page disagree, the vendor's page is right.
-
Read down the output column: flash to Fable 5 is 179×. The cached-input column is starker — $1.00 against $0.0028 is 357× — and DeepSeek's cache is automatic and free to write, where Anthropic bills cache writes at 1.25–2× the input rate.
+
Read today's flash card against that column: flash to Fable 5 is 83× on output off-peak, 42× at peak. The cached-input column is starker — $1.00 against $0.003 is 333× — and DeepSeek's cache is automatic and free to write, where Anthropic bills cache writes at 1.25–2× the input rate.
The same task
-
Rate cards mislead without a workload, so take an ordinary agent exchange — the shape Anthropic itself uses as a worked example: 50k input tokens of which 40k are cache reads, 15k output tokens. Counting the same nominal tokens at each vendor's list prices:
+
Rate cards mislead without a workload, so take an ordinary agent exchange — the shape Anthropic itself uses as a worked example: 50k input tokens of which 40k are cache reads, 15k output tokens. Counting the same nominal tokens at deepseek-flash's card today, off-peak, and at the other vendors' list prices of 2026-08-06:
model
that exchange costs
vs flash
-
deepseek-v4-flash
$0.0057
1×
-
Claude Sonnet 5
$0.18
31×
-
GPT-5.4
$0.26
46×
-
Claude Opus 5
$0.45
78×
-
GPT-5.5
$0.52
91×
-
Claude Fable 5
$0.89
156×
-
GPT-5.5-pro
$4.20
735×
+
deepseek-flash
$0.0106
1×
+
Claude Sonnet 5*
$0.18
17×
+
GPT-5.4
$0.26
25×
+
Claude Opus 5
$0.45
42×
+
GPT-5.5
$0.52
49×
+
Claude Fable 5
$0.89
84×
+
GPT-5.5-pro
$4.20
396×
-
Not twenty percent cheaper. Thirty to a few hundred times cheaper, depending on the model and how much of the prompt caches.
+
Not twenty percent cheaper. Seventeen to a few hundred times cheaper off-peak, half that at peak, depending on the model and how much of the prompt caches. *Sonnet 5 at its introductory price; the table on the V4 flat card, which billed $0.0057 for this exchange, read 31× to 735×.
That table is still arithmetic on a rate card, which is the weakest kind of cost claim — it assumes every model spends the same tokens on the same work, and they do not. The section below is the stronger evidence: what independent benchmarks actually paid.
What the benchmarks actually paid
+
The figures in this section and the next measured V4 Flash and V4 Pro, read on 2026-08-07. V4 Flash was retired on 2026-09-10; deepseek-flash is V4.1 Flash, a different model at a different price, and none of these rows describe it. They stay as dated V4 measurements until independent ones exist for V4.1.
Two leaderboards publish a dollar figure next to the score, measured from their own runs rather than multiplied out of a price list. Artificial Analysis reports cost per task — total spend to run its Intelligence Index divided by the number of tasks — alongside the index score itself:
model
intelligence index
cost per task
@@ -95,19 +106,19 @@
Where the cheap-token argument breaks
Against the flagship tier, the gap is real and enormous. One to two orders of magnitude per task, on published same-harness runs, confirmed by two independent measurers.
Against the cheap frontier tier, the gap mostly evaporates. The cheap tiers of the big labs now land within a factor of two of DeepSeek's per-task cost, sometimes with better scores.
-
The top of the quality range is not for sale at this price. Flash's 52 sits ten-plus index points below Opus 5 and Fable 5, and Artificial Analysis pairs that $0.03 with a 37% score on Humanity's Last Exam and an 84% hallucination rate. Paying 76× more buys something; whether it buys enough for your task is your measurement to make, not ours.
+
The top of the quality range was not for sale at this price. V4 Flash's 52 sat ten-plus index points below Opus 5 and Fable 5, and Artificial Analysis pairs that $0.03 with a 37% score on Humanity's Last Exam and an 84% hallucination rate. Paying 76× more buys something; whether it buys enough for your task is your measurement to make, not ours.
Every figure above was read on 2026-08-07 and they drift for two reasons at once: vendors reprice (DeepSeek's own cost-to-run fell from $1,071 to $176 for v4-pro after a price cut), and benchmarks reweight their indices. Treat the ratios as this month's, not as constants.
What this does not claim
-
Three smaller caveats on top of the big one. Tokenizers differ, so identical text is not identical token counts across vendors — Anthropic notes its current tokenizer emits roughly 30% more tokens for the same input than its previous one. DeepSeek has announced peak-hour pricing at 2× the listed rates, with no effective date yet, which would halve the gap during working hours in Beijing. And a harness costs money too: one comparison held flash constant across four agent frameworks and watched cost per successful task swing 2.7× — $0.073 on the cheapest, $0.195 on Claude Code — so the tool you drive the model with can matter as much as the model.
+
Three smaller caveats on top of the big one. Tokenizers differ, so identical text is not identical token counts across vendors — Anthropic notes its current tokenizer emits roughly 30% more tokens for the same input than its previous one. DeepSeek bills peak hours at 2× the off-peak rates since 2026-08-16 (01:00–04:00 and 06:00–10:00 UTC, Monday to Friday), which halves the gap during working hours in Beijing. And a harness costs money too: one comparison held V4 Flash constant across four agent frameworks and watched cost per successful task swing 2.7× — $0.073 on the cheapest, $0.195 on Claude Code — so the tool you drive the model with can matter as much as the model.
What survives all of that is still worth saying plainly: for the broad middle of real work — summarize, translate, refactor, answer, glue — the going rate differs by one to two orders of magnitude depending on whose API you call, and the cheapest credible option is no longer a toy.
Why it matters
-
Chat is measured in thousands of tokens; agents are measured in millions. The moment a model works unattended — reading files, retrying, checking its own output — token consumption stops tracking human attention and starts tracking machine patience. An overnight agent run that emits ten million output tokens costs $2.80 at flash prices and $500 at Fable prices. One of those is "leave it running"; the other is a line item that gets a meeting. At frontier prices, autonomy is a luxury good. At flash prices, it is a background process.
+
Chat is measured in thousands of tokens; agents are measured in millions. The moment a model works unattended — reading files, retrying, checking its own output — token consumption stops tracking human attention and starts tracking machine patience. An overnight agent run that emits ten million output tokens costs $6 at flash's off-peak price and $500 at Fable prices. One of those is "leave it running"; the other is a line item that gets a meeting. At frontier prices, autonomy is a luxury good. At flash prices, it is a background process.
Whatever AGI turns out to be, it will be made of tokens, and nobody runs civilization-scale inference at $50 per million. Every 10× drop in token price makes a class of applications viable that was silly the day before — the same way compute-per-dollar curves, not any single breakthrough, decided what software got built. Cheap tokens are not the budget option. They are the substrate.
-
This page is also the explanation of the gateway you are reading it on. At flash prices, a dollar buys roughly three thousand ordinary conversational turns; at frontier list prices, the same dollar buys about fifty. A free tier funded by donated keys and pocket money is arithmetic that only works at the bottom of that table — which is why it runs on deepseek-v4-flash, and why there is no paid tier to upsell you to.
+
This page is also the explanation of the gateway you are reading it on. At flash prices, a dollar buys roughly nineteen hundred ordinary conversational turns off-peak, half that at peak; at frontier list prices, the same dollar buys about fifty. A free tier funded by donated keys and pocket money is arithmetic that only works at the bottom of that table — which is why it runs on deepseek-flash, and why there is no paid tier to upsell you to.