From b510d0e85f31dc0a503638d9ecd14e0a493a0981 Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Thu, 17 Sep 2026 23:20:43 -0700 Subject: [PATCH] feat: deepseek-flash everywhere, and web_search is gone DeepSeek-V4.1-Flash (`deepseek-flash`) is now the default and the recommended pick on every surface: the CLI docs, the site, and the free tier. `deepseek-v4-pro` stays callable and correctly priced, but nothing presents it as the default or the stronger pick any more. Where the copy says Flash is ahead, it cites DeepSeek's own 2026-09-10 changelog and release note. Gateway (freeseek): - DSGATE_MODEL defaults to deepseek-flash. resolve() now matches the Anthropic API guide: claude-opus* goes to pro; claude-sonnet*, claude-haiku* and unknown names go to deepseek-flash. The two retired flash names are the same model. checkModel resolves both sides, so a pre-rename DSGATE_MODEL does not start refusing the CLI's default. - server.New serves the model under its canonical name. With DSGATE_MODEL=deepseek-v4-flash, /models had been filtering upstream's new-only list down to nothing. That is live on freeseek today. - web_search is refused with the reason. DeepSeek removed it from the Responses API on 2026-09-10; the guide now lists it as ignored, and a 2026-09-18 probe billed 21 input tokens where a search used to bill ~40K. DailySearches, searchInputAllowance and the Search admission field are deleted. - Pages (index, terms, economics) name deepseek-flash. Economics now quotes today's card and keeps the August flat card and the V4 cost-per-task figures as dated history. - DESIGN/README record that OpenCode Zen's free lane answers "Model is unavailable" as of 2026-09-18. CLI: `respond --web-search` is hidden and exits 1 with the reason. It sends nothing. The free-tier search ration display is gone. Pricing: V41At moves from the inferred 11:00 UTC to 04:00 UTC on 2026-09-10, the instant DeepSeek's release note publishes. This covers the CLI and the gateway, plus the tests at the new peak/card boundary. Tripwires, each mutation-checked: - internal/cli/claims_test.go: the docs' "(default)" model must be deepseek.ModelFlash, and no doc may offer `respond --web-search`. - gateway/internal/server/claims_test.go: the DSGATE_MODEL default must be a current name, the pages must name it, and no page may promise web search. - site/build.py check_model_claims: a retired flash name without its successor, or live web-search copy, fails the build unless the section is marked historical. Also: the playground's search toggle is removed. A pre-existing flex bug that split the playground's "when it runs out" row is fixed. A news entry for 2026-09-18 is added. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 24 +- README.md | 24 +- TASTE.md | 25 +- gateway/DESIGN.md | 58 ++-- gateway/README.md | 21 +- gateway/cmd/dsgate/main.go | 6 +- gateway/deploy/README.md | 16 +- gateway/internal/meter/meter.go | 45 +-- gateway/internal/meter/meter_test.go | 30 +- gateway/internal/meter/weekend_test.go | 14 +- gateway/internal/policy/policy.go | 86 ++--- gateway/internal/policy/policy_test.go | 77 +++-- gateway/internal/quota/quota.go | 30 +- gateway/internal/quota/quota_test.go | 48 --- gateway/internal/server/claims_test.go | 96 ++++++ gateway/internal/server/freelane_test.go | 8 +- gateway/internal/server/interop_test.go | 2 +- gateway/internal/server/proxy.go | 12 +- gateway/internal/server/server.go | 15 +- gateway/internal/server/server_test.go | 112 +++---- gateway/internal/server/web/app.js | 1 - gateway/internal/server/web/index.html | 20 +- .../internal/server/web/pages/economics.html | 51 +-- gateway/internal/server/web/pages/terms.html | 6 +- internal/cli/claims_test.go | 70 ++++ internal/cli/e2e_test.go | 20 +- internal/cli/free.go | 8 - internal/cli/pricing_test.go | 2 +- internal/cli/respond.go | 44 +-- internal/deepseek/free.go | 6 - internal/deepseek/pricing.go | 18 +- internal/deepseek/responses.go | 9 +- internal/deepseek/usage_test.go | 14 +- site/bench/index.html | 114 ++++--- site/build.py | 301 +++++++++++++----- site/commands/index.html | 25 +- site/cost/index.html | 21 +- site/formats/index.html | 6 +- site/index.html | 4 +- site/llms.txt | 9 +- site/news/index.html | 47 ++- site/playground.dom.test.js | 6 +- site/playground.js | 10 +- site/playground/index.html | 14 +- site/pricing/index.html | 12 +- site/style.css | 11 - skill/SKILL.md | 22 +- 47 files changed, 970 insertions(+), 650 deletions(-) create mode 100644 gateway/internal/server/claims_test.go create mode 100644 internal/cli/claims_test.go 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.*?`) + 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 @@ freeseek — free DeepSeek API, no key, no account - - + + - + - + @@ -52,7 +52,7 @@

