Skip to main content

Migrate a Text Review Workflow to DeepSeek V4.1 Flash

Move a DeepSeek chat integration to V4.1 Flash: model ID, fields to delete, reasoning budget, a checked fixture, tool-call history and the token bill.

Author
Lydia
Published
Read
12 min read
Updated
A woman and a man talk across a teal bicycle in a sunlit repair workshop.

Say a service sends repair notes to a DeepSeek model and expects a JSON object back: what was inspected, what was recommended, whether the customer approved, whether the work was done. Today it calls deepseek-chat, deepseek-flash or an OpenRouter route, and you want it running on DeepSeek V4.1 Flash through SpicyAPI (spicyapi.ai).

Four edits move it: use the model ID deepseek/v4.1-flash/chat, delete every field this model does not accept, choose a reasoning_effort, and give max_tokens real headroom, because reasoning is paid out of the same budget. Then run one small fixture before real traffic. Use the V4.1 Flash model page to try the request.

What to change in the request

Base URL, key and model ID are the easy part (migration doc). The request body is where it breaks:

In your current clientOn SpicyAPI
Base URL https://api.deepseek.com and a DeepSeek keyhttps://api.spicyapi.ai/v1 and a key that starts with sk-spicy-
Model deepseek-flash, or the older deepseek-chat, deepseek-reasoner, deepseek-v4-flash, deepseek-v4-prodeepseek/v4.1-flash/chat
thinking: {"type": "disabled"}, the behaviour deepseek-chat used to givereasoning_effort: "none" skips the reasoning pass
Thinking on (the default)Leave reasoning_effort out for high, or set low for volume work
reasoning_effort of max, medium, minimal, xhighOnly none, low and high are accepted; anything else is a 400 invalid_request
temperature, top_p, presence_penalty, frequency_penaltyDelete; 400 unsupported_parameter
parallel_tool_callsDelete; not exposed on this model
stopKeep: up to 4 sequences, and the matched text is left out of the reply
image_url or file partsKeep image_url parts for images; remove PDF/file parts and extract their text first
OpenRouter models, route, provider, usage, transforms, plugins, and a reasoning objectDelete the first six; replace reasoning with top-level reasoning_effort

There is no sampling control on this hosted model, even with reasoning_effort: "none". Validate task results instead of depending on temperature: 0 for repeatability.

What stays the same: messages (text or image content, up to 256), tools (up to 128 definitions) with tool_choice (auto, none, required or one named function), response_format with {"type": "json_object"}, stop, max_tokens (default 32,768, maximum 384,000) and an input window of 1,048,576 tokens that counts every message you send, including the system prompt and earlier tool results.

SpicyAPI never drops a field silently: the 400 names the field and nothing is billed. DeepSeek's own API accepts and ignores temperature and the penalty fields in thinking mode, so an old client may carry them unnoticed.

The quiet failure is the model name. On DeepSeek's own API, V4 Flash has been retired and deepseek-v4-flash temporarily routes to V4.1 Flash; the current name is deepseek-flash. V4 Pro is different: DeepSeek initially announced a September 14 switch to Flash, then its change log confirmed that V4 Pro service would continue with unchanged billing. Do not migrate on the assumption that every old Pro call already runs Flash.

On SpicyAPI the three DeepSeek chat routes have separate published contracts: keeping deepseek/v4-flash/chat in your config is a valid request with a 65,536-token output ceiling and higher prices. A native-service alias does not change that hosted contract. Use deepseek/v4.1-flash/chat explicitly for this migration.

This route accepts images through image_url parts, but not PDF/file parts. Extract the text from a PDF before sending it; the text-only repair-note examples below need no image input.

Give the reply room

The model reasons before it answers, and the reasoning comes out of max_tokens. The default effort is high, and a 120-token cap returned no content at all. The default max_tokens is 32,768, and a longer deliverable is truncated. Size the cap to the visible answer plus several thousand tokens of thinking room, not to the 384,000 ceiling.

Start extraction at Low effort. If it misses a necessary distinction, try High on that record rather than making every row more expensive. For large inputs, use the streaming interface.

Try it in Playground first

  1. Open DeepSeek V4.1 Flash, choose Playground and set the effort control to Low.
  2. Put the JSON-shape instruction from the script below in System prompt and repair note R17 in Message to the model, then click Run model.
  3. Confirm that pending approval does not become a completed repair before migrating your app.

Run a fixture that catches the expensive mistake

A repair note makes a good fixture because a fluent summary can be wrong in a way that costs money. "Recommend replacement" is not customer approval, and approval is not completed work.

Put a key from API Keys in SPICY_API_KEY, then save this as check_repair.py and run python3 check_repair.py. The system prompt shows the JSON shape and contains the word "json", which DeepSeek's JSON mode guide asks for.

python
import json
import os
import uuid
import urllib.request
import re

