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 client | On SpicyAPI |
|---|---|
Base URL https://api.deepseek.com and a DeepSeek key | https://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-pro | deepseek/v4.1-flash/chat |
thinking: {"type": "disabled"}, the behaviour deepseek-chat used to give | reasoning_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, xhigh | Only none, low and high are accepted; anything else is a 400 invalid_request |
temperature, top_p, presence_penalty, frequency_penalty | Delete; 400 unsupported_parameter |
parallel_tool_calls | Delete; not exposed on this model |
stop | Keep: up to 4 sequences, and the matched text is left out of the reply |
image_url or file parts | Keep image_url parts for images; remove PDF/file parts and extract their text first |
OpenRouter models, route, provider, usage, transforms, plugins, and a reasoning object | Delete 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
- Open DeepSeek V4.1 Flash, choose Playground and set the effort control to Low.
- 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.
- 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.
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 record | Authorized | Completed |
|---|---|---|
| R17: “Customer approval is pending.” No later record | false | null |
| R18: “Customer approved the quoted replacement. Parts ordered; work scheduled for Thursday.” | true | null |
| R19: “Replaced pads; road check completed Thursday.” Linked to the approved job | true | true |
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 Flash | V4 Flash | V4 Pro | |
|---|---|---|---|
| Model ID | deepseek/v4.1-flash/chat | deepseek/v4-flash/chat | deepseek/v4-pro/chat |
| Max output | 384,000 | 65,536 | 384,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_calls | Not exposed | Not exposed | Exposed |
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
| Symptom | Likely cause | Correction |
|---|---|---|
400 unsupported_parameter naming temperature, top_p, a penalty, parallel_tool_calls or thinking | Field carried over from the old client | Delete it |
content is empty or finish_reason is length | max_tokens spent on reasoning, or the reply ran past it | Raise max_tokens, lower the effort, retry |
| JSON parses but reports completed work | Recommendation read as completion | Keep separate fields, require quotes, run the checker |
| Error on turn two of a tool conversation | Assistant message rebuilt without reasoning_content | Append the returned message unchanged |
| 402 with enough balance | 40201 hold exceeds balance, or 40202 spend cap reached | Inspect 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 body | 400 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 seconds | Network edge cutoff | Stream 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.