Free DeepSeek API. No key. No account.

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.

    diff --git a/gateway/internal/server/web/pages/economics.html b/gateway/internal/server/web/pages/economics.html index 600d993..7b31ddb 100644 --- a/gateway/internal/server/web/pages/economics.html +++ b/gateway/internal/server/web/pages/economics.html @@ -4,8 +4,8 @@ Economics — freeseek - - + + @@ -19,15 +19,25 @@

    Cheap tokens are the point

    -

    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.

    + + + + + + + + +
    modelperiodinputcached inputoutput
    deepseek-flashoff-peak$0.15$0.003$0.60
    peak$0.30$0.006$1.20
    deepseek-v4-prooff-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.

    - - + + @@ -39,27 +49,28 @@

    The rate cards

    modelinputcached inputoutput
    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

    *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:

    - - - - - - - + + + + + + +
    modelthat exchange costsvs flash
    deepseek-v4-flash$0.0057
    Claude Sonnet 5$0.1831×
    GPT-5.4$0.2646×
    Claude Opus 5$0.4578×
    GPT-5.5$0.5291×
    Claude Fable 5$0.89156×
    GPT-5.5-pro$4.20735×
    deepseek-flash$0.0106
    Claude Sonnet 5*$0.1817×
    GPT-5.4$0.2625×
    Claude Opus 5$0.4542×
    GPT-5.5$0.5249×
    Claude Fable 5$0.8984×
    GPT-5.5-pro$4.20396×
    -

    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:

    @@ -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.

    A community credit pool run by thevibeworks/deepseek-cli. No uptime guarantee. Privacy · Terms

    diff --git a/gateway/internal/server/web/pages/terms.html b/gateway/internal/server/web/pages/terms.html index 59f662d..73b86b0 100644 --- a/gateway/internal/server/web/pages/terms.html +++ b/gateway/internal/server/web/pages/terms.html @@ -18,7 +18,7 @@

    Terms of service

    -

    Last updated: 2026-08-06

    +

    Last updated: 2026-09-18

    This service, dsgate at freeseek.1lm.io, is a free, keyless proxy to the DeepSeek API. It is a hobby project run by the maintainers of the open-source project thevibeworks/deepseek-cli. It is not a company and there is no legal entity behind it. These terms are written to be honest rather than to sound like a contract, because they are not one: they are the conditions under which we are willing to spend our own money on your API calls. By using the service you accept them.

    @@ -35,7 +35,7 @@

    3. Quotas

  • 60,000 input tokens and 20,000 output tokens
  • 4,096 output tokens per call
  • 128 KB per request body
  • -
  • the deepseek-v4-flash model only; a request for any other model is refused, not silently downgraded
  • +
  • the deepseek-flash model only (DeepSeek-V4.1-Flash; the retired names deepseek-v4-flash and deepseek-v4-flash-vision-exp are the same model); a request for any other model is refused, not silently downgraded
  • Enrolment tokens expire seven days after they are minted; the CLI renews them automatically.

    Circumventing the quota system is prohibited. That includes minting multiple identities to multiply your allowance, evading or outsourcing the proof-of-work, stockpiling tokens, and sharing or distributing tokens to others. The proof-of-work exists to make each identity cost something; treating it as a farming target is abuse of a shared resource that other people paid for.

    @@ -55,7 +55,7 @@

    5. Acceptable use

    If you have real volume or a real product, bring your own key from platform.deepseek.com. It is cheap, it is unmetered by us, and it is the intended path.

    6. What the gateway does to your requests

    -

    This is a policy-enforcing proxy, and you should know what the policy touches. The gateway pins the model, clamps the output-token cap, and overwrites any user identity field with your subject id. It refuses request parameters that multiply cost (n, best_of above 1) and server-side tools such as web search, whose cost cannot be metered. Only an allowlisted set of endpoints is proxied at all. Apart from those named fields, your request is forwarded as you sent it.

    +

    This is a policy-enforcing proxy, and you should know what the policy touches. The gateway pins the model, clamps the output-token cap, and overwrites any user identity field with your subject id. It refuses request parameters that multiply cost (n, best_of above 1) and server-side tools: web search because DeepSeek removed it on 2026-09-10 and now ignores it, anything else because its cost cannot be metered. Only an allowlisted set of endpoints is proxied at all. Apart from those named fields, your request is forwarded as you sent it.

    7. Revocation and termination

    Any token may be revoked at any time, without notice and without appeal. A revoked token does not heal at midnight. In practice revocation is used against abuse, but nothing here obliges us to justify it: this is a gift, and a gift can be withdrawn.

    diff --git a/internal/cli/claims_test.go b/internal/cli/claims_test.go new file mode 100644 index 0000000..317f987 --- /dev/null +++ b/internal/cli/claims_test.go @@ -0,0 +1,70 @@ +package cli + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/thevibeworks/deepseek-cli/internal/deepseek" +) + +// The docs an agent reads before it runs this tool. They restate things +// the code decides — the default model, which flags work — and nothing +// else keeps them honest: on 2026-09-10 the default changed name and a +// flag stopped doing anything, and every check stayed green while the +// docs went on saying the old thing. +var claimDocs = []string{"README.md", "AGENTS.md", "skill/SKILL.md", "llms.txt", "site/llms.txt"} + +func readClaimDoc(t *testing.T, rel string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join("..", "..", rel)) + if err != nil { + t.Fatalf("read %s: %v", rel, err) + } + return string(b) +} + +// Every sentence that names the default model names the one the flags +// actually default to. Every occurrence, not "somewhere": a stale second +// copy must not hide behind a fresh first one. +func TestPublishedDefaultModelIsTheFlagDefault(t *testing.T) { + claims := []*regexp.Regexp{ + regexp.MustCompile("`(deepseek-[a-z0-9.-]+)` \\(default\\)"), + regexp.MustCompile("default \\(`(deepseek-[a-z0-9.-]+)`"), + } + found := map[string]int{} + for _, doc := range claimDocs { + text := readClaimDoc(t, doc) + for _, re := range claims { + for _, m := range re.FindAllStringSubmatch(text, -1) { + found[doc]++ + if m[1] != deepseek.ModelFlash { + t.Errorf("%s says the default model is %s; the flags default to %s", doc, m[1], deepseek.ModelFlash) + } + } + } + } + // The two agent-facing docs state it; if they stop, the claim moved + // somewhere this test cannot see, which is its own kind of drift. + for _, doc := range []string{"AGENTS.md", "skill/SKILL.md"} { + if found[doc] == 0 { + t.Errorf("%s no longer states the default model in a form this test reads", doc) + } + } +} + +// DeepSeek removed server-side web_search on 2026-09-10 and `respond +// --web-search` now exits 1. A doc that still shows it as a command to run +// is teaching a failure. +func TestNoDocOffersWebSearchAsACommand(t *testing.T) { + run := regexp.MustCompile(`^\s*(\$ )?(deepseek|ds|dscli) respond\b.*--web-search`) + for _, doc := range claimDocs { + for i, line := range strings.Split(readClaimDoc(t, doc), "\n") { + if run.MatchString(line) { + t.Errorf("%s:%d offers --web-search, which DeepSeek removed on 2026-09-10: %s", doc, i+1, strings.TrimSpace(line)) + } + } + } +} diff --git a/internal/cli/e2e_test.go b/internal/cli/e2e_test.go index a1d2704..a3d1ddd 100644 --- a/internal/cli/e2e_test.go +++ b/internal/cli/e2e_test.go @@ -372,18 +372,20 @@ func TestRespondSchemaImpliesJSONSchemaFormat(t *testing.T) { } } -func TestRespondWebSearchAddsTheServerSideTool(t *testing.T) { - got := runCLI(t, serve(`{"id":"r1","object":"response","status":"completed","model":"deepseek-v4-flash","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`), +// DeepSeek removed server-side web_search on 2026-09-10 and now ignores +// the tool. The flag must fail with the reason and send nothing, rather +// than bill a request that answers from memory as if it had searched. +func TestRespondWebSearchIsRefusedWithTheReason(t *testing.T) { + got := runCLI(t, serve(`{"id":"r1","object":"response","status":"completed","model":"deepseek-flash","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`), "respond", "hi", "--web-search", "--stream=false") - if got.err != nil { - t.Fatal(got.err) + if got.err == nil { + t.Fatal("--web-search succeeded; the tool no longer runs upstream") } - tools, _ := got.requests[0]["tools"].([]any) - if len(tools) != 1 { - t.Fatalf("sent %d tools", len(tools)) + if !strings.Contains(got.err.Error(), "removed") || !strings.Contains(got.err.Error(), "2026-09-10") { + t.Errorf("the error does not say it was removed upstream, and when: %v", got.err) } - if tool, _ := tools[0].(map[string]any); tool["type"] != "web_search" { - t.Errorf("tool = %v", tools[0]) + if len(got.requests) != 0 { + t.Errorf("sent %d requests; a refused flag must send nothing", len(got.requests)) } } diff --git a/internal/cli/free.go b/internal/cli/free.go index 9ed1f2a..df6aafd 100644 --- a/internal/cli/free.go +++ b/internal/cli/free.go @@ -144,11 +144,6 @@ func (o *Options) printFreeOffer(gateway string, info *deepseek.FreeInfo) { row("model", info.Model) perDay := fmt.Sprintf("%s requests · %s input · %s output tokens", humanTokens(info.Limits.Requests), humanTokens(info.Limits.InputTokens), humanTokens(info.Limits.OutputTokens)) - // A gateway that does not offer web search sends no ration, and - // printing "0 searches" would read as "you have used them all". - if info.Limits.Searches > 0 { - perDay += fmt.Sprintf(" · %d web searches", info.Limits.Searches) - } row("per day", perDay) if info.MaxTokens > 0 { row("per call", fmt.Sprintf("%s output tokens max", humanTokens(info.MaxTokens))) @@ -197,9 +192,6 @@ func runFreeStatus(ctx context.Context, o *Options) error { quota.Used.Requests, quota.Limits.Requests, humanTokens(quota.Used.InputTokens), humanTokens(quota.Limits.InputTokens), humanTokens(quota.Used.OutputTokens), humanTokens(quota.Limits.OutputTokens)) - if quota.Limits.Searches > 0 { - today += fmt.Sprintf(" · %d/%d searches", quota.Used.Searches, quota.Limits.Searches) - } row("today", today) row("spent", money(quota.Used.SpentUSD)+" "+o.dim("(on our credits, not yours)")) if d := time.Until(quota.ResetsAt); d > 0 { diff --git a/internal/cli/pricing_test.go b/internal/cli/pricing_test.go index 40cb5a7..a6dbccd 100644 --- a/internal/cli/pricing_test.go +++ b/internal/cli/pricing_test.go @@ -85,7 +85,7 @@ func TestPricingAfterV41ShowsTheNewFlashCard(t *testing.T) { } text := formatPricing(now) - for _, want := range []string{"since 2026-09-10 11:00 UTC", "$0.15", "$0.6", "deepseek-v4-flash-vision-exp bill as deepseek-flash"} { + for _, want := range []string{"since 2026-09-10 04:00 UTC", "$0.15", "$0.6", "deepseek-v4-flash-vision-exp bill as deepseek-flash"} { if !strings.Contains(text, want) { t.Errorf("text output is missing %q:\n%s", want, text) } diff --git a/internal/cli/respond.go b/internal/cli/respond.go index 9ef4ece..450d9ff 100644 --- a/internal/cli/respond.go +++ b/internal/cli/respond.go @@ -3,6 +3,7 @@ package cli import ( "context" "encoding/json" + "errors" "fmt" "strings" "time" @@ -39,14 +40,13 @@ func newRespondCmd(o *Options) *cobra.Command { Short: "Response in the OpenAI Responses format (POST /responses)", Long: strings.TrimSpace(` Send a request in OpenAI's Responses format — the wire format Codex -speaks. Two things live here and nowhere else in the DeepSeek API: -JSON Schema structured output, and web_search, a tool DeepSeek runs -server-side. +speaks. JSON Schema structured output lives here and nowhere else in +the DeepSeek API. Both models are accepted since V4-Pro's official release -(2026-08-12); the endpoint was flash-only before that. +(2026-08-12); the endpoint was flash-only before that. DeepSeek removed +the server-side web_search tool on 2026-09-10, with V4.1 Flash. - deepseek respond "what shipped in Go 1.26" --web-search deepseek respond "extract the versions" --schema @versions.json --json`), Args: cobra.ArbitraryArgs, RunE: func(cmd *cobra.Command, args []string) error { @@ -66,7 +66,10 @@ Both models are accepted since V4-Pro's official release fl.StringVar(&f.schemaName, "schema-name", "response", "name for the JSON Schema") fl.StringArrayVar(&f.tools, "tool", nil, "tool definition as JSON or @file (repeatable)") fl.StringVar(&f.toolChoice, "tool-choice", "", "none, auto, required, or a JSON tool-choice object") - fl.BoolVar(&f.webSearch, "web-search", false, "let the model search the web (runs on DeepSeek's servers)") + // Kept, hidden, so a script that still passes it gets the reason + // instead of "unknown flag". + fl.BoolVar(&f.webSearch, "web-search", false, "removed upstream on 2026-09-10") + _ = fl.MarkHidden("web-search") fl.StringVar(&f.user, "user", "", "user identifier for cache and scheduling isolation") fl.StringArrayVarP(&f.files, "file", "f", nil, "attach a file's contents to the prompt (repeatable)") fl.BoolVar(&f.stream, "stream", true, "stream the answer (default off when --json or --jq is used)") @@ -90,6 +93,13 @@ func runRespond(cmd *cobra.Command, o *Options, f *respondFlags, args []string) showReasoning = f.reasoning } + // DeepSeek now ignores the tool, so sending it would answer from the + // model's memory while the caller believes it searched. Refuse before + // anything is read or spent. + if f.webSearch { + return errWebSearchRemoved + } + // This format accepts instructions alone, with no input at all. prompt, err := readPrompt(args, f.files, f.instructions == "") if err != nil { @@ -125,7 +135,7 @@ func runRespond(cmd *cobra.Command, o *Options, f *respondFlags, args []string) if req.Text, err = buildTextConfig(f); err != nil { return err } - if req.Tools, err = loadResponsesTools(f.tools, f.webSearch); err != nil { + if req.Tools, err = loadResponsesTools(f.tools); err != nil { return err } if f.toolChoice != "" { @@ -214,8 +224,6 @@ func (o *Options) streamRespond(ctx context.Context, c *deepseek.Client, req *de fmt.Fprint(o.stdout, ev.Delta) wroteAnswer = true } - case "response.web_search_call.searching": - fmt.Fprintln(o.stderr, o.dim("· searching the web")) } return nil }) @@ -252,7 +260,14 @@ func buildTextConfig(f *respondFlags) (*deepseek.TextConfig, error) { return nil, fmt.Errorf("--format takes text, json_object, or json_schema, not %q", f.format) } -func loadResponsesTools(sources []string, webSearch bool) ([]deepseek.ResponsesTool, error) { +// errWebSearchRemoved answers --web-search. The date and the source are in +// the message because the flag worked until then, and "unknown flag" would +// read as a bug in this tool. +var errWebSearchRemoved = errors.New("--web-search: DeepSeek removed server-side web_search from the Responses API on 2026-09-10; " + + "the tool is now ignored, so no search would run. Search on your side and pass the results with --file or stdin " + + "(see: deepseek docs show guides/responses_api)") + +func loadResponsesTools(sources []string) ([]deepseek.ResponsesTool, error) { tools, err := loadTools(sources) if err != nil { return nil, err @@ -268,13 +283,10 @@ func loadResponsesTools(sources []string, webSearch bool) ([]deepseek.ResponsesT Parameters: t.Function.Parameters, }) } - if webSearch { - out = append(out, deepseek.ResponsesTool{Type: "web_search"}) - } return out, nil } -// printResponsesCalls reports function calls and web searches to stderr. +// printResponsesCalls reports function calls to stderr. func (o *Options) printResponsesCalls(resp *deepseek.ResponsesResponse) { if o.JSON || o.JQ != "" { return @@ -283,10 +295,6 @@ func (o *Options) printResponsesCalls(resp *deepseek.ResponsesResponse) { switch item.Type { case "function_call": fmt.Fprintln(o.stderr, o.dim(fmt.Sprintf("tool_call %s %s(%s)", item.CallID, item.Name, item.Arguments))) - case "web_search_call": - if len(item.Action) > 0 { - fmt.Fprintln(o.stderr, o.dim("web_search "+string(item.Action))) - } } } } diff --git a/internal/deepseek/free.go b/internal/deepseek/free.go index e939f76..f8a8497 100644 --- a/internal/deepseek/free.go +++ b/internal/deepseek/free.go @@ -116,10 +116,6 @@ type FreeInfo struct { Requests int `json:"requests"` InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` - // Searches is the daily ration for server-side web_search. Older - // gateways do not send it, so zero means "not offered" rather than - // "none left". - Searches int `json:"searches"` } `json:"daily_limits"` Endpoints []string `json:"endpoints"` } @@ -132,14 +128,12 @@ type FreeQuota struct { Requests int `json:"requests"` InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` - Searches int `json:"searches"` SpentUSD float64 `json:"spent_usd"` } `json:"used"` Limits struct { Requests int `json:"requests"` InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` - Searches int `json:"searches"` } `json:"limits"` ResetsAt time.Time `json:"resets_at"` Exhausted bool `json:"service_exhausted"` diff --git a/internal/deepseek/pricing.go b/internal/deepseek/pricing.go index 57c2282..fa9d69d 100644 --- a/internal/deepseek/pricing.go +++ b/internal/deepseek/pricing.go @@ -73,17 +73,15 @@ 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. The changelog dates the release 2026-09-10 and says only that -// "API prices have been reduced accordingly"; the pricing page carries -// the new card and no effective instant. Pro's card did not move. +// release. Pro's card did not move. The changelog dates the release and +// the pricing page carries no instant, but DeepSeek's release note does: +// "New pricing takes effect at 04:00 UTC on Sept 10, 2026" +// (api-docs.deepseek.com/news/news260910, read 2026-09-18). // -// The instant is INFERRED, not published. Our docs mirror fetched the -// pricing page at 04:50 UTC on 2026-09-10 and got the old card, and at -// 11:27 UTC the same day and got the new one. 11:00 UTC is the last whole -// hour inside that bracket, which errs toward the old, dearer card: an -// estimate for a call in the bracket can overstate what it cost, never -// understate it. Move it if DeepSeek publishes the real instant. -var V41At = time.Date(2026, time.September, 10, 11, 0, 0, 0, time.UTC) +// Until that was read, this was 11:00 UTC, inferred from our docs mirror, +// which fetched the old card at 04:50 UTC that day and the new one at +// 11:27 UTC. The pricing page lagged the price; the note is the source. +var V41At = time.Date(2026, time.September, 10, 4, 0, 0, 0, time.UTC) // beijing is the vendor's clock. China has observed no daylight saving // since 1991, so a fixed offset is exact and needs no tzdata. diff --git a/internal/deepseek/responses.go b/internal/deepseek/responses.go index 2362f2d..69ab987 100644 --- a/internal/deepseek/responses.go +++ b/internal/deepseek/responses.go @@ -6,9 +6,10 @@ import ( "fmt" ) -// The OpenAI Responses format. DeepSeek added it for Codex, and it is the -// only one of the four formats with a server-side tool: web_search runs -// on DeepSeek's side and comes back as web_search_call output items. +// The OpenAI Responses format. DeepSeek added it for Codex. It carried the +// API's only server-side tool, web_search, until DeepSeek removed it on +// 2026-09-10; web_search_call items passed back as input are still +// restored upstream, which is all Action below is for now. // // It is stateless. previous_response_id and conversation are rejected, so // multi-turn means resending the whole input list every time. @@ -51,7 +52,7 @@ type TextFormat struct { Schema json.RawMessage `json:"schema,omitempty"` } -// ResponsesTool is a function or the built-in server-side web_search. +// ResponsesTool is a function tool, flattened the way this format wants it. type ResponsesTool struct { Type string `json:"type"` Name string `json:"name,omitempty"` diff --git a/internal/deepseek/usage_test.go b/internal/deepseek/usage_test.go index 2f080d1..ef83f3a 100644 --- a/internal/deepseek/usage_test.go +++ b/internal/deepseek/usage_test.go @@ -295,13 +295,14 @@ func TestNextChangeCrossesTheWeekend(t *testing.T) { } func TestV41FlashCardSwitchesOnItsInstant(t *testing.T) { - // 2026-09-10: the Flash card dropped with V4.1, Pro's did not. 11:00 - // UTC is off-peak on a Thursday, so the flip lands on the off-peak - // rows of both cards. + // 2026-09-10: the Flash card dropped with V4.1, Pro's did not. 04:00 + // UTC on that Thursday is also the end of the 01:00-04:00 peak window, + // so the instant before the flip is V4 at peak and the flip itself is + // V4.1 off-peak: two changes land at once, and both must be seen. u := Usage{InputTokens: 2_000_000, CacheHitTokens: 1_000_000, CacheMissTokens: 1_000_000, OutputTokens: 1_000_000} before, _ := CostAt(ModelFlash, u, V41At.Add(-time.Nanosecond)) - if want := 0.007 + 0.22 + 0.66; math.Abs(before-want) > 1e-9 { + if want := 2 * (0.007 + 0.22 + 0.66); math.Abs(before-want) > 1e-9 { t.Errorf("one instant before V4.1: got %v, want the V4 card's %v", before, want) } after, _ := CostAt(ModelFlash, u, V41At) @@ -342,8 +343,9 @@ func TestRetiredFlashNamesBillAsFlashInEveryEra(t *testing.T) { } func TestNextChangeSeesTheCardChange(t *testing.T) { - // 10:00 to 11:00 UTC on 2026-09-10 is off-peak on both sides of the - // flip, so only the card changes; a label-only walk would sail past it. + // 04:00 UTC on 2026-09-10 ends a peak window and changes the card at + // once. From 03:30 the next change is that instant either way; from + // 04:30, off-peak on the new card, it is the 06:00 window. at := V41At.Add(-30 * time.Minute) if got := NextChange(at); !got.Equal(V41At) { t.Errorf("NextChange(%v) = %v, want the card change at %v", at, got, V41At) diff --git a/site/bench/index.html b/site/bench/index.html index 1097dfd..c9fad8a 100644 --- a/site/bench/index.html +++ b/site/bench/index.html @@ -3,25 +3,25 @@ -DeepSeek V4-Pro benchmarks vs GPT, Claude, Kimi and GLM, and the kill line - - +DeepSeek V4.1 Flash benchmarks, the V4 launch table, and the kill line + + - - + + - - + + @@ -30,7 +30,7 @@ and "}}]}', '{"choices":[{"delta":{"content":"[x](javascript:alert(3))"}}]}', - '{"model":"deepseek-v4-flash","choices":[{"delta":{},"finish_reason":"stop"}],' + + '{"model":"deepseek-flash","choices":[{"delta":{},"finish_reason":"stop"}],' + '"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}', '[DONE]', ]); diff --git a/site/playground.js b/site/playground.js index 2ebd9c9..7fd1190 100644 --- a/site/playground.js +++ b/site/playground.js @@ -137,7 +137,6 @@ }; if (s.system) body.instructions = s.system; if (s.temperature !== null) body.temperature = s.temperature; - if (s.search) body.tools = [{ type: 'web_search' }]; applyThinking(body, s); return body; }, @@ -231,8 +230,7 @@ ['enrol', 'enrolBtn', 'enrolStatus', 'app', 'log', 'composer', 'prompt', 'send', 'stop', 'usage', 'quota', 'command', 'copy', 'format', 'formatNote', 'think', 'effort', 'maxTokens', 'temperature', 'system', 'clear', 'reset', - 'gateway', 'fimFields', 'suffix', 'chatFields', 'error', - 'search', 'searchField' + 'gateway', 'fimFields', 'suffix', 'chatFields', 'error' ].forEach(function (id) { el[id] = document.getElementById('pg-' + id); }); if (!el.app) return; // not the playground page @@ -263,7 +261,6 @@ maxTokens: isNaN(maxTokens) ? 1024 : Math.max(1, Math.min(4096, maxTokens)), temperature: isNaN(temp) ? null : temp, system: el.system.value.trim(), - search: !!(el.search && el.search.checked), prefix: el.prompt.value, suffix: el.suffix.value, }; @@ -288,7 +285,6 @@ if (c.maxTokens) el.maxTokens.value = c.maxTokens; if (c.temperature !== null && c.temperature !== undefined) el.temperature.value = c.temperature; if (c.system) el.system.value = c.system; - if (c.search && el.search) el.search.checked = true; } catch (e) {} } @@ -312,14 +308,12 @@ if (s.effort) parts.push('--effort ' + s.effort); if (s.maxTokens !== 1024) parts.push('--max-tokens ' + s.maxTokens); if (s.temperature !== null) parts.push('--temperature ' + s.temperature); - if (s.search && f.command === 'respond') parts.push('--web-search'); if (history.length > 1 && !f.fim) parts.push('--session playground'); el.command.textContent = wrap(parts); el.formatNote.textContent = f.note; el.fimFields.hidden = !f.fim; el.chatFields.hidden = !!f.fim; - if (el.searchField) el.searchField.hidden = f.command !== 'respond'; } function quote(s) { @@ -820,7 +814,7 @@ el.prompt.addEventListener('input', renderCommand); el.suffix.addEventListener('input', renderCommand); - ['format', 'think', 'effort', 'maxTokens', 'temperature', 'system', 'search'].forEach(function (id) { + ['format', 'think', 'effort', 'maxTokens', 'temperature', 'system'].forEach(function (id) { if (!el[id]) return; el[id].addEventListener('change', saveControls); el[id].addEventListener('input', renderCommand); diff --git a/site/playground/index.html b/site/playground/index.html index efe3dc5..333ef71 100644 --- a/site/playground/index.html +++ b/site/playground/index.html @@ -99,8 +99,8 @@

    playground

  • per day 30 requests · 60k input · 20k output tokens
  • privacy prompts are relayed to DeepSeek and are not stored or logged by the gateway; only token counts and cost are recorded
  • -
  • when it runs out the quota resets at 00:00 UTC, and the - CLI with your own key has no limits at all
  • +
  • when it runs out the quota resets at 00:00 UTC, and the + CLI with your own key has no limits at all
  • @@ -143,16 +143,6 @@

    request

    - -
    modelintelligence indexcost per task