SOURCE = (
    'Repair note R17: "Inspected the rear brake pads. Recommend replacement before '
    'the next long ride. Customer approval is pending." No later work record is supplied.'
)
KEYS = {"inspected", "recommended_action", "authorized", "completed", "evidence"}
REQUIRED_QUOTES = (
    "Inspected the rear brake pads.",
    "Recommend replacement before the next long ride.",
    "Customer approval is pending.",
)


def check(response: dict) -> list[str]:
    choice = response["choices"][0]
    problems = []
    if choice.get("finish_reason") != "stop":
        problems.append(f"finish_reason is {choice.get('finish_reason')!r}: the reply may be cut off")
    content = (choice.get("message") or {}).get("content")
    if not content or not content.strip():
        return problems + ["empty content: reasoning may have used the whole max_tokens budget"]
    try:
        data = json.loads(content)
    except json.JSONDecodeError as error:
        return problems + [f"not valid JSON: {error}"]

    if not isinstance(data, dict):
        return problems + ["JSON must be an object"]
    if missing := KEYS - data.keys():
        problems.append(f"missing keys: {sorted(missing)}")
    for key in ("inspected", "authorized", "completed"):
        if data.get(key) is not None and type(data.get(key)) is not bool:
            problems.append(f"{key} must be true, false or null")
    if data.get("inspected") is not True:
        problems.append("inspected should be true")
    if data.get("authorized") is not False:
        problems.append("authorized must be false: approval is pending")
    if data.get("completed") is not None:
        problems.append("completed must be null: no completion record is supplied")
    action = data.get("recommended_action")
    if not isinstance(action, str) or not action.strip():
        problems.append("recommended_action must be a non-empty string")
    else:
        words = set(re.findall(r"[a-z]+", action.lower()))
        # A lexical screen, not a proof of semantic equivalence.
        required = {"rear", "long", "ride"}
        compatible = (required <= words
                      and bool(words & {"replace", "replacement", "replacing"})
                      and bool(words & {"pad", "pads"})
                      and not words & {"not", "no", "never", "front", "after"})
        if not compatible:
            problems.append("review recommendation wording against R17")
    quotes = data.get("evidence")
    if not isinstance(quotes, list) or not quotes:
        problems.append("evidence must be a non-empty list of quotes")
    else:
        problems += [f"invalid or unmatched quote: {q!r}" for q in quotes
                     if not isinstance(q, str) or not q.strip() or q not in SOURCE]
        for expected in REQUIRED_QUOTES:
            if not any(isinstance(q, str) and expected in q for q in quotes):
                problems.append(f"missing supporting quote: {expected}")
    return problems



body = {'model': 'deepseek/v4.1-flash/chat',
 'messages': [{'role': 'system',
               'content': 'Extract only facts stated in the source. Reply with one '
                          'JSON object shaped like {"inspected": null, '
                          '"recommended_action": null, "authorized": null, '
                          '"completed": null, "evidence": []}. inspected, '
                          'authorized and completed are true, false or null; use '
                          'null for anything the source does not state. evidence '
                          'lists exact quotes from the source that support every '
                          'non-null value.'},
              {'role': 'user',
               'content': 'Repair note R17: "Inspected the rear brake pads. '
                          'Recommend replacement before the next long ride. '
                          'Customer approval is pending." No later work record is '
                          'supplied.'}],
 'max_tokens': 2048,
 'reasoning_effort': 'low',
 'response_format': {'type': 'json_object'}}
request = urllib.request.Request(
    "https://api.spicyapi.ai/v1/chat/completions",
    data=json.dumps(body).encode(),
    headers={"Authorization": "Bearer " + os.environ["SPICY_API_KEY"],
             "Content-Type": "application/json",
             "Idempotency-Key": str(uuid.uuid4())},
)
with urllib.request.urlopen(request, timeout=90) as reply:
    response = json.load(reply)
problems = check(response)
print(json.dumps(response.get("usage", {})))
if problems:
    raise RuntimeError("; ".join(problems))
print(response["choices"][0]["message"]["content"])

The expected decision is inspected, approval still pending, and completion unknown. The recommendation must preserve the rear pads and the before-the-next-long-ride deadline.

Add approval and completion as separate fixtures

Do not stop at the pending-approval case. Use these as templates for additional fixtures: update SOURCE, REQUIRED_QUOTES and the expected approval/completion values before running each one. The R17 checker is not directly runnable against R18 or R19:

Latest recordAuthorizedCompleted
R17: “Customer approval is pending.” No later recordfalsenull
R18: “Customer approved the quoted replacement. Parts ordered; work scheduled for Thursday.”truenull
R19: “Replaced pads; road check completed Thursday.” Linked to the approved jobtruetrue

Keep the job ID, record order and dates with the source: R18 supersedes pending approval, but scheduling the work does not establish completion. If an undated “done” note cannot be linked to the same job, return the unresolved link rather than merging it into R17.

The same three-way split shows up in a creator's production workflow: “Approve this character design,” “design approved” and “video generated from the approved design” are a request, a decision and an execution. Ask the extraction prompt for those stages separately, with the record that supports each one, and preserve any conflict between notes instead of asking the model to pick the most confident-sounding sentence.

