cyberine
Providers

DeepSeek API (direct)

  • Kind: raw API, OpenAI-compatible Chat Completions
  • Official docs: https://api-docs.deepseek.com
  • Cyberine adapter: LoopAgentAdapter (ids loop-deepseek-direct, build worker loop-deepseek-build)
  • Preferred over OpenRouter when DEEPSEEK_API_KEY is set (also backs the LLM/adversarial judges).
  • Researched/verified: 2026-08-25 — confidence high. Every page cited here is fetched with curl and grepped as raw HTML, never via an LLM-summarizing fetch (a summarizing fetch drops table cells, which is how the reasoning_effort enum stayed wrong for two revisions). The 2026-08-25 pass (T679) re-read quick_start/pricing, api/create-chat-completion, guides/thinking_mode, guides/vision, guides/files_api, guides/responses_api, guides/tool_calls, guides/json_mode, guides/kv_cache, guides/fim_completion, guides/chat_prefix_completion, quick_start/agent_integrations/codex and updates, and re-probed the live API for every number below that a page cannot supply (enum error text, per-effort prompt_tokens, alias resolution, image usage shape, Files/Responses status codes).
  • Partial re-verify 2026-09-24: quick_start/pricing and updates re-read directly (not this file's usual curl, but not an LLM-summarizing fetch either) to correct the deepseek-v4-pro redirect claim and the Flash pricing — see the "Models" table and "Pricing" section. Everything else in this file is still dated 2026-08-25 and was not re-checked in this pass.

DELISTED 2026-07-24, but NOT dead on the wire — corrected 2026-08-25 (T679). The legacy ids deepseek-chat and deepseek-reasoner were announced for discontinuation on 2026-04-24 (api-docs.deepseek.com/updates, "DeepSeek-V4 Release") with a 2026-07-24 15:59 UTC cutoff, and that cutoff HAS passed — the sunset is a past-tense fact, not a pending deadline. They are gone from GET https://api.deepseek.com/models, which returns exactly deepseek-v4-flash, deepseek-v4-pro and deepseek-v4-flash-vision-exp, and they are absent from the /api/create-chat-completion model enum. But POST /chat/completions still accepts both — live-probed 2026-08-25, six calls, all HTTP 200 (see "Legacy aliases" under Gotchas). Earlier revisions of this file, and the config.ts comment + CLI message that cite it, claimed "every call using it fails at the provider"; that claim is FALSE and is corrected here and in src/core/config.ts.

The pin is still wrong to use, on grounds that survive the correction: the ids are undocumented, unlisted and unannounced, so DeepSeek can drop them without any further notice; and both now resolve to the SAME model (deepseek-v4-flash), so deepseek-reasoner no longer selects a distinct reasoner — it only preselects thinking mode. Cyberine's own defaults are already on the V4 names (deepseek-v4-pro / deepseek-v4-flash — see config.ts deepseekModel, judgeModel, openrouterModel, openCodeModel, smartRouterModel), so no shipped default is affected. If DEEPSEEK_MODEL, JUDGE_MODEL, CYBERINE_JUDGE_MODEL*, OPENROUTER_MODEL, OPENCODE_MODEL, or CYBERINE_ROUTER_MODEL is set to a legacy id, every subcommand that would actually dispatch or spend hard-stops at startup with a one-line CLI error (T681, 2026-08-24). That hard stop is KEPT after the T679 correction — it is now justified by "unlisted, unversioned and silently collapsed onto flash" rather than by a guaranteed 400. cyberine doctor and cyberine config are exempt from the stop — they are the commands that diagnose/inspect a config, so doctor reports the same pin as a [FAIL] line instead of dying before it can. Switch it to deepseek-v4-pro or deepseek-v4-flash.

Base URL & auth

Base URLhttps://api.deepseek.com (OpenAI-compatible)
Anthropic-format basehttps://api.deepseek.com/anthropic
Beta (strict tool calling)https://api.deepseek.com/beta
Auth headerAuthorization: Bearer <DEEPSEEK_API_KEY>
Keyshttps://platform.deepseek.com (funded directly; accepts PayPal)

Endpoints

All paths are relative to https://api.deepseek.com. Every one below was called live on 2026-08-25 (T679) and returned the status shown.

Method + pathPurposeLive status
POST /chat/completionsChat completions (OpenAI-compatible). The endpoint Cyberine calls via the openai SDK.200
POST /responsesOpenAI Responses API, natively supported (shipped for deepseek-v4-flash 2026-07-31, for deepseek-v4-pro 2026-08-13). POST /v1/responses is the same endpoint — both 200, so an SDK that appends /v1 works unchanged. See "Responses API" below.200
GET /modelsList available model ids. Returns exactly the three V4 ids — no legacy alias.200
POST /filesFiles API upload (multipart). Returns 200, not 201.200
GET /filesList uploaded files (after / limit 1-1000 / order / purpose).200
GET /files/{file_id}Retrieve one file's metadata.200
DELETE /files/{file_id}Delete a file. Returns {"id":..., "object":"file", "deleted":true}.200
POST /anthropic/v1/messagesAnthropic-format chat (base https://api.deepseek.com/anthropic).not re-probed
POST / GET / DELETE /anthropic/v1/files[/{id}]Anthropic-format Files API. Requires anthropic-beta: files-api-2025-04-14; the Anthropic SDK appends /v1 itself, a plain curl must write the full path.not re-probed
POST /beta/chat/completions, POST /beta/completionsBeta base URL: strict tool schemas, chat prefix completion, FIM.not re-probed

Models

Model idNotes
deepseek-v4-flashGA DeepSeek-V4-Flash-0731 (2026-07-31). 1M context, 384K max output. Fast/economical. Dual thinking + non-thinking. Concurrency 2500. Parameter count: see the unverified note below.
deepseek-v4-proGA DeepSeek-V4-Pro-0813 (2026-08-13). 1M context, 384K max output. Higher capability. Concurrency 500. Parameter count: see the unverified note below.
deepseek-v4-flash-vision-expDeepSeek-V4-Flash-Vision-Exp (shipped 2026-08-21). DeepSeek's ONLY vision-capable id — the other two hard-400 on an image ("This model does not support image"). 1M context, 384K max output, Concurrency 2500 — and priced identically to deepseek-v4-flash on every row (see Pricing). One capability gap vs the other two: FIM Completion (Beta) is "Not supported" here, where flash and pro both support it in non-thinking mode (live pricing/model-details table, 2026-08-25). Every other listed feature (Json Output, Tool Calls, Responses API, Anthropic API, Chat Prefix Completion (Beta)) is a checkmark for all three.
deepseek-flashDeepSeek-V4.1-Flash, released 2026-09-10. Announced on the updates page the same day: deepseek-v4-flash and deepseek-v4-flash-vision-exp are retired and their names are now ALIASES, temporarily routed to V4.1-Flash and billed at this model's price. deepseek-v4-pro is NOT redirected — the same 2026-09-10 changelog entry says, verbatim, "we have decided to continue providing API services for DeepSeek V4 Pro after September 14, 2026, with the billing method remaining unchanged" (an earlier revision of this row claimed the opposite; corrected 2026-09-24 after re-reading the changelog and pricing pages directly). Wire-confirmed 2026-09-24: GET /models returns exactly [deepseek-flash, deepseek-v4-pro]; model: deepseek-v4-flash returns response.model: deepseek-flash billed at the flash price, and model: deepseek-v4-pro returns response.model: deepseek-v4-pro unchanged. Pricing per 1M, off-peak/peak: cache-hit 0.003/0.006, cache-miss 0.15/0.30, output 0.60/1.20 — in modelPricing.ts since k9tvx.
deepseek-chatDELISTED at the 2026-07-24 15:59 UTC cutoff, still ACCEPTED on the wire. Absent from GET /models and from the model enum on /api/create-chat-completion, but POST /chat/completions returns 200 and the response body says "model": "deepseek-v4-flash" — i.e. it is still an alias → flash, non-thinking (live-probed 2026-08-25, T679; the earlier "every call fails at the provider" claim was wrong). Do not pin it: see the callout at the top.
deepseek-reasonerSame status, same cutoff, same live result: 200, resolves to deepseek-v4-flash, thinking mode preselected. It no longer selects a different model from deepseek-chat — only a different default thinking state.

Cyberine default: DEEPSEEK_MODEL = deepseek-flash (config.ts, since k9tvx 2026-09-11).

RESOLVED 2026-09-11 (k9tvx), live-probed on a funded account. The deadline callout that stood here is settled, and its central worry was wrong. GET /models now returns exactly [deepseek-flash, deepseek-v4-pro]. POST /chat/completions accepts deepseek-flash, deepseek-v4-flash, deepseek-v4-pro and deepseek-v4-flash-vision-exp — all four HTTP 200, no 400 anywhere — but the response model field reads deepseek-flash for every one of them except deepseek-v4-pro, which still returns itself. So the delisted ids do not reject, they alias silently, which is the worse failure and the reason DEEPSEEK_LEGACY_MODEL_IDS now covers deepseek-v4-flash and deepseek-v4-flash-vision-exp.

The default therefore moved to deepseek-flash everywhere (config.ts, workerProfiles.ts's model and BOTH variants, and the operator's .cyberine/fleet.json) — pinning the one real, wire-confirmed id over an aliasing legacy name.

CORRECTED 2026-09-24. This paragraph previously assumed deepseek-v4-pro would itself be redirected to Flash from 2026-09-14 04:00 UTC, so pinning it bought only three more distinct days and modelPricing.ts's pro row would become a stale 3.3x over-count once the redirect landed. That did not happen: DeepSeek's 2026-09-10 changelog says V4 Pro's API service continues past 2026-09-14 with billing unchanged, and a live probe on 2026-09-24 confirms deepseek-v4-pro still returns itself, not Flash. modelPricing.ts's deepseek-v4-pro row was correct all along. loop-deepseek-build's reasoning variant is pinned to deepseek-v4-pro again as of this correction — see workerProfiles.ts.

RESOLVED 2026-09-11 (k1k86), live image-probed same funded account, same day. The prior paragraph's worry is confirmed and no longer open: a real chat/completions call to deepseek-v4-flash-vision-exp carrying an actual image part (a 1x1 PNG data URL, one request) returned HTTP 200, response.model = deepseek-flash, and a confident one-word answer ("White") to "what single color is this image" for a fully transparent pixel — the alias does not reject or describe-as-unseen, it silently drops the image and answers anyway. isMultimodalModel() in workerProfiles.ts no longer matches this bare id, so DeepSeek's build-fleet router gate (hasImages in router.ts, already excluding every DeepSeek profile — see "Routing scope" below) and the manual-pin default-compare-adapter path (buildAdapters.ts / architectEditor.ts) both now fall back to LoopAgentAdapter's multimodal: false branch, which visibly tells the worker its image attachments were not forwarded instead of silently sending them to a model that cannot see them. modelPricing.ts's deepseek-v4-flash-vision-exp row was also stale (still the pre-dealiasing flash rate) and now mirrors the current deepseek-flash price, since the id still dispatches and bills as that model. See "Vision" and "Routing scope" below for what this closes.

UNVERIFIED — parameter counts (T687, 2026-08-25). Earlier revisions of this table stated deepseek-v4-flash = "284B total / 13B active" and deepseek-v4-pro = "1.6T total / 49B active" with no source cited. Re-checked 2026-08-25 by curl-ing the two official pages this doc treats as authoritative — api-docs.deepseek.com/quick_start/pricing and api-docs.deepseek.com/updates — and grepping both for 284, 13B, 1.6T, 49B, and for any "total/active params" phrasing: zero hits on either page, while the same fetches DID carry the live V4 model ids and the pricing/context columns (so this is a genuine absence, not a JS-rendered page the grep missed). DeepSeek's API docs do not publish parameter counts at all. The figures are therefore removed from the model rows above rather than restated: treat any parameter count for these models as unsourced third-party reporting until a primary source (a DeepSeek model card or release paper) is found and cited HERE. Nothing in src/ reads or depends on a parameter count — routing and cost use contextWindow + modelPricing.ts rates only.

Key request params

  • model (required). The /api/create-chat-completion schema's enum is now, verbatim, Possible values: [deepseek-v4-flash, deepseek-v4-pro, deepseek-v4-flash-vision-exp] — the vision id is in the chat-completions enum, it is not a separate endpoint. Passing an id outside the enum returns HTTP 400 "The supported API model names are deepseek-v4-pro, deepseek-v4-flash, and deepseek-v4-flash-vision-exp, but you passed <x>." (live-probed 2026-08-25) — note that the two legacy aliases are NOT in the enum yet do NOT hit this error.

  • messages (required: roles system/user/assistant/tool). content is a plain string OR an array of content blocks — {"type":"text"}, {"type":"image_url","image_url":{"url":..., "detail":...}}, {"type":"file","file_id":...} or {"type":"file","file_data":..., "filename":...}. Image and file blocks are vision-only and user-message-only; see "Vision".

  • thinking: {type:'enabled'} (default) or {type:'disabled'} — thinking-mode toggle ONLY, does not carry the effort level (correction 2026-08-21: earlier revisions of this doc nested reasoning_effort inside thinking; the live page shows it as a sibling top-level field). Re-confirmed by probe 2026-08-25: the nested form is accepted with 200 and has no effect — see the reasoning_effort entry below for why that made the error survive so long.

  • reasoning_effort (top-level, sibling of thinking, NOT nested inside it): controls thinking depth. The enum has SEVEN values, not six — minimal was missing from every earlier revision of this doc. Corrected 2026-08-25 (T679). The authoritative list is the API's own deserialization error, not the docs page: reasoning_effort:"bogus" on deepseek-v4-pro returns HTTP 400 with, verbatim,

    Failed to deserialize the JSON body into the target type: reasoning_effort: unknown variant
    `bogus`, expected one of `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max` at line 1
    column 97

    The docs page is NOT the source here and disagrees in both directions. /api/create-chat-completion displays only three values but states the aliasing verbatim: "Possible values: [low, high, max] — Controls the reasoning effort of the model. The default effort is high. For compatibility, medium and xhigh are mapped to high." /guides/thinking_mode shows {"reasoning_effort": "low/high/max"} in its toggle table yet its footnote (2) maps FIVE inputs (low, medium, high, xhigh, max). Neither page displays none or minimal anywhere. So the 2026-08-13 changelog's "three thinking effort levels: low / high / max" named the canonical BUCKETS, it did not remove the aliases — do not narrow this list to three.

    Anthropic-format equivalents (verbatim from /guides/thinking_mode's "Thinking Mode Toggle and Effort Control" table):

    PurposeOpenAI formatAnthropic formatResponses API format
    Thinking-mode toggle{"thinking":{"type":"enabled"/"disabled"}}{"reasoning":{"effort":"none/low/high/max"}} (none disables thinking mode){"reasoning":{"effort":...}}
    Thinking effort{"reasoning_effort":"low/high/max"}{"output_config":{"effort":"low/high/max"}}{"reasoning":{"effort":...}} — effort supported, summary accepted but never generated

    Nesting reasoning_effort inside thinking is silently ignored (live-probed 2026-08-25). {"thinking":{"type":"enabled","reasoning_effort":"none"}} and ..."max" both return 200 and both land on the DEFAULT bucket; even {"thinking":{"reasoning_effort":"bogus"}} returns 200, where the same bogus value at top level 400s. There is no error and no effect — the exact failure mode that let an earlier revision of this doc document the nested shape for months. Put it at top level.

  • reasoning_effort — what the live probe measures, and the two metrics it takes to see it. Re-probed 2026-08-25 (T679) on both v4 models, prompt fixed at "What is 2+2?", max_tokens fixed. Two metrics, each answering a different question; conflating them gets the grouping wrong.

    (a) Bucket membership — prompt_tokens on a fixed short prompt, i.e. the size of the boilerplate DeepSeek injects for a given effort. Fully deterministic: 3 runs x 7 values x 2 models produced 42/42 identical readings, and the numbers reproduce on a second machine with only a constant offset for prompt length (that run, on a 5-token-shorter prompt, read 6 / 85 / 98 with omitted=85):

    reasoning_effortprompt_tokens (v4-pro)prompt_tokens (v4-flash)Bucket
    none1111injects nothing
    minimal1111injects nothing
    low1111injects nothing
    medium9090identical to high, to the token
    high9090identical to medium/xhigh, to the token
    xhigh9090identical to high, to the token
    max103103its own value
    omitted9090lands on the high bucket
    thinking:{type:'disabled'}1111(control — same as the no-injection bucket)

    Two things this table settles that prose could not. The medium/xhigh cells matching high to the token is a deterministic confirmation of the schema page's "medium and xhigh are mapped to high", not an inference from it. And omitted landing on that same 90 is stronger evidence that the default is high than the page's own sentence — it is the observable, and it agrees.

    What this metric CANNOT do: separate none from low, or minimal from low. All three inject nothing and are indistinguishable at 11 tokens. The minimal-vs-low relationship is therefore left unasserted here — nothing available separates them, and guessing would be the same mistake that produced the six-value list.

    (b) Is the parameter HONOURED at all — presence of reasoning_content. This is the only signal that catches a silently-ignored effort field, the failure mode where a value is accepted, changes nothing, and looks identical to working. 4 runs each on deepseek-v4-pro:

    reasoning_effortreasoning_content length, 4 runs
    none0, 0, 0, 0 — and completion_tokens_details.reasoning_tokens absent entirely
    minimal115, 101, 61, 103
    low113, 69, 103, 102

    So none genuinely disables CoT while minimal and low genuinely produce it, even though all three are identical on metric (a).

    NEVER use reasoning-TOKEN counts for either job. On an easy prompt they do not separate the buckets at all: an independent run measured low=81, medium=24, high=62, xhigh=75, max=33 — pure sampling noise, ordered wrongly, and reproducible only as noise.

    Cyberine's LoopAgentAdapter never sets this field today; since T692 (2026-08-25) it also no longer forces thinking:{type:'disabled'} on the tool path — see "Thinking mode + tool calling" in Gotchas for what actually constrains that combination. reasoning_effort is accepted alongside tools + tool_choice:'auto' at every level (minimal|low|medium|high probed live, all 200 with a correct tool call). reasoning_effort=low is accepted end-to-end with no code change needed — grepped the whole src/ tree for a rejecting enum/zod schema around DeepSeek's own reasoning_effort and found none (the one 'high'|'xhigh'|'max'-shaped Set in config.ts gates Claude Code CLI's unrelated --effort flag, and already includes 'low'; OpenCode's --variant flag is a free string with no enum gate either).

  • stream + stream_options.include_usage — Cyberine sets both; usage (incl. reasoning_tokens) appears in the final chunk

  • max_tokens (caps total output including CoT tokens — so in thinking mode a tight cap can be spent entirely on CoT and return finish_reason:'length' with NO tool call at all; measured thresholds in "Thinking mode + tool calling" below), temperature (default 1; unsupported in thinking mode — though it is silently ACCEPTED, not rejected, alongside tools), top_p

  • tools (max 128), tool_choice (none/auto/required/named). In thinking mode only none, auto and an omitted tool_choice are accepted: required AND a named function both return HTTP 400 "Thinking mode does not support this tool_choice". See Gotchas.

  • response_format.type: text | json_object

  • stop (≤16), logprobs, top_logprobs (≤20), user_id (charset [a-zA-Z0-9\-_], ≤512 chars; also drives KVCache and scheduling isolation), prefix (beta)

  • frequency_penalty and presence_penalty are DEPRECATED and no-op. The schema page marks both deprecated with, verbatim, "This parameter is no longer supported. It will not take effect if you pass it to the API." They are still ACCEPTED, not rejected: frequency_penalty:1.5 and presence_penalty:1.5 each returned HTTP 200 on deepseek-v4-pro (live-probed 2026-08-25). So passing them is silent dead weight, never an error. Independently, /guides/thinking_mode lists them alongside temperature/top_p as having no effect in thinking mode.

  • prompt_tokens_details.cached_tokens is returned in usage on every chat-completions response — present in all ~60 live probe responses in the T679 pass, including non-thinking, vision and zero-cache-hit calls. It is the OpenAI-shaped mirror of prompt_cache_hit_tokens and is documented nowhere on api-docs.deepseek.com (/guides/kv_cache names only the two prompt_cache_* fields). Treat it as observed-but-unspecified: safe to read, not safe to rely on.

Notable features

Every claim in this section was re-read against its own guide page on 2026-08-25 (T679); where the page has changed since this doc last quoted it, the delta is called out inline.

  • Streaming: SSE, terminates with data: [DONE] on Chat Completions. The Responses API is different — it has NO [DONE], it ends with response.completed / response.incomplete / response.failed. See "Responses API".
  • Reasoning/Thinking: both v4 models support dual mode. CoT streams as chunk.choices[0].delta.reasoning_content (a DeepSeek extension, not in the OpenAI SDK delta type — Cyberine reads it via a cast).
  • Tool calling: supported, including with thinking left enabled — tool_calls and reasoning_content come back in the same message (measured 2026-08-25, T692; full matrix in Gotchas). /guides/tool_calls AGREES on this point and always did: "From DeepSeek-V3.2, the API supports tool use in the thinking mode." (Correction 2026-08-25/T679: an earlier revision of this bullet said "DeepSeek's own docs say otherwise and are wrong on this point" — that is true only of the reasoning_content-echo 400, documented under Gotchas, not of tools-with-thinking as such.) The one real restriction is on tool_choice, not on tools as such.
  • Strict tool schemas (/beta base URL + "strict": true per function): re-read 2026-08-25. /guides/tool_calls now states explicitly that strict mode "is supported by both thinking and non-thinking mode" — this doc previously left that open. Supported JSON-Schema surface, verbatim from the page: object, string, number, integer, boolean, array, enum, anyOf, plus $ref/$def for reuse and recursion. Per-type limits worth knowing before writing a schema: string supports pattern and format (email, hostname, ipv4, ipv6, uuid) but not minLength/maxLength; array does not support minItems/maxItems; number/integer support const, default, minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf. Every object must set all properties required and additionalProperties: false.
  • Context caching: on by default; reported as prompt_cache_hit_tokens / prompt_cache_miss_tokens (NOT the Anthropic/OpenAI cache_read_input_tokens). The hit rule changed and /guides/kv_cache now documents it in terms of Sliding Window Attention: a cached prefix is "an independent, complete unit" and "a subsequent request can only hit the cache if it fully matches a cache prefix unit" — it is no longer a plain longest-common-prefix match. Units are persisted at request boundaries (one at the end of the user input, one at the end of the model output), by common-prefix detection across multiple requests, and at fixed token intervals for long inputs/outputs. Consequence for a fan-out: two requests sharing prefix A but diverging (A+B, A+C) BOTH miss; only after the system notices the shared A and persists it does a third A+D hit. Best-effort, never guaranteed; construction takes seconds, eviction "usually within a few hours to a few days".
  • JSON mode (response_format:{"type":"json_object"}): re-read 2026-08-25. Two page-stated requirements this doc previously omitted — the prompt must contain the word "json" (system or user) and should show an example of the desired shape, and max_tokens must be set high enough that the JSON is not truncated mid-string. The page also carries a standing caveat, verbatim: "When using the JSON Output feature, the API may occasionally return empty content."
  • FIM Completion (Beta) — non-thinking only, /beta base URL, and not at all on deepseek-v4-flash-vision-exp (its live feature row reads "Not supported"). Newly quoted here: "The max tokens of FIM completion is 4K", independent of the models' 384K normal ceiling.
  • Chat prefix completion (Beta) — /beta base URL; the LAST message must have role: "assistant" and prefix: true. Re-read 2026-08-25, unchanged.
  • Vision: deepseek-v4-flash-vision-exp only. Three input paths, a hard 384-token-per-image ceiling, and no separate image-token billing axis — see the dedicated section below.
  • Files API: POST/GET/DELETE /files, purpose=user_data, file-api-* ids — see below.
  • Responses API: natively supported, with a documented Codex integration — see below.
  • OpenAI-SDK and Anthropic-SDK compatible (swap base URL).

Vision

deepseek-v4-flash-vision-exp is the only id that accepts images; source https://api-docs.deepseek.com/guides/vision, re-read and live-probed 2026-08-25 (T679).

DEALIASED 2026-09-11 (k1k86) — accepts but does not see. This is now the only id isMultimodalModel() in workerProfiles.ts does NOT treat as vision-capable, because it no longer is: GET /models delisted it (k9tvx, same day) and it resolves on the wire to deepseek-flash, which has never accepted images. A live probe carrying an actual image part got HTTP 200 and a confident but ungrounded answer, not a rejection — the everything-below-this- callout describes the format that used to work and may still be accepted syntactically, but the model behind the id no longer reads any of it. Do not pin this id for vision; there is currently no DeepSeek-direct id that genuinely accepts images.

Supported formats: JPEG, PNG, GIF, WebP — "detected from the actual file content, not from the file name or the declared MIME type."

Three input paths (all on the standard content-array shape, all also available on the Responses API as input_image parts):

PathShapeCeiling
Inline base64{"type":"image_url","image_url":{"url":"data:image/png;base64,..."}}32 MiB per image; counts toward the 48 MiB body limit
Public https URL{"type":"image_url","image_url":{"url":"https://..."}}URL ≤8192 chars, image ≤32 MiB, download must complete within 60 s
Files API file_id{"type":"file","file_id":"file-api-..."}up to 64 MiB — "not subject to the 32 MiB per-image check"

A file block may alternatively carry the bytes inline as file_data (a data: URL) plus filename; file_data and file_id are mutually exclusive, and filename is not allowed together with file_id.

detail (on image_url inputs only, ignored when the image comes from a file_id):

ValueBehavior (verbatim)
low"The image is downscaled to 512×512 before inference. Faster and cheaper when fine visual detail is not important."
high"Keeps the original image. (Provided for compatibility; equivalent to original.)"
original"Keeps the original image."
auto"Automatic selection. Currently equivalent to original."

So only low actually changes anything today; the other three are the same behaviour under three names. Live-probed: detail:"low" returns 200.

Token usage — a hard 384-token ceiling per image, billed as ordinary input tokens. Verbatim: "Images are converted into tokens based on their dimensions, and these tokens are billed together with your text tokens." Every image is resized before inference — anything under roughly 384×384 is scaled UP, anything larger is scaled DOWN to roughly an 800×800 pixel budget, both preserving aspect ratio — so "there is an upper bound of 384 tokens per image: for example, a 2000×2000 image and a 5000×5000 image consume the same number of tokens after resizing." Multiple images are each counted independently under the same rule; there is no multi-image discount or special case. The docs ship an interactive image-token calculator on /quick_start/token_usage for a specific width/height.

There is NO image-specific usage field. Live dispatch 2026-08-25 (T679), one 8×8 RGB PNG plus a one-line text prompt to deepseek-v4-flash-vision-exp, HTTP 200; the entire usage object was:

{
  "prompt_tokens": 203,
  "completion_tokens": 32,
  "total_tokens": 235,
  "prompt_tokens_details": { "cached_tokens": 0 },
  "completion_tokens_details": { "reasoning_tokens": 32 },
  "prompt_cache_hit_tokens": 0,
  "prompt_cache_miss_tokens": 203
}

No image_tokens key or any variant, anywhere in usage or in the response body; the image cost is folded into prompt_tokens, and prompt_cache_miss_tokens equals prompt_tokens exactly. (T683's earlier probe of the same shape read prompt_tokens: 211 with a slightly different prompt — that object is the one pinned in the smoke; this run re-confirms the SHAPE, which is what matters.) Consequence for cost accounting: nothing image-specific to track — see Pricing.

Limits (verbatim table from /guides/vision):

LimitValue
Supported formatsJPEG, PNG, GIF, WebP
External URL length8192 characters
Request body size48 MiB
Max single image size (base64 / external URL)32 MiB
Max single image size (Files API file_id)64 MiB
Max images per request600
Max total image size per request64 MiB without file_id images; up to 200 MiB including file_id images
Max image dimension8192 px per side; drops to 4096 px per side when a request contains 15 or more images

Restrictions — both live-confirmed 2026-08-25 with the exact wire message:

ViolationHTTPVerbatim message
Image in a system message400Image in system message is unsupported
Image on deepseek-v4-pro / -flash400This model does not support image

Images are accepted in user messages only — the page states system and assistant both 400 (only the system case was probed here). User text containing DeepSeek's reserved image-placeholder token is also rejected with a 400.

Anthropic format. The same three paths exist on https://api.deepseek.com/anthropic, with an image block carrying a source object instead of image_url: source.type is base64 (requires media_type), url (≤8192 chars), or file (requires header anthropic-beta: files-api-2025-04-14).

Routing scope — DECIDED 2026-08-28 (T689): manual pin, never a routed capability

CLOSED 2026-09-11 (k1k86): the manual-pin escape hatch this section describes no longer has a real destination. deepseek-v4-flash-vision-exp dealiased to deepseek-flash, which does not see images (see the "Vision" section callout above). isMultimodalModel() no longer recognises this bare id, so a manual pin now hits LoopAgentAdapter's text-only branch (visible "images not forwarded" note) instead of the silent-blind-answer behaviour the probe measured. Separately, any env-based pin (DEEPSEEK_MODEL etc.) already hard-stops at CLI startup — config.ts's DEEPSEEK_DIRECT_DEAD_MODEL_IDS covers this id since k9tvx. There is currently no DeepSeek-direct id this repo can point either mechanism at; the four reasons below (why vision stayed a manual pin rather than a routed axis) are kept for history but their premise — that a working manual pin exists — no longer holds.

DeepSeek vision is a manual-pin escape hatch. It will not become a routed capability. T680 already shipped the whole manual-pin half: isMultimodalModel() recognises deepseek-v4-flash-vision-exp, so a pinned model carries its images through instead of silently dropping them, and the TUI's @-mention attach path (ChatApp.tsx) gates on the same function. That is the supported way to use DeepSeek vision, and it is sufficient. Four reasons, in the order that decided it:

  1. The routed axis would have to be selected against the user's own tier signal. variants is keyed by task TIER (reasoning / flash) and resolved by resolveVariantModels() in router.ts. A variants.vision axis would have to WIN over the tier pick, so a complex image task would be silently downgraded from deepseek-v4-pro to an experimental flash id — trading reasoning depth for image reading without the router having any way to know which the caller wanted. Vision is not a tier, so it does not belong on an axis selected by tier.
  2. The one vision id is experimental and capability-reduced. deepseek-v4-flash-vision-exp carries an -exp suffix and is the only DeepSeek id where FIM Completion is "Not supported". Routing to it autonomously would make an experimental model the automatic answer for a whole input shape.
  3. The naive shortcut stays un-taken, and the gate is already safe. No DeepSeek profile declares multimodal: true (verified: the eight that do are the kimi / openai / gemini / xai / azure / copilot / claude / gpt build workers). So the hasImages gate in route() already filters every DeepSeek worker out of an image task, and the hard 400 the task warned about cannot happen. Declaring multimodal: true on loop-deepseek-build — the shortcut — is what would CREATE that bug, because the tier variant would then resolve deepseek-v4-pro.
  4. There is no consumer. Images reach this repo only through a human pinning a model, which is the path that already works. Adding a shared-type change across every profile that declares variants, plus crossing the image gate with variant resolution, plus the adapterMultimodal call sites in buildAdapters.ts, buys nothing that anything asks for today.

What would reopen it: a dispatch surface that receives images without a human choosing a model — e.g. an MCP or web-API caller attaching a screenshot to a task — AND a non-experimental DeepSeek vision id that is reachable from both tiers. Both, not either.

Files API

Source https://api-docs.deepseek.com/guides/files_api, re-read and live-probed end-to-end 2026-08-25 (T679): upload → list → retrieve → reference in a chat call → delete, every step 200.

  • POST /files is multipart/form-data. Required fields: file, and purpose — which must be user_data (purpose=assistants returns 400 The 'assistants' purpose is not supported. Supported purposes: ["user_data"]). Optional expiry is a PAIR: expires_after[anchor] (must be created_at) and expires_after[seconds], between 3600 and 2592000 (1 hour to 30 days). Omit both to keep the file permanently. Upload must complete within 10 minutes.

  • It returns HTTP 200, not 201. Confirmed live; worth pinning because the OpenAI-shaped create-a-resource convention would suggest 201 and a strict client could reject the real answer.

  • Ids are file-api-<uuid>, e.g. file-api-adc4918a-6eda-4db9-8d20-00b90082631a — that is the literal prefix, not a placeholder. Live upload response:

    {"id":"file-api-adc4918a-...","object":"file","bytes":165,"created_at":1787599739,
     "filename":"t679-8x8.png","purpose":"user_data"}

    With expires_after set, the response gains "expires_at": <unix ts>; it is absent otherwise.

  • GET /files takes after (a file_id cursor), limit (1–1000, default 1000), order (asc default / desc) and purpose. Returns {"object":"list","data":[...],"first_id", "last_id","has_more"}.

  • GET /files/{id} returns the same file object. DELETE /files/{id} returns {"id":...,"object":"file","deleted":true}.

  • Reference it from a chat request with a file content block — {"type":"file","file_id":"file-api-..."} — on deepseek-v4-flash-vision-exp. Live-probed: 200. Files belong to the API key and can be referenced from either API family.

Limits (verbatim): max upload 64 MiB; max filename 512 characters; max storage per user 25 GiB; max stored files per user 10000; expiration 1 hour to 30 days, or permanent.

Anthropic-compatible variant at https://api.deepseek.com/anthropic/v1/files, requiring anthropic-beta: files-api-2025-04-14 on every request, differs in shape: pagination is after_id/before_id (mutually exclusive) instead of after; limit defaults to 20 instead of 1000; order/purpose are not supported; there is no top-level "object":"list"; the size field is size_bytes not bytes; the type field is type not object; created_at is an RFC 3339 string not a Unix timestamp; and a delete returns {"id":..., "type":"file_deleted"}.

Cyberine touch-point: none today. No adapter, tool or judge uploads files or sends images — LoopAgentAdapter builds text-only content strings. This section is reference for the first caller that needs it, not a description of shipped behaviour.

Implementation scope — DECIDED 2026-08-28 (T690): deliberately DEFERRED

Files API support is deliberately not built. This is a recorded deferral, not an oversight, and not "we never decided". The 2026-08-24 audit's recommendation is taken as-is.

What building it would take, so the estimate is not re-derived: a small Files client (upload / list / get / delete), a TaskImage or new TaskFile variant carrying a fileId (today TaskImage in src/core/types.ts has no fileId or purpose field), and a LoopAgentAdapter branch emitting a {type:'file', file:{file_id}} content part — the openai SDK already exports that part type, the adapter just imports only the text and image ones.

Why defer: inline base64 covers every case this repo has. The Files API exists for exactly two things neither of which has a consumer here — reusing one image across many requests without re-uploading it, and a single image above the 32 MiB inline ceiling (64 MiB via file_id). Building an upload lifecycle, including expiry and deletion, for zero callers means owning a stateful remote resource with no test that exercises it.

The two triggers that reopen this, either one being sufficient:

  1. Cross-request image reuse — a caller sends the same image to more than one request (a judge panel over one screenshot, a multi-round vision loop), where re-uploading base64 per call is the actual cost.
  2. A single image over the 32 MiB inline cap — base64 simply cannot carry it, so file_id is not an optimisation but the only path.

Responses API (native) and the Codex integration

DeepSeek natively serves the OpenAI Responses API at https://api.deepseek.com — shipped for deepseek-v4-flash on 2026-07-31 and for deepseek-v4-pro on 2026-08-13, both changelog entries saying it was "specifically adapted for Codex". Sources https://api-docs.deepseek.com/guides/responses_api and https://api-docs.deepseek.com/quick_start/agent_integrations/codex; live-probed 2026-08-25 (T679).

It is a real endpoint, not a shim. POST /responses and POST /v1/responses both returned 200. client.responses.create(model=..., instructions=..., input=...) works with the stock openai SDK pointed at https://api.deepseek.com.

Streaming is NOT Chat-Completions-shaped. Semantic SSE events, each with an event type and a monotonic sequence_number; there is no data: [DONE] — the stream ends with response.completed, response.incomplete or response.failed, and that final event carries the full response object including usage. Text arrives as response.output_text.delta, CoT as response.reasoning_text.delta, tool args as response.function_call_arguments.delta.

Usage shape differs from Chat Completions — live-observed: usage.input_tokens / input_tokens_details.cached_tokens / output_tokens / output_tokens_details.reasoning_tokens. The prompt_cache_hit_tokens / prompt_cache_miss_tokens pair does not appear on this surface, so any cost code that reads those two fields silently sees nothing here.

Unsupported parameters are silently ignored, never rejected (verbatim: "Unsupported parameters are silently ignored and do not cause errors, so existing Responses API clients can connect without modification"). Live-confirmed: a request sending store:true, metadata:{"a":"b"} and parallel_tool_calls:false returned 200 with store:false, metadata:{} and parallel_tool_calls:true in the response. Read that as a hazard, not a convenience — a misconfigured client gets a plausible answer instead of an error.

Not supported at all (from the page's compatibility table): previous_response_id and conversation (the API is stateless), store, background, metadata, include, prompt, truncation (an over-length request 400s instead), service_tier, safety_identifier, prompt_cache_key / prompt_cache_retention, context_management, stream_options. Partially supported: tools (only function and web_search), reasoning (effort yes, summary accepted but never generated), text (format yes, verbosity accepted but inert). Ignored: parallel_tool_calls (always on), max_tool_calls.

Server-side web_search is genuinely executed by DeepSeek (web_search / web_search_2025_08_26); search_context_size and user_location are ignored and server-side auto-continuation is capped at 10 rounds. DISAGREEMENT, unresolved: re-read on 2026-09-19, the vendor's compatibility table at https://api-docs.deepseek.com/guides/responses_api lists web_search together with file_search, code_interpreter, computer_use and mcp as "Ignored", and the Codex guide sets web_search = "disabled" for DeepSeek models. Treat server-side search as unconfirmed until a live Responses request shows source URLs in its output. The only custom tool accepted is {"type":"custom","name":"apply_patch"} — for Codex compatibility; any other custom name 400s.

Codex integration. All Codex clients (CLI, ChatGPT desktop app, VS Code extension) share ~/.codex/config.toml. DeepSeek ships a one-click setup script — bash <(curl -fsSL https://cdn.deepseek.com/api-docs/codex-deepseek-setup-en.sh) on macOS/Linux, irm https://cdn.deepseek.com/api-docs/codex-deepseek-setup-en.ps1 | iex on Windows — which backs up config.toml, writes a ~/.codex/models.json model catalog and adds a [model_providers.deepseek] section (menu option 9 restores the pre-install state). The manual equivalent:

model = "deepseek-v4-flash"
model_provider = "deepseek"
preferred_auth_method = "apikey"
forced_login_method = "api"
model_reasoning_effort = "high"
model_catalog_json = "~/.codex/models.json"

[model_providers.deepseek]
name = "deepseek"
base_url = "https://api.deepseek.com/"
wire_api = "responses"
experimental_bearer_token = "<your DeepSeek API Key>"

Two details from the shipped models.json worth recording: each DeepSeek entry declares "context_window": 1048576 and "supported_reasoning_levels": [low, high, max] — the same three canonical buckets, again without the four aliases — and only the deepseek-v4-flash-vision-exp entry declares "image" in input_modalities, which is what tells Codex that model takes images.

Cyberine touch-point: none today, and this is the notable gap. LoopAgentAdapter speaks Chat Completions exclusively, and Cyberine's codex-cli worker (docs/providers/codex-cli.md) drives Codex against its own OpenAI auth, not against DeepSeek. Routing a Codex worker at DeepSeek via wire_api = "responses" is a real, documented, upstream-supported option that nothing in this repo uses yet.

Driving Codex against DeepSeek (T682)

DeepSeek documents a Codex integration (changelog 2026-08-13, https://api-docs.deepseek.com/quick_start/agent_integrations/codex) using wire_api = "responses". Cyberine needs no adapter change to use it. The full operator recipe — including why DeepSeek's own setup script must NOT be run, how CODEX_HOME scopes it safely, the undocumented models.json schema, and the go/no-go on registering it as a standing worker — lives in codex-cli.md, section "DeepSeek-backed Codex worker". Verified 2026-08-25 against codex 0.149.0 with a recorded run (44320a3b).

Two Responses-API constraints worth repeating here: DeepSeek is fully stateless (previous_response_id, conversation, store, background are all "Not supported"), and the custom tool type is restricted to {"type":"custom","name":"apply_patch"} — which happens to be exactly the tool Codex's own file-edit path uses, so that works, while any other custom or MCP tool routed through DeepSeek is dropped.

Gotchas

  • Legacy aliases: delisted everywhere, still answered on the wire (live-probed 2026-08-25, T679). This corrects a claim this file and src/core/config.ts both carried. deepseek-chat and deepseek-reasoner are absent from GET /models, absent from the /api/create-chat-completion model enum, and absent from the 400 message an unknown model produces — and yet POST /chat/completions serves them:

    Request modelHTTPresponse.modelprompt_tokens ("hi")reasoning_content
    deepseek-chat200deepseek-v4-flash5none (non-thinking)
    deepseek-reasoner200deepseek-v4-flash84present (thinking)
    deepseek-v4-flash (control)200deepseek-v4-flash84present
    deepseek-nonexistent-xyz (control)400——The supported API model names are deepseek-v4-pro, deepseek-v4-flash, and deepseek-v4-flash-vision-exp, but you passed deepseek-nonexistent-xyz.

    Two runs of every row, identical both times. The unknown-model control is what makes this conclusive: a genuinely dead id 400s with a message that names only the three V4 ids, so the aliases are not merely "not yet cleaned up from an error path" — they are actively routed. Both now collapse onto deepseek-v4-flash, differing only in the thinking default they preselect (5 vs 84 prompt_tokens on the same prompt is exactly the thinking-boilerplate injection measured in the reasoning_effort bucket table). Practical consequences: (1) a legacy pin does NOT fail fast — it silently bills you for flash while your config claims otherwise, which is worse than a 400, and is the real argument for keeping T681's startup hard-stop; (2) a deepseek-reasoner pin no longer gets a distinct reasoner model; (3) an undocumented alias can be withdrawn with no changelog entry, so this row must be re-probed each sweep rather than assumed stable in either direction.

  • reasoning_content echo policy — empirically NEITHER direction reproduces an HTTP 400, live-probed 2026-08-24 (T684). DeepSeek's own /guides/thinking_mode states twice, verbatim, that a tools-carrying request which fails to echo reasoning_content back on every subsequent turn — "even for turns where the model did not perform a tool call" — "will return a 400 error". That claim is unreproduced as of 2026-08-24 across two probe passes on deepseek-v4-pro:

    ProbeTransportCallsToolsThinkingreasoning_content echoPeak context (prompt_tokens)Result
    Shallow (cyber-011/ds-params-probe)non-streaming6present / absentdefaultechoed / omitted~100-400200, 6/6
    Deep A — production shapestreaming SSE24 (12 cycles)presentdisabled (what LoopAgentAdapter actually sends)n/a — never produced172,962200, 24/24
    Deep B — guide's required shapestreaming SSE24 (12 cycles)presentenabled (default)echoed every turn173,088200, 24/24
    Deep C — guide's claimed 400 triggerstreaming SSE24 (12 cycles)presentenabled (default)omitted every turn172,981200, 24/24

    The deep probes forced a tool call every cycle (tool_choice:'auto' — DeepSeek 400s 'required' outright with "Thinking mode does not support this tool_choice", an unrelated but real constraint) and grew context via a large opaque padding field on tool results (the model was told to ignore it), specifically to clear the ~150K-token depth an independent report (deepseek-harness discussion #4217) named as where a 400 starts appearing on every subsequent request. Zero 400s appeared in any cell, at any depth reached, in either echo direction. See .local/tasks/ACTIVE.md T684 for the full task record; the probe scripts are throwaway (.local/tmp/ds-explore.ts, .local/tmp/ds-reasoning-replay-probe.ts, gitignored, left in place for re-run but not guaranteed to survive routine .local/ cleanup).

    T692 addendum (2026-08-25) — the question is now LIVE, and the answer still holds. T684's verdict was safe by construction for Cyberine: production sent thinking:{type:'disabled'} whenever tools were present, so reasoning_content was never produced and there was nothing to echo or omit. T692 removed that gate, so the field IS now emitted on tool-call turns and the echo policy became a real production decision. Re-probed under the new shape — thinking enabled alongside tools, both v4 models, 108 requests across 14 scenarios, zero non-200s:

    ScenarioEcho directionShapeResult
    6-round loop with an intermediate assistant turn carrying NO tool call and NO reasoning_contentomittedstreaming200, 6/6 (this is the exact shape DeepSeek's docs name as a 400: "even for turns where the model did not perform a tool call")
    sameechoed every turnstreaming200, 6/6
    12-round loop, tool results padded to ~6K prompt tokensomittedstreaming200, 12/12
    sameechoed every turnstreaming200, 12/12
    8-round loop, echo on alternating turns (worst case for a strict validator)inconsistentstreaming200, 8/8
    6-round loopomittednon-streaming200, 6/6
    4-round loop with stale reasoning_content echoed into thinking:{type:'disabled'} requestsechoedstreaming200, 4/4 (mixed-mode history is accepted)

    needsReasoningReplay therefore stays false for DeepSeek — now on evidence rather than vacuity. Omitting the echo is also cheaper: the CoT never enters any later prompt. DeepSeek's documented 400 remains unreproduced in either direction, at any depth reached, in either transport. Only append content to history in production (this is still the opposite gate direction from Moonshot Kimi's contract — see the integration note below).

  • A round that streams only reasoning_content (empty content, no tool calls) must never be replayed as an assistant turn — observed 2026-09-18 in autopilot, LOOP-EMPTY-ASSISTANT-TURN. Because reasoning_content is deliberately not echoed back (see above), a reasoning-only round left the rebuilt assistant message with content:null and no tool_calls, and the following request 400'd with Invalid assistant message: content or tool_calls must be set. LoopAgentAdapter.ts now skips pushing that turn into the context entirely instead of pushing a null-content placeholder.

  • temperature/top_p unsupported in thinking mode — omit them.

Thinking mode + tool calling — VERDICT (T692, live-probed 2026-08-25)

Tool calling works with thinking ENABLED. The old "tools are incompatible with thinking mode" claim is FALSE and the hasTools -> thinking:disabled workaround it justified has been removed. What is real is a narrower constraint on tool_choice, plus a budget interaction with max_tokens. 312 live requests against api.deepseek.com, both v4 models; the ONLY non-200s in the entire sweep were the four tool_choice cells called out below.

1. Coexistence — tools and thinking in the same response (24 cells, all 200, all correct). With tool_choice:'auto' and a multiply(17,23) task, every cell returned finish_reason:'tool_calls' with correct arguments AND non-empty reasoning_content in the same message:

Dimension variedValues probedResult
Modeldeepseek-v4-pro, deepseek-v4-flash200 + tool_calls + reasoning_content, both
thinking fieldomitted (default), {type:'enabled'}, {type:'disabled'}200 + correct call in all three; reasoning_content non-empty for the first two, empty for disabled
Transportstreaming SSE, non-streaming200 + correct call both ways; streamed CoT arrives as delta.reasoning_content
Tool count1, 12 (1 real + 11 decoys), 32 (1 real + 31 decoys)200 + the RIGHT tool picked at every count
Schema complexityflat 2-int schema; deeply nested (nested objects, array-of-objects, enums, 3 levels of required)200 + valid nested arguments
reasoning_effortminimal, low, medium, high (with tools, thinking on)200 + correct call at every level
temperature0.2 with thinking on + tools200 (accepted, not rejected, despite the docs calling it unsupported)
Multi-round loop5 rounds, 12 rounds (padded to ~6.8K prompt tokens)200 on every round; reasoning_content on every tool-call round; the final answering round always put its answer in content, never only in reasoning_content

2. The REAL constraint — tool_choice must not FORCE a call (the only 400s in the sweep). Verbatim message: Thinking mode does not support this tool_choice.

tool_choicethinking enabledthinking disabled
'auto'200, finish_reason:'tool_calls'200, tool_calls
omitted (with tools present)200, finish_reason:'tool_calls'200, tool_calls
'none'200, finish_reason:'stop', no call (as asked)200, stop
'required'HTTP 400 "Thinking mode does not support this tool_choice"200, tool_calls
{type:'function',function:{name}} (named)HTTP 400, same message200, tool_calls

Both rows reproduce identically on deepseek-v4-pro and deepseek-v4-flash. The named-function form 400ing is new information — T684 only recorded 'required'. So: any forcing tool_choice must keep thinking off; auto/none/omitted must not.

3. The budget interaction — max_tokens covers CoT AND the answer, so a tight cap can starve the tool call entirely. One hard multi-step planning task, one write_plan tool, streaming:

max_tokensthinking enabledthinking disabled
256finish=length, 0 tool calls, 256 reasoning tokensfinish=length, 1 call, arguments TRUNCATED
1024finish=length, 0 tool calls, 1024 reasoning tokensfinish=length, 1 call, arguments TRUNCATED
4096finish=length, 0 tool calls, 4096 reasoning tokensfinish=tool_calls, 1 call, valid arguments
16000finish=tool_calls, 1 call, valid arguments, ~3.5–3.9K reasoning tokensfinish=tool_calls, 1 call, valid arguments

Same shape on both models. This is the one way leaving thinking on can make a round strictly worse than the old gate: below roughly 8K the whole budget goes to CoT and the round yields nothing to execute. It is NOT a 400 and NOT an error — just a wasted round. Cyberine's mitigation is DEEPSEEK_THINKING_MIN_MAX_TOKENS (see integration notes); the residual risk above the floor (an unusually long CoT still starving a round) is accepted and unmitigated.

4. What thinking BUYS on the tool path — no accuracy delta on an easy task, ~1.4–1.8x the completion tokens. A 3-step derived-operand word problem, 6 trials per cell:

CellCorrectAvg roundsAvg tool callsAvg reasoning tokensAvg completion tokens
pro, thinking enabled6/64.03.0243428
pro, thinking disabled6/64.03.00241
flash, thinking enabled6/63.33.0117293
flash, thinking disabled6/64.03.00305

So the upside is not proven on easy work: both settings scored 6/6, and thinking costs real output tokens (billed as completion tokens). The case for enabling it is that the implementer fleet's actual tasks are much harder than this probe, and the gate was removing the option unconditionally. If DeepSeek spend matters more than reasoning quality for a given deployment, CYBERINE_DEEPSEEK_TOOL_THINKING=off restores the old always-disable behaviour with no code change.

5. Not re-probed / out of scope. Streaming vs non-streaming was covered at 1 and 5+ rounds but not at 150K+ context (T684 covered that depth with thinking enabled and found no 400). FIM's thinking-mode incompatibility was NOT re-probed — that remains a docs-only claim, and Cyberine does not use FIM. The /beta strict-schema base URL was not probed with thinking on. Prompt-cache behaviour with CoT in the prefix was not measured.

Probe scripts (throwaway, gitignored, may not survive .local/ cleanup): .local/tmp/ds-thinking-tools-probe.ts (phases A–E), .local/tmp/ds-replay-probe.ts (phase F), .local/tmp/ds-quality-probe.ts (phase G), .local/tmp/ds-budget-probe.ts (phase H); raw results in .local/tmp/t692-results-*.jsonl. The permanent guard is npm run selftest:deepseek-thinking-tools, which pins the request-body policy offline and additionally re-checks the live contract when DEEPSEEK_API_KEY is set.

  • Strict tool calling needs the /beta base URL + every property required with additionalProperties:false. Supported/unsupported JSON-Schema keywords, and the page's own "supported by both thinking and non-thinking mode" statement, are in the Notable features bullet above (re-read 2026-08-25).
  • Streaming usage only present when stream_options.include_usage:true.

Pricing

LIVE since 2026-08-16 16:00 UTC — peak/off-peak billing (confirmed 2026-08-21, knk0q; whole table + both footnotes re-read cell-by-cell 2026-08-25, T679 — unchanged). The 2026-07-10 doubt below has resolved: this is now DeepSeek's own documented pricing, not a secondary-source report. Fetched directly (curl, not WebFetch's LLM summary — the raw HTML's server-rendered pricing table was grepped verbatim) from https://api-docs.deepseek.com/quick_start/pricing on 2026-08-21, and re-read column-by-column on 2026-08-25 (T683) when the vision model's rates were added. Verbatim table cells (the page now carries THREE model columns: deepseek-v4-flash | deepseek-v4-pro | deepseek-v4-flash-vision-exp; model versions shown: DeepSeek-V4-Flash-0731 / DeepSeek-V4-Pro-0813 / DeepSeek-V4-Flash-Vision-Exp, GA 2026-07-31 / 2026-08-13 / 2026-08-21, ids unchanged):

Per 1M tokensOFF-PEAK (flash / pro / vision)PEAK (flash / pro / vision)
Input, cache HIT$0.007 / $0.022 / $0.007$0.014 / $0.044 / $0.014
Input, cache MISS$0.22 / $0.66 / $0.22$0.44 / $1.32 / $0.44
Output$0.66 / $1.98 / $0.66$1.32 / $3.96 / $1.32
Concurrency limit2500 / 500 / 2500(same)

The vision column is identical to the flash column on every single row — there is no vision surcharge and no separate image-token rate.

SUPERSEDED for Flash 2026-09-10, re-confirmed 2026-09-24. The table above is the pricing that launched with DeepSeek-V4-Flash-0731 on 2026-08-16 and is kept for history. DeepSeek-V4.1-Flash (changelog and pricing page, both re-read 2026-09-24) cut the flash rate to, per 1M tokens off-peak/peak: cache-hit $0.003/$0.006, cache-miss $0.15/$0.30, output $0.60/$1.20 — see the deepseek-flash row under "Models" above. deepseek-v4-flash and deepseek-v4-flash-vision-exp are legacy aliases now routed to V4.1-Flash and billed at this same new rate, so the flash and vision columns above are stale; the deepseek-v4-pro column is unaffected and still current. modelPricing.ts's deepseek-v4-flash row is updated to match.

Footnote (1) verbatim (re-verified 2026-08-25, T685): "Off-peak rates are half of the peak rates. Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through Friday (all other hours are off-peak)." (Narrowed to weekdays-only since the 2026-08-21 quote above; see the T685 anchoring note on isDeepSeekWeekday() in src/core/modelPricing.ts for the unresolved UTC-vs-Beijing weekday-boundary question and why it turns out not to matter for these hours.)

Footnote (2) verbatim (read live 2026-08-25, T683): "Images sent to deepseek-v4-flash-vision-exp are converted into tokens based on their dimensions and billed as input tokens together with your text tokens." So image tokens need no separate accounting — they arrive inside the ordinary cache-miss input axis the table above already prices. Confirmed by a live dispatch rather than taken on the doc's word (T683: one 8x8 RGB PNG + a one-line text prompt, HTTP 200), whose entire usage object was:

{
  "prompt_tokens": 211,
  "completion_tokens": 32,
  "total_tokens": 243,
  "prompt_tokens_details": { "cached_tokens": 0 },
  "completion_tokens_details": { "reasoning_tokens": 32 },
  "prompt_cache_hit_tokens": 0,
  "prompt_cache_miss_tokens": 211
}

No image_tokens field (or any variant) appears anywhere in usage or in the response body, and prompt_cache_miss_tokens equals prompt_tokens exactly. That object is pinned verbatim in scripts/knk0q-deepseek-peak-pricing-smoke.ts (section 11) as an object-shape assertion, so a future provider-side change that DID introduce a separate image-token axis fails the smoke instead of silently under-counting.

src/core/modelPricing.ts's direct DeepSeek rows model the cache-MISS input / output rate (this table's existing single-rate convention, cache-HIT not modelled) and now carry both off-peak (base) and peak (.peak) figures; getModelPricing()/computeChatCost() pick peak vs off-peak from a Date (UTC hour, default now()) — time-of-day-aware, not a flat conservative bump. src/store/creditLedger.ts's SEPARATE provider-level PROVIDER_RATES table is what actually feeds estimateCostUsd/recordSpend for real LoopAgentAdapter dispatches (and therefore the Orchestrator.ts / WorkflowEngine spend-cap gate -- modelPricing.ts only feeds the oracle tool and the TUI cost chips directly). Fixed in the same wave (2026-08-21, knk0q follow-on, commit 27cd286): estimateCostUsd() now takes an optional modelId + at and, when modelPricing.ts has a row for the model, bills at that per-model time-of-day-aware rate; LoopAgentAdapter passes its model id on the own-cost path. The provider-level PROVIDER_RATES.deepseek fallback (used when the model id is unknown or absent) was raised from the pre-V4 flat $0.27/$1.10 to the PEAK deepseek-v4-pro rate $1.32/$3.96 so it can never under-count a DeepSeek direct worker. Guarded by npm run selftest:deepseek-peak-pricing (section 8).

Vision row added 2026-08-25 (T683). deepseek-v4-flash-vision-exp previously had NO row in modelPricing.ts at all, so getModelPricing() returned undefined and computeChatCost() silently reported $0 for every vision dispatch (same silent-zero failure mode as the pre-T645 MiniMax-M3 gap). It now mirrors the flash row exactly, off-peak and peak. PROVIDER_RATES.deepseek in creditLedger.ts needed no change: it holds pro's PEAK rate as the conservative ceiling and the vision model shares flash's cheaper rate, so the ceiling still cannot under-count — asserted directly by the "provider fallback >= every DeepSeek direct row at peak, vision included" check in section 8 of the smoke. Sections 10-11 cover the new row (its existence, the flash-parity claim, the peak/off-peak flip, the weekday gate, the spend-cap path, and the pinned live usage object).

Time-of-day-aware routing (decision record)

DECISION (2026-08-26): DECLINED — the router does NOT re-rank or skip DeepSeek workers during the peak window. The peak/off-peak signal stays where it already is: it feeds cost estimation (getModelPricing() / computeChatCost() in src/core/modelPricing.ts, the credit ledger's estimateCostUsd(), the spend cap, and the TUI cost chips), and it does NOT drive fleet selection in src/core/router.ts. This section records why, and what would change the call.

The saving is a fraction of a cent per task. The peak multiplier doubles DeepSeek's rates (flash $0.44/$1.32 peak vs $0.22/$0.66 off-peak per 1M tokens; pro $1.32/$3.96 vs $0.66/$1.98). A typical implement task — tens of thousands of input tokens, a few thousand output — costs on the order of $0.01-$0.04 total, so the entire peak-window saving is pennies, and only if the task actually lands inside the window.

The comparable off-peak workers are not actually interchangeable. The preferred pool the router races is claude (rank 1, subscription), codex (rank 2, subscription), opencode-deepseek (rank 3, cheap metered), opencode-kimi (rank 4, dead as of 2026-08-25 — no credential AND suspended account, and filtered out of the candidate list before the router sees it). opencode-deepseek is ALREADY the last available preferred-pool worker, so a peak-hour "down-rank" is a no-op in the common case. Skipping it entirely would mean either burning subscription quota on claude/codex (a premium resource reserved for hard/critical tasks — a bad trade for pennies) or dropping out of the preferred pool into the metered key-loop fallback, which is credential-gated and, for code-small tasks, re-picks loop-deepseek-build (rank 7) — itself a DeepSeek worker, so the fallback path defeats the purpose anyway. The only fallback worker cheaper than peak flash is loop-minimax-build ($0.30/$1.20), and routing onto it (or loop-mimo-build, $0.50/$1.50) would trade a known-good, always-available worker for a key-gated one to save about a cent.

What would make this worth implementing: a non-DeepSeek, non-subscription worker in the preferred pool with demonstrated availability (e.g. a revived opencode-kimi, or a new cheap opencode CLI agent), or a DeepSeek peak multiplier large enough that the per-task saving clears the noise floor, or a cheap-fleet mode whose dispatch could simply be deferred to off-peak. The implementation would be a small deepseekPeakDownrank() pass in src/core/router.ts after the credit gate, reusing isDeepSeekPeakHour() from src/core/modelPricing.ts (no new time logic), emitting a reasons line like deepseek-peak-hour: down-ranked opencode-deepseek during DeepSeek peak window (01:00-04:00 / 06:00-10:00 UTC weekday). Until then the signal stays in cost estimation, which is the place it already does real work — the spend cap never under-counts (provider fallback uses the peak deepseek-v4-pro rate as the ceiling).

Request isolation via user_id, and the keep-alive / 10-minute disconnect contract

user_id IS sent, keyed on the RUN id. DeepSeek's "Rate Limit & Isolation" page documents a user_id field granting sub-identities under one account three things: content-safety isolation, KVCache isolation, and scheduling isolation. On a regular account every user_id still pools into one concurrency count; an account with increased quota gets PER-user_id concurrency at the full numbers (500 pro / 2500 flash / 2500 vision), so one id exhausting its limit 429s only that id. Format is [a-zA-Z0-9\-_]+, max 512 chars, and it must carry nothing user-identifying.

Helpers live in src/core/userId.ts. buildRunUserId(task.id) is used by LoopAgentAdapter (only when deepSeekDirect), buildJudgeUserId() by makeOpenAICompletion in src/judges/llmClient.ts (gated on an api.deepseek.com baseURL).

The keying decision is run id, not worker id. KVCache isolation pays off exactly when several fan-out workers share a long prompt prefix, so a per-RUN id lets one fleet dispatch's workers reuse each other's cached prefix; a per-WORKER id would fragment it. Scheduling isolation still holds at run granularity: one run saturating its concurrency bucket cannot starve a different run. Judge calls are single-turn with no shared prefix, so they take a per-call nonce id instead - the value there is only the separate scheduling bucket. The id is a UUID behind a cyberine- prefix, so the no-PII constraint holds by construction.

Transport: a TOP-LEVEL body field, never extra_body. extra_body is a Python-SDK concept. openai-node 4.x has no such envelope - requestOptionsKeys in node_modules/openai/core.js does not list it and the request body is JSON.stringify'd verbatim, so unknown fields pass through as-is. Nesting user_id under extra_body would put a literal extra_body object on the wire and DeepSeek would never receive the id - a silent no-op at best, a 400 from a strict endpoint at worst. buildLoopRequestBody() therefore emits user_id at the top level, and scripts/t688-user-id-wire-smoke.ts (npm run selftest:user-id-wire) pins that shape so a future edit cannot quietly reintroduce the envelope. The Anthropic-SDK equivalent would be metadata: {"user_id": ...}; this repo does not currently use that path for DeepSeek.

Keep-alive and the 10-minute no-inference disconnect. The same page documents that non-streaming requests receive empty-line keepalives, streaming receives SSE : keep-alive comments, and verbatim: "If the request has not started inference after 10 minutes, the server will close the connection."

Our timeout handling is the idle-stream watchdog in src/adapters/LoopAgentAdapter.ts (IdleStreamWatchdog, CYBERINE_STREAM_WATCHDOG_MS, default 5 minutes, 0 opts out). Two things follow from putting these side by side, neither of which has been checked against a live 10-minute stall:

  • The watchdog's 5-minute default fires BEFORE the server's 10-minute close, so in the streaming path Cyberine aborts and retries on its own rather than ever observing the server-side disconnect. That is the desired ordering - but it means the 10-minute path is untested here.
  • Whether a : keep-alive comment counts as a stream event (and so resets the watchdog) depends on the SDK's SSE parser. If it does, a request stalled pre-inference keeps the watchdog alive on keepalives alone and the server's 10-minute close becomes the real deadline; the abort then surfaces as a transport error, not as a watchdog timeout. Raising CYBERINE_STREAM_WATCHDOG_MS above 10 minutes hands control to the server in either case.

Cyberine integration notes

  • Wired in buildAdapters.ts as loop-deepseek-direct (read-only tools) and the opt-in loop-deepseek-build (implementer tools, sandbox-pinned). Base/key/model from DEEPSEEK_BASE_URL / DEEPSEEK_API_KEY / DEEPSEEK_MODEL.

  • LoopAgentAdapter surfaces reasoning_content live in the peek view but never folds it into the answer.

  • ✓ Mismatch FIXED (2026-06-07): LoopAgentAdapter previously replayed reasoning_content on the assistant tool-call turn for every provider — believed at the time to 400 on DeepSeek if echoed back. The replay is now gated by needsReasoningReplay(provider, model), true only for Moonshot/Kimi; DeepSeek no longer echoes it. Verified with a DeepSeek tool-call loop (calculator) completing without 400. Override available via the replayReasoning constructor option for a gateway-routed thinking model.

  • Re-verified at depth 2026-08-24 (T684): the never-echo default was never actually forced by an observed 400 — see the Gotchas matrix above. Streamed, 24-round, ~173K-token probes found no 400 whether reasoning_content was echoed or omitted, in either direction, and production's own thinking:disabled-when-tools shape never produces the field at all. needsReasoningReplay is left unchanged: DeepSeek stays false (never echo) as a conservative default, not because omission is required to avoid a 400.

  • ✓ Thinking gate REMOVED 2026-08-25 (T692). LoopAgentAdapter no longer sends thinking:{type:'disabled'} just because tools are present, so every loop-deepseek-direct / loop-deepseek-build dispatch now keeps the model's reasoning. The request body is built by the exported pure function buildLoopRequestBody (same file), whose whole job is this policy:

    • non-DeepSeek endpoint → never send thinking (it is a DeepSeek-native field).
    • DeepSeek-direct, no tools → send nothing; thinking stays at DeepSeek's default (enabled). Unchanged by T692, and this is already what the judges do: LlmJudge / AdversarialJudge go through makeOpenAICompletion (src/judges/llmClient.ts), which sends NO tools at all, so the hasTools gate never applied to them. The judges were never affected by this workaround — only the loop workers were.
    • DeepSeek-direct, tools, non-forcing tool_choice (auto / none / omitted) → send nothing; thinking stays ENABLED. This is the T692 change.
    • DeepSeek-direct, tools, FORCING tool_choice ('required' or a named function) → send thinking:{type:'disabled'}. Mandatory: the request 400s otherwise. The forcing choice itself still reaches the wire unchanged. Exposed as the new LoopAgentAdapterOptions.toolChoice (default 'auto'); nothing in the tree forces a call today, so this branch exists to keep the invariant true for the first caller that does.
    • DeepSeek-direct, tools, maxTokens below DEEPSEEK_THINKING_MIN_MAX_TOKENS (8000) → send thinking:{type:'disabled'}. max_tokens caps CoT and the answer together, and below ~8K a hard task spends the whole budget on CoT and returns zero tool calls (matrix above). config.loopMaxTokens defaults to 16000 so the standing fleet is above the floor; the floor catches tight-budget callers and this adapter's own 4096 default when maxTokens is unset.
    • CYBERINE_DEEPSEEK_TOOL_THINKING=off (also 0 / disabled / false) → always disable with tools, i.e. the pre-T692 behaviour, as a no-code-change kill switch for cost or for an unexpected regression. Parsing fails OPEN to auto so a typo cannot silently re-cost the fleet its reasoning.

    Guarded by npm run selftest:deepseek-thinking-tools, which pins that whole truth table offline (CI has no DeepSeek key) and additionally asserts the live contract — coexistence, the 'required' 400, and 'required' + thinking:disabled succeeding — whenever DEEPSEEK_API_KEY IS set. Known limitation, deliberately not fixed here: there is no runtime recovery for a round that comes back finish_reason:'length' with zero tool calls because CoT ate the budget. The static floor prevents the pathological small-budget case; an unusually long CoT above the floor can still waste a round, and the loop will simply retry it. A retry-that-round-with-thinking-off path is the obvious follow-up.

Sources

Every URL below was fetched with curl and grepped as raw HTML on 2026-08-25 (T679); all 200.

Live API probes (2026-08-25, T679) — the source for every claim above that no page states: the reasoning_effort enum error text, the per-effort prompt_tokens buckets, the legacy-alias resolution table, the vision usage shape and its two 400 messages, the Files API status codes and id format, and the Responses API's silent-ignore behaviour. Throwaway scripts, gitignored and not guaranteed to survive .local/ cleanup: .local/tmp/t679-probe{,2,3,4,5,6,7}.sh.