Status: measured 2026-08-14 on deepseek-v4-pro via https://api.deepseek.com
Script: experiments/2026-08-14_deepseek-test/deepseek_test.py (key from DEEPSEEK_API_KEY env)
Raw: experiments_history/2026-08-14_deepseek-test/results/20260814T15*_deepseek_series.json
Sibling: docs/streamlake-kat-thinking-cache.md (same suite against the StreamLake/KAT gateway)
Everything below is grounded in the official references (read before the runs) plus live measurements.
- Chat Completions API — request/response fields,
stream_options.include_usage,user_id. - Thinking Mode —
thinking:{type:enabled|disabled}(default enabled, effort default high),reasoning_effort: low|high|max;temperature/top_pignored in thinking mode; multi-turn CoT handling rules. - Context Caching — automatic, on by default; prefix units persisted at (1) request boundaries (end of user input / end of model output), (2) common-prefix detection, (3) fixed token intervals.
- Models & Pricing — v4-pro: hit $0.003625/M, miss $0.435/M, out $0.87/M (120× hit/miss gap).
- Rate Limit & Isolation —
user_iddocumented as KVCache isolation (see caveat below).
usageis on the final streaming chunk automatically — nostream_options.include_usageneeded (unlike StreamLake/KAT).prompt_tokens == prompt_cache_hit_tokens + prompt_cache_miss_tokenson every request (verified on all 20 turns).
| Scale | Observed behaviour |
|---|---|
| Ladder (182-token prompt) | hit pinned at 128 (t2–t7), then 256 (t8): units persist lazily (fixed intervals / boundaries); the growing conversation suffix misses until a unit lands |
| Big (48 147-token prefix) | t1 cold (hit 0 or 128 common-prefix unit), t2+ entire prefix hits (48 128); miss = only the appended turn text (49–132 tokens) |
- Hits sit on the exact 128-token lattice (48 128 = 376×128).
- After a warm-up turn, hit ratio on the big prefix: 0.997–0.999.
| Series | Cost | Note |
|---|---|---|
| big_default (6 turns, 48K prefix) | $0.0241 | $0.0209 = cold turn 1 (miss 48 019) — 87% of the series |
| no_think (4 turns) | $0.0216 | same cold-turn dominance |
| isolation (2×2) | $0.0011 | both buckets already warm |
- Cold 48K pre-fill ≈ $0.021; warm turn ≈ $0.0002. ~100× difference.
- Latency: hit 1 172–3 282 ms vs cold 6 208–7 069 ms (4–5×) on the 48K prefix.
thinking:{type:"disabled"}works:reasoning_tokens = 0on every turn (unlike the StreamLake/KAT gateway, which ignores the toggle).- Disabled thinking also shrinks the template slightly (48 068 vs 48 147 prompt).
- Historical
reasoning_contentbetween two user messages without tool calls is IGNORED by the API — echoing it adds wire bytes but does not enterprompt_tokens. - For requests carrying
tools,reasoning_contentis echoed back in all subsequent requests. The 400 that vendor docs attribute to a missing field is misattributed — re-probed 2026-09-12, it fires on atool_callid the server never issued (see the correction indocs/reasoning-round-trip-contract.md). Keep the echo; never synthesise atool_callid on replay. - Re-verified 2026-08-28 (live 400 on variant without the field) + cross-vendor matrix and the gateway rewrite: see
docs/reasoning-round-trip-contract.md.
- Docs claim KVCache isolation per
user_id. Live test: two freshuser_ids (verify-iso-a, verify-iso-b) both hit the full 48 128 prefix on turn 1 — cache is account-level shared on this account. - Do not rely on
user_idfor cache separation.
- Sends
thinking:{type:"enabled"}for deepseek-v4 (matches default think-on workflow). - Drops CoT bytes from the replay for assistant messages WITHOUT tool calls (
transform.ts) — the API ignores them anyway, so the per-turn miss tail stays text-only. - Keeps the full CoT echo for messages WITH tool calls — the 400-guard.
- Does not send
prompt_cache_keyfor the deepseek SDK route (dead field — never serialized, no isolation). - Replay cap (
tool_output.replay_max_chars, default 32K chars) + injection warn (>24 576 tokens) + prefix-reset warn — seeplans/2026-08-14-cache-miss-tail.mdandplans/2026-08-14-cache-alignment.md.
- Cold turn is unavoidable and dominant — a fresh session (or post-compaction prefix) pays ~full miss price once. Amortize by keeping sessions long-lived and stable.
- Compaction = one expensive re-prefill. The prefix-reset warn (P4) flags it; schedule compaction deliberately, not eagerly.
- Don't bother echoing historical CoT without tools — ignored and costs wire bytes.
- Never drop CoT for tool-call messages — 400 error.
- Miss tail = appended turn only when the prefix is stable: keep big stable blocks (system, tools) unchanged and order-stable; append new content at the end.
python experiments/2026-08-14_deepseek-test/deepseek_test.py --series ladder
python experiments/2026-08-14_deepseek-test/deepseek_test.py --series big --turns 6
python experiments/2026-08-14_deepseek-test/deepseek_test.py --series no_think --turns 4
python experiments/2026-08-14_deepseek-test/deepseek_test.py --series isolation
python experiments/2026-08-14_deepseek-test/deepseek_test.py --series all
Requires DEEPSEEK_API_KEY in env. Results land in experiments/2026-08-14_deepseek-test/results/ with per-turn rows + auto verification summaries (balance, lattice, hit ratio, cost).
Measured on d:/!!! over one day, cross-checked against the provider's own
/user/balance endpoint. Companion plan: plans/2026-10-04_files-api-and-tariff.md.
Off-peak rates are half the peak price. Peak is a wall-clock window —
01:00-04:00 and 06:00-10:00 UTC, Monday through Friday, public holidays excluded
(api-docs.deepseek.com, pricing footnote 2). getUsage branched only on a
context threshold and never on the time, so the model could not express a
time-varying tariff at all.
| tariff (deepseek-flash) | predicted for the day | against actual |
|---|---|---|
| off-peak (what the model used) | $1.074 | 55% low |
| PEAK | $2.149 | 10% low — closest |
Peak/off-peak is exactly 2.0; the measured gap was 2.22x. The arithmetic itself
was never wrong: recorded/computed was 1.000 to the last digit. provider/tariff.ts
now selects by UTC clock, the schedule lives in config beside the rates, and
DeepSeek's peak block is derived by doubling the base so a re-priced table
needs no code edit.
One captured request (245 120 cache-hit, 304 fresh, 4 009 completion):
| tokens | cost | share | |
|---|---|---|---|
| cache hit | 245 120 | $0.000735 | 23.1% |
| cache miss | 304 | $0.000046 | 1.4% |
| completion | 4 009 | $0.002405 | 75.5% |
4 009 output tokens cost as much as the entire 245k context. Reasoning was 59.5% of that output, and 82.3% of output on tool-calling turns. The bill is paid for thinking. Any optimisation aimed at the context or at images is aimed at the cheap 24.5%.
An image bills as at most 1024 tokens regardless of its pixel size (api-docs.deepseek.com vision docs), so fifteen base64 screenshots are ≤15 360 tokens — 6.3% of a 245k prompt — while being 5.77 MB of a 7.75 MB payload.
- Base64 in the request is not counted as text: 6.51 MB would be 1 705 723
text tokens against an actual
prompt_tokensof 638 553. Refuted by 2.7x. - But base64 in a TEXT block would be the catastrophe: ~205 000 tokens for one image versus ≤1 024 as a file block. Nothing guards that path; it wants a WARN.
- Consecutive screenshots repeat: 7 of 13
cuaframe pairs were pixel-identical, and only 2 of 13 had a diff small enough for a crop to pay. Skipping identical frames is a bandwidth win, not a money one.
POST /files (purpose=user_data, ≤64 MiB) → file_id, referenced as
{"type":"file","file_id":"…"}. The response carries no status field — there
is nothing to poll, so "ready" is established by asking (files.retrieve).
Limits: 25 GiB and 10 000 files per account; JPEG/PNG/GIF/WebP.
It removes 5.77 MB from every request and walks us away from the 48 MiB body limit. It saves almost no money, because a 1024-token image on a warm cache is $0.000046. Ranking it above reasoning as a cost optimisation would be wrong.
The residual 10% ($2.149 vs $2.390) is not explained locally. It needs
platform.deepseek.com/usage — the daily total, the model named on the charge,
and the per-token-type breakdown. Their cache-hit rate decides whether the
account is on peak pricing or the gap is in the provider's own deduction:
/user/balance returns only is_available and balance_infos, no tariff.
provider/balance.ts carries the alarm for this: it compares our model against
the real balance over an 8-snapshot window at 15% tolerance, and was silent the
whole time this drift was running.