Add DeepSeek as a first-class model provider - #1167
Conversation
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.
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.
Reasoning effort: measured, and now wired per modelFollow-up commit. I'd originally left effort Accepted enum (the API validates it; deepseek-v4-flash — reasoning tokens, n=5,
deepseek-v4-pro — So flash gets
This required making Live from a real daemon, no Also verified: a docs claim that would have caused a bugDeepSeek's thinking-mode guide states that on tool-carrying requests It does not reproduce. A 4-round tool loop dropping Separately: the Anthropic-compat endpoint accepts native Full workspace suite green. |

DeepSeek was reachable only by hand-declaring a
[smith.models.deepseek]profile, and the documented example still named the retireddeepseek-chatmodel. SettingDEEPSEEK_API_KEYdid nothing on its own.smith
deepseek:<model>prefix, served by the existing OpenAI-compatible client atapi.deepseek.com/v1— same wiring asgrok:, no new transport.deepseek*names route to DeepSeek (every model it serves isdeepseek-*, so it's unambiguous). Legacydeepseek-chat/deepseek-reasonerstill land there — the API maps them onto a current model.DEEPSEEK_API_KEYis 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/-flashadded to the sharedMODEL_COMPLETIONScatalog, which feeds both/modelcompletion and the router picker.Router — built-in target (spec 0179)
DEEPSEEK_API_KEYalone makes DeepSeek a route target for every route-capable harness, with nothing inconfig.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_000with 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_reloadsandpty_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:
Route list for a
claudesession (router.list_routesover IPC — the redirect menu is mouse-click-only, so this is shown as its underlying data):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
thinking: {"type":"disabled"}but has no verified level scale, soprofile_effort_supportstays at theUnsupporteddefault rather than claiming one.construct(touchesadapter-smith,daemon,cli,protocol— all one binary).🤖 Generated with Claude Code