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

    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.

    modelintelligence indexcost per task