provider-deepseek
v0.1.13DeepSeek Chat Completions provider worker; implements provider::deepseek::stream and provider::deepseek::refresh_models behind llm-router.
Install
iii trigger compose::add worker=provider-deepseek@0.1.13Run in your project with the iii engine running. Exact versions are immutable and digest-pinned.
provider-deepseek
DeepSeek Chat Completions provider worker behind llm-router.
Implements the provider protocol from
tech-specs/2026-06-agentic/llm-router.md: provider::deepseek::stream
(SSE chunks → AssistantMessageEvent frames into a router-owned channel) and
provider::deepseek::refresh_models (upstream GET /models, enriched with
local metadata → router::models::reconcile), plus
provider::deepseek::count_tokens (prompt token counting behind
router::count_tokens, with DeepSeek's own published vocabulary rather than
a borrowed one; never runs the model and costs nothing).
Default upstream: https://api.deepseek.com/chat/completions — DeepSeek's
OpenAI-compatible surface is rooted at the bare host, with no /v1 segment.
Override api_url to point at any other OpenAI-compatible endpoint; the
models listing is always read from that endpoint's /models sibling.
Behavior
- Registration: self-declares via
router::provider::registerwith backoff until acked, and re-declares on therouter::readytrigger type. The declaration carries no models andcredential_env_var: DEEPSEEK_API_KEY; the post-register refresh discovers the catalog, gated on a configured credential (no key → empty slice, so the picker never shows unusable rows). - Identity binding: the router returns a
registration_tokenon first registration; it is persisted in state (scopeprovider-deepseek, keyregistration_token) and presented on every laterregister/resolve/reconcile. If that state is lost the router rejects re-registration — the operator must clear the binding on the router side. - Credentials: resolved per request via
router::provider::resolve(config slice →DEEPSEEK_API_KEYenv on the router → none). Bothapi_keyandoauthcredential shapes are sent asAuthorization: Bearer; v1 performs no OAuth refresh. - Catalog:
GET /modelsowns the id list;src/curated.rssupplies what the listing does not carry — display names, context windows, output ceilings, pricing (USD per MTok) and thereasoning_effortvocabulary from api-docs.deepseek.com (snapshot 2026-09-10). The live ids aredeepseek-flash(DeepSeek-V4.1-Flash) anddeepseek-v4-pro; the retireddeepseek-v4-flashnames are still accepted by the API and get the Flash row's metadata under their own id. An id the table does not know still lands in the catalog on conservative defaults (64K context, 8K output, no pricing) rather than disappearing, so a model DeepSeek ships tomorrow is routable today. Prices are the peak rate — DeepSeek's list price; off-peak (every hour outside 01:00–04:00 and 06:00–10:00 UTC, Monday to Friday) is half of it, so reported cost is exact at peak and a 2x ceiling off-peak — a time-of-day multiplier is not something the catalog can express. DeepSeek retires V4 Pro on 2026-09-14 12:00 Beijing time: from thendeepseek-v4-prorequests are answered by V4.1 Flash and billed at the Flash price until V4.1 Pro ships; the row stays while the id is listed. - Liveness:
pingat least every 30s of upstream silence; a failed channel write (caller gone /router::abort) drops the SSE receiver and aborts the in-flight HTTP request. DeepSeek holds an overloaded request open with: keep-aliveSSE comments, which the decoder ignores. - Errors: 401/403 →
auth_expired, 402 (out of balance) →permanent(a billing wall retry cannot fix), 429 →rate_limited, 400/422 →permanentunless the message says the prompt did not fit,context_length_exceeded→context_overflow, 500/503 and network →transient.finish_reason: insufficient_system_resource— the upstream running out of capacity mid-generation — terminates the stream as atransienterror so the router retries instead of returning a silently truncated answer. No transport retries here: the router owns retry policy. - Structured output:
json_objectmode only — DeepSeek documents no strict json_schema mode. Aresponse_formatschema is dropped with a report-and-continue warning; every catalog record declaressupports_structured_output: false. The caller must mention "json" in the prompt per DeepSeek's rules. - Reasoning: with a requested thinking level the request carries
thinking: {type: enabled}plus the top-levelreasoning_effortparam — the router's five levels collapse onto DeepSeek's three-wide vocabulary asminimal/low→low,medium/high→high,xhigh→max(src/reasoning.rs; the API would coerce a literalxhighdown tohigh, so the mapping is done here). With no level both params are omitted, so each model runs its own documented default: the V4 family reasons athigheffort — an unconfigured console chat streams its chain of thought out of the box, with reasoning tokens billed as output — while a legacy non-thinking alias (deepseek-chat) keeps the behavior its name encodes. Off is the one knob the ladder cannot express: passprovider_options: { deepseek: { thinking: "disabled" } }on the send and the request carriesthinking: {type: disabled}with noreasoning_effort(athinking_levelon the same send is reported as ignored). It is never synthesised — an off-by-default would blank the console's thinking pane — and any value other thanenabled/disabledfails the turn instead of silently running at full effort. Each documented catalog row advertises the effort vocabulary asreasoning_efforts(low,high,max) sorouter::models::getshows what a level lands on. Reasoning models stream their chain of thought asreasoning_contentdeltas, which the worker surfaces asthinkingblocks (src/sse.rs), andcompletion_tokens_details.reasoning_tokenslands onusage.reasoning. Assistant thinking is replayed to the API asreasoning_contentonly on tool-calling messages — DeepSeek 400s a tool round whose intermediate reasoning was dropped — and nowhere else: the API documents replayed reasoning as ignored outside tool rounds, where it would only re-bill the whole chain as input on every later turn. - Block ordering: the assembled message is a sequence of blocks in the
order the model produced them, not one merged block per kind. A turn that
reasons, answers, reasons again and answers again lands as four blocks, and
a tool call sits between the blocks it actually fell between — matching what
the
thinking_start/text_start/… frames already described on the wire, and the same shapeprovider-anthropicassembles. DeepSeek's own response carries onereasoning_contentand onecontentper message, so in practice a turn is one thinking block then one text block; the ordering matters behind anapi_urloverride onto a gateway that genuinely interleaves. On the turns that replay at all (tool-calling ones — see Reasoning), replay flattens the blocks back to the two scalar fields the request shape has (thinking blocks joined, text blocks joined) — the only representation DeepSeek accepts. - Text only: DeepSeek documents no multimodal content-part array, so
image blocks are replaced with a text marker instead of being sent as
image_urlparts (which would fail the whole turn); the caller gets a report-and-continue warning and every known catalog record declaressupports_vision: false. - Prompt caching: on by default for every account, with no request
markers (context caching on disk). Hits land on shared request prefixes,
so the wire layer keeps a session's transcript append-only by
construction: every serialization rule depends only on a message's own
content (never on what follows it), which makes turn N's request body a
byte-stable prefix of turn N+1's; ignorable reasoning is dropped instead
of resent (see Reasoning); and no
user_idis sent, so all traffic shares one account-wide cache instead of partitioning it. The two deliberate exceptions mutate history for correctness and cost one cache bust each: a late tool result replacing its orphan placeholder, and latest-wins dedup of a duplicated result. DeepSeek reports the resulting prompt split directly, and it maps straight onto the spec's disjoint usage splits:prompt_cache_hit_tokens→usage.cache_read(billed at the cached-input rate) andprompt_cache_miss_tokens→usage.input(billed at the input rate). Because the router's cost fill adds the two,inputis deliberately the miss slice rather than theprompt_tokenstotal — with a ~120x cache discount, billing the cached prefix at both rates would roughly double the reported cost of a long agent loop.
Tests
cargo test # unit (pure modules + TCP stubs)
III_ENGINE_BIN=$(which iii) cargo test --test integration -- --test-threads=1The integration suite spawns a real engine, the real router (path dep), this
provider, and a local stub upstream serving both /models and
/chat/completions — no external API calls anywhere.
Running
The binary takes the standard worker CLI flags: --url (engine WebSocket,
default ws://127.0.0.1:49134, falls back to the III_URL environment
variable), --manifest (print the registry manifest and exit), and
--config (accepted but ignored with a warning — provider config comes
from the llm-router configuration entry).