Skip to content

Add DeepSeek as a first-class model provider - #1167

Merged
edwin-zvs merged 4 commits into
mainfrom
feat-deepseek-provider
Aug 2, 2026
Merged

Add DeepSeek as a first-class model provider#1167
edwin-zvs merged 4 commits into
mainfrom
feat-deepseek-provider

Conversation

@edwin-zvs

Copy link
Copy Markdown
Contributor

DeepSeek was reachable only by hand-declaring a [smith.models.deepseek] profile, and the documented example still named the retired deepseek-chat model. Setting DEEPSEEK_API_KEY did nothing on its own.

smith

  • deepseek:<model> prefix, served by the existing OpenAI-compatible client at api.deepseek.com/v1 — same wiring as grok:, no new transport.
  • Bare deepseek* names route to DeepSeek (every model it serves is deepseek-*, so it's unambiguous). Legacy deepseek-chat / deepseek-reasoner still land there — the API maps them onto a current model.
  • DEEPSEEK_API_KEY is a new last rung on the auto-detect ladder → deepseek-v4-pro. Appended, never inserted, so no machine that already resolves changes provider.
  • deepseek:deepseek-v4-pro / -flash added to the shared MODEL_COMPLETIONS catalog, which feeds both /model completion and the router picker.

Router — built-in target (spec 0179)

DEEPSEEK_API_KEY alone makes DeepSeek a route target for every route-capable harness, with nothing in config.toml. This is a new concept: previously only OAuth logins auto-appeared and API-key endpoints had to be declared.

It is synthesized as an ordinary profile, so dialect translation, published-model ids, effort levels, and picker blockers needed no changes. A declared profile of the same name replaces it entirely, and with no key the target simply doesn't exist rather than showing as blocked.

Context window

DeepSeek had no entry in the context-window table, so it fell through to the generic 8K default on a model with a 1M window — it would have compacted almost immediately. Caught during live verification, not planned; now 1_000_000 with a regression test.

Verification

Full workspace suite green (cargo test --workspace, unfiltered, run both with and without the key). Two unrelated flakes seen once under build load and confirmed passing in isolation: service_supervisor::a_channel_binds_moves_and_releases_across_reloads and pty_render::many_small_events_stay_fast_as_history_grows (batches 0–3 flat at ~2.2ms, batch 4 spiked; ~2.2ms across all five in isolation).

Live against the real API — isolated daemon, empty config dir, every other provider key unset:

### smith availability on a DeepSeek-only machine
  [ok]     config    10 adapters, 0 smith model profile(s)
  [ok]     smith     ready (DeepSeek API key)

### no --model at all (auto-detect ladder)
  msg:Assistant AUTODETECT_OK

### deepseek: prefix — real tool call over the wire
  #13 tool shell
  #14 task-start shell call_00_cmJJfr3tvX9YlBeAAQuj1923

Route list for a claude session (router.list_routes over IPC — the redirect menu is mouse-click-only, so this is shown as its underlying data):

[with-key]  deepseek: dialect=openai-chat base_url=https://api.deepseek.com/v1
            selectable=True reason=None
            models=['deepseek-v4-pro', 'deepseek-v4-flash']
            all routes: [claude-oauth, codex-oauth, grok-oauth, kimi-oauth, deepseek]

[no-key]    deepseek: ABSENT from route list
            all routes: [claude-oauth, codex-oauth, grok-oauth, kimi-oauth]

Also verified end to end: streaming SSE, tool calling, and DeepSeek's Anthropic-compatible surface (documented as a profile option, not a second code path).

Notes for review

  • No reasoning-effort levels advertised. DeepSeek accepts thinking: {"type":"disabled"} but has no verified level scale, so profile_effort_support stays at the Unsupported default rather than claiming one.
  • Specs 0030 and 0071 updated so no design record contradicts the new behavior — 0030's example was DeepSeek-as-a-profile, which is now the wrong first thing to reach for.
  • Relevant binary: construct (touches adapter-smith, daemon, cli, protocol — all one binary).

🤖 Generated with Claude Code

DeepSeek was reachable only by hand-declaring a [smith.models.deepseek]
profile, and the documented example still named the retired deepseek-chat
model. Setting DEEPSEEK_API_KEY did nothing on its own.

smith gains a `deepseek:` prefix served by the existing OpenAI-compatible
client (api.deepseek.com/v1), bare-name routing for `deepseek*`, and a last
rung on the auto-detect ladder so a DeepSeek-only machine starts sessions
instead of erroring. Appended, not inserted, so no machine that already
resolves changes provider.

For other harnesses, DeepSeek is a built-in route target: the key alone puts
it in every redirect menu and native /model picker, with no config.toml. It
is synthesized as an ordinary profile, so dialect translation, published-model
ids, and picker blockers need no changes — and a declared profile of the same
name still wins. Recorded as spec 0179.

Also gives DeepSeek its real 1M context window; without an entry it fell to
the generic 8K default and would have compacted almost immediately.

Verified live against the API: streaming, tool calls, the deepseek: prefix,
no-model auto-detect, and the route appearing for a claude session with the
key set and vanishing without it.
@edwin-zvs

Copy link
Copy Markdown
Contributor Author

Smith running on DeepSeek with an empty config dir and no other provider key set. The modeline reads deepseek:deepseek-v4-pro, and the context gauge shows 22k/1.0M 2% — the 1M window from this PR rather than the 8K generic default it would otherwise have inherited.

smith on deepseek

Effort support was provider-wide, so DeepSeek could only be all-or-nothing.
Measured against the live API, its two models disagree: on deepseek-v4-flash
low/high/max produce cleanly separated reasoning lengths (medians 624 / 2055
/ 5859 tokens, n=5, low and high ranges disjoint), while deepseek-v4-pro
floors every level to one default (1235 / 922 / 1144, fully overlapping and
non-monotonic).

profile_effort_support now takes the model as well as the provider, so flash
advertises low/high/max with high as its default and pro advertises none
rather than a picker column that changes nothing. Resolving a route derives
its scale from the model it actually resolved, so an armed route can't carry
a sibling model's scale.

The levels left out are deliberate: the enum also accepts none, minimal,
medium and xhigh, but medium measured above high and xhigh below it, with
ranges overlapping too heavily at n=5 to order them honestly. `none` is a
verified off switch (0 tokens, 10/10 runs, both models) with no home in
Construct's graded scales; noted in spec 0160 for a future off position.

Spec 0160 updated: effort support is a property of target and model
together, and "verified" means measured, not documented.
@edwin-zvs

Copy link
Copy Markdown
Contributor Author

Reasoning effort: measured, and now wired per model

Follow-up commit. I'd originally left effort Unsupported as "no verified scale" — that was right for the provider as a whole but wrong for deepseek-v4-flash, which does grade the work.

Accepted enum (the API validates it; zzz is rejected and enumerates the set): none, minimal, low, medium, high, xhigh, max.

deepseek-v4-flash — reasoning tokens, n=5, max_tokens above any observed length so nothing truncates:

level median range
none 0 0–0
minimal 642 252–836
low 624 399–1218
xhigh 1475 880–2416
high 2055 1472–3069
medium 3021 1436–3494
max 5859 1548–7983

deepseek-v4-pronone = 0, then low 1235 / high 922 / max 1144: non-monotonic, fully overlapping ranges. No usable scale, matching DeepSeek's own docs (pro floors everything to high).

So flash gets ["low", "high", "max"] with high as default — in the matched-condition run low (399–1218) and high (1472–3069) don't overlap at all, and max medians ~3× high. Pro gets no column.

medium and xhigh are deliberately excluded: medium measured above high and xhigh below it, and the three ranges overlap too heavily at n=5 to order honestly. Offering Construct's standard Verbatim set [low, medium, high] would have presented an ordering the model doesn't deliver.

none is a genuine off switch (0 tokens, 10/10 runs, both models) but has no home in Construct's graded scales — recorded in spec 0160 as a candidate for a future off position rather than smuggled in as a level.

This required making profile_effort_support model-aware, and resolving a route's model once so the endpoint, armed model, and effort scale can't disagree.

Live from a real daemon, no config.toml:

deepseek: dialect=openai-chat base_url=https://api.deepseek.com/v1
          models=['deepseek-v4-pro', 'deepseek-v4-flash']
          efforts={"deepseek-v4-flash": ["low", "high", "max"]}

Also verified: a docs claim that would have caused a bug

DeepSeek's thinking-mode guide states that on tool-carrying requests reasoning_content must be echoed back or "the API will return a 400 error." Smith's OpenAI provider does not echo it.

It does not reproduce. A 4-round tool loop dropping reasoning_content every round completed cleanly on both models with no 400. Had I "fixed" this to match the docs I'd have added dead complexity to the shared OpenAI provider for every vendor it serves.

Separately: the Anthropic-compat endpoint accepts native thinking: {type: enabled, budget_tokens: N} and returns proper thinking blocks, so a provider = "anthropic" profile pointed at /anthropic maps onto the existing Thinking support if budgets are ever wanted.

Full workspace suite green.

@edwin-zvs
edwin-zvs merged commit 9c87c18 into main Aug 2, 2026
1 check passed
@edwin-zvs
edwin-zvs deleted the feat-deepseek-provider branch August 2, 2026 15:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant