Skip to content
Registry

provider-deepseek

v0.1.15

DeepSeek Chat Completions provider worker; implements provider::deepseek::stream and provider::deepseek::refresh_models behind llm-router.

license: Apache-2.0chat-completionsdeepseekllmprovider

Install

bash
iii trigger compose::add worker=provider-deepseek@0.1.15

Run 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::register with backoff until acked, and re-declares on the router::ready trigger type. The declaration carries no models and credential_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_token on first registration; it is persisted in state (scope provider-deepseek, key registration_token) and presented on every later register/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_KEY env on the router → none). Both api_key and oauth credential shapes are sent as Authorization: Bearer; v1 performs no OAuth refresh.
  • Catalog: GET /models owns the id list; src/curated.rs supplies what the listing does not carry — display names, context windows, output ceilings, pricing (USD per MTok) and the reasoning_effort vocabulary from api-docs.deepseek.com (snapshot 2026-09-10). The live ids are deepseek-flash (DeepSeek-V4.1-Flash) and deepseek-v4-pro; the retired deepseek-v4-flash names 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 then deepseek-v4-pro requests 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: ping at 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-alive SSE comments, which the decoder ignores. Generation must start within 120s of the upstream request, including the wait for HTTP headers. Keepalives, empty deltas, and usage-only chunks cannot extend that deadline. On expiry the provider closes the HTTP request and emits one transient error explaining that generation did not start; the model selection and catalog stay unchanged. The first text, reasoning, or tool-call content disarms this startup deadline, so a long answer continues under the normal router stream/idle budgets. Set PROVIDER_READ_TIMEOUT_SECS on the provider worker to change both this startup budget and the existing HTTP read-silence timeout (for example, when a self-hosted api_url needs a longer prompt evaluation).
  • Errors: 401/403 → auth_expired, 402 (out of balance) → permanent (a billing wall retry cannot fix), 429 → rate_limited, 400/422 → permanent unless 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 a transient error so the router retries instead of returning a silently truncated answer. No transport retries here: the router owns retry policy. Pre-generation diagnostics stay in error_message, with empty assistant content. A read failure after generation starts retains the streamed content, usage, and warnings so the Harness can preserve and resume the partial response.
  • Structured output: json_object mode only — DeepSeek documents no strict json_schema mode. A response_format schema is dropped with a report-and-continue warning; every catalog record declares supports_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-level reasoning_effort param — the router's five levels collapse onto DeepSeek's three-wide vocabulary as minimal/low → low, medium/high → high, xhigh → max (src/reasoning.rs; the API would coerce a literal xhigh down to high, 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 at high effort — 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: pass provider_options: { deepseek: { thinking: "disabled" } } on the send and the request carries thinking: {type: disabled} with no reasoning_effort (a thinking_level on 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 than enabled/disabled fails the turn instead of silently running at full effort. Each documented catalog row advertises the effort vocabulary as reasoning_efforts (low, high, max) so router::models::get shows what a level lands on. Reasoning models stream their chain of thought as reasoning_content deltas, which the worker surfaces as thinking blocks (src/sse.rs), and completion_tokens_details.reasoning_tokens lands on usage.reasoning. Assistant thinking is replayed to the API as reasoning_content only 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 shape provider-anthropic assembles. DeepSeek's own response carries one reasoning_content and one content per message, so in practice a turn is one thinking block then one text block; the ordering matters behind an api_url override 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_url parts (which would fail the whole turn); the caller gets a report-and-continue warning and every known catalog record declares supports_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_id is 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) and prompt_cache_miss_tokens → usage.input (billed at the input rate). Because the router's cost fill adds the two, input is deliberately the miss slice rather than the prompt_tokens total — 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=1

The 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).

Search the registry

Find workers, kits and executables, or jump to a page.