Keep approval and completion separate in tool workflows

An approval record does not prove the repair happened: look up the completion record before changing the status, and keep approval and completion as separate fields in whatever your tools return.

Chat without tools needs nothing extra. With tools in the request, a client that rebuilds each assistant message as a bare role and content passes the first turn and fails the second with a 400, so a single-turn test hides the bug. Append the assistant message exactly as returned, including reasoning_content, then add one tool message per call with the matching tool_call_id, and cap the number of rounds. Test two turns, not one. The chat recipes show the full loop.

Let an Agent prepare the call

For repeated record extraction, ask your coding Agent to use the SpicyAPI Skill: “Read deepseek/v4.1-flash/chat, then adapt the example to my input; show the request before running it.” The CLI lookup is spicyapi models get deepseek/v4.1-flash/chat --json; the MCP lookup is spicyapi_model_get with model: "deepseek/v4.1-flash/chat". Chat calls use a compatible client or the chat API; the media task commands do not generate chat replies.

Recalculate the bill

The rates are $0.30 per million uncached input tokens, $0.006 per million cached input and $1.20 per million output, one flat rate at any request length. A 40,000-token uncached prompt with 2,000 output tokens costs $0.0144. If 30,000 of the input tokens are billed as cached and 10,000 as uncached, the same call costs $0.00558. Reasoning tokens bill as output, so a short visible answer can still use more output than you expect; none is the only setting that keeps them off the bill.

Two comparisons matter when you leave DeepSeek's own API. Its list prices are the same $0.30, $0.006 and $1.20 at peak, but off-peak hours are half price there and there is one flat rate here: a batch you schedule off-peak on DeepSeek costs about twice as much on SpicyAPI for the same tokens. And the cached rate is one fiftieth of the input rate, but only for a prefix that is byte-identical from call to call: a 1,500-token system prompt over 10,000 calls is 15 million input tokens, $4.50 uncached and at most $0.09 if every one were a cache read. Compare returned usage and settled cost with these estimates. A timestamp or reordered instructions can break a shared prefix; unchanged input alone does not guarantee a cache hit.

Where each DeepSeek route lands

V4.1 FlashV4 FlashV4 Pro
Model IDdeepseek/v4.1-flash/chatdeepseek/v4-flash/chatdeepseek/v4-pro/chat
Max output384,00065,536384,000
Input / cached / output per 1M$0.30 / $0.006 / $1.20$0.44 / $0.014 / $1.32$1.32 / $0.044 / $3.96
parallel_tool_callsNot exposedNot exposedExposed

All three take 1,048,576 input tokens, none, low or high effort (default high), and a default max_tokens of 32,768. Coming from deepseek/v4-flash/chat, the request body is identical, so only the ID changes: the output ceiling grows almost sixfold and every rate drops. Coming from V4 Pro, you lose parallel_tool_calls and pay 77% less for input and about 70% less for output. Whether V4 Flash or V4 Pro is the better fit for a given task is a separate decision, covered in DeepSeek V4 Flash vs Pro.

If your app writes adult fiction

V4.1 Flash is listed as uncensored on SpicyAPI, but that is not a promise that it will complete every adult-fiction request. In our September 2026 tests, direct requests for intense mature scenes came back softened every time, while framing them as fiction or setting the frame in the system prompt rendered more of them in full, though not all (details in the V4.1 Flash review). If your prompts were written for a model that accepted bare requests, move the framing into the system message during the migration and keep every character an adult.

Fix migration failures directly

SymptomLikely causeCorrection
400 unsupported_parameter naming temperature, top_p, a penalty, parallel_tool_calls or thinkingField carried over from the old clientDelete it
content is empty or finish_reason is lengthmax_tokens spent on reasoning, or the reply ran past itRaise max_tokens, lower the effort, retry
JSON parses but reports completed workRecommendation read as completionKeep separate fields, require quotes, run the checker
Error on turn two of a tool conversationAssistant message rebuilt without reasoning_contentAppend the returned message unchanged
402 with enough balance40201 hold exceeds balance, or 40202 spend cap reachedInspect the error: reduce an excessive output budget for the hold, or review key/workspace caps
Reused Idempotency-Key rejected (400 or 409)The key belongs to a completed request or a different body400 for a completed request cannot replay the answer; 409 can mean a changed body. Reuse the key only for an identical network retry within its window; inspect task settlement before choosing a new paid call
A long non-streamed call dies near 100 secondsNetwork edge cutoffStream the response

Cut over without losing the old baseline

Keep the old model ID in configuration and run the fixture on both. Move traffic once it passes on the new ID, your own sources validate, and logged usage matches the arithmetic above.

Also in 日本語 · 한국어 · Deutsch · Français · Español · Português (Brasil) · Русский · Italiano · Polski

Try it yourself

Pay only for what you run — no plan, no subscription.

Made with MiniMax H3 · ByteDance Seedance 2.0 Fast