Skip to content

Commit 648e708

Browse files
committed
feat: enable live-verified DeepSeek image uploads by default
1 parent 5b13256 commit 648e708

7 files changed

Lines changed: 149 additions & 27 deletions

File tree

‎.env.example‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,10 @@ DEEPSEEK_CLIENT_VERSION=2.0.0
5454
# Upstream base URL (only change it for a mirror or offline testing).
5555
DEEPSEEK_BASE_URL=https://chat.deepseek.com
5656

57-
# ── Images (experimental) ──
57+
# ── Images (live-verified flow; DeepSeek Web API may change) ──
5858
# Upload inline images (data: URLs / base64) to DeepSeek and reference them in
59-
# the completion. Off until verified against the live Web API.
60-
DEEPSEEK_IMAGE_UPLOAD=0
59+
# the completion. Enabled by default; set to 0 to disable image uploads.
60+
DEEPSEEK_IMAGE_UPLOAD=1
6161
# Most recent images sent into a new chat, and the size cap per image.
6262
DEEPSEEK_MAX_IMAGES=4
6363
DEEPSEEK_MAX_IMAGE_BYTES=7340032

‎README.md‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -646,15 +646,19 @@ FreeDeepseekAPI принимает:
646646
| `deepseek-v4-pro` | да | нет | устаревший: V4 Pro отключён, отвечает V4.1-Flash |
647647
| `deepseek-expert-search` | да | да | устаревший alias, теперь работает |
648648

649-
### Картинки (экспериментально)
649+
### Картинки (проверено на живом DeepSeek Web API)
650650

651-
Веб-чат понимает изображения в едином режиме. Proxy умеет загружать их в
652-
DeepSeek так же, как это делает веб-клиент (`/api/v0/file/upload_file` с PoW,
653-
ожидание обработки, `ref_file_ids` в completion). Пока это не проверено на
654-
живом DeepSeek, функция выключена по умолчанию:
651+
На живом DeepSeek Web API 7 октября 2026 года проверена передача PNG/JPEG через
652+
`deepseek-vision`: multipart upload, обработка файла от `PENDING` через
653+
`PARSING` до `SUCCESS`, ссылка на file ID в completion и визуальный ответ с
654+
читаемым текстом, цветами и расположением элементов. Почти квадратные
655+
прямоугольники модель назвала квадратами, поэтому точность распознавания формы
656+
нужно проверять для конкретной задачи. Загрузка картинок
657+
включена по умолчанию. Чтобы запретить отправку изображений в DeepSeek, явно
658+
задайте `DEEPSEEK_IMAGE_UPLOAD=0`:
655659

656660
```bash
657-
DEEPSEEK_IMAGE_UPLOAD=1 npm start
661+
DEEPSEEK_IMAGE_UPLOAD=0 npm start
658662
```
659663

660664
- принимаются встроенные картинки: OpenAI `image_url` / Responses
@@ -670,11 +674,11 @@ DEEPSEEK_IMAGE_UPLOAD=1 npm start
670674
картинки повторно не загружаются (кэш на 30 минут);
671675
- если DeepSeek не смог обработать картинку, запрос завершается ошибкой `502
672676
image_upload_failed`, а не тихим ответом «вслепую»;
673-
- с включённым флагом `deepseek-vision` появляется в `/v1/models` (это тот же
674-
единый режим).
677+
- при включённой загрузке `deepseek-vision` появляется в `/v1/models` (это тот
678+
же единый режим).
675679

676-
Без флага картинки заменяются коротким маркером `[Image attached; not visible
677-
through this proxy]` вместо вставки base64 в промпт.
680+
При `DEEPSEEK_IMAGE_UPLOAD=0` картинки заменяются коротким маркером `[Image
681+
attached; not visible through this proxy]`; их байты не загружаются в DeepSeek.
678682

679683
Полный маппинг:
680684

‎docs/api-documentation.md‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -321,11 +321,20 @@ and Search toggles:
321321
| `deepseek-reasoner-search`, `deepseek-r1-search` | ✓ | ✓ | |
322322
| `deepseek-expert`, `deepseek-v4-pro` | ✓ | – | deprecated: Expert/V4 Pro no longer exist |
323323
| `deepseek-expert-search` | ✓ | ✓ | deprecated |
324-
| `deepseek-vision` | – | – | needs `DEEPSEEK_IMAGE_UPLOAD=1` (see Images) |
324+
| `deepseek-vision` | – | – | enabled by default; set `DEEPSEEK_IMAGE_UPLOAD=0` to disable |
325325

326326
Unknown model names get `400 invalid_model`.
327327

328-
### Images (experimental, `DEEPSEEK_IMAGE_UPLOAD=1`)
328+
### Images (live-verified; set `DEEPSEEK_IMAGE_UPLOAD=0` to disable)
329+
330+
Live verification on 2026-10-07 confirmed inline PNG/JPEG upload, file status
331+
polling from `PENDING` through `PARSING` to `SUCCESS`, and `ref_file_ids` on
332+
completion. The synthetic image's text, colors, shapes, and arrangement were
333+
described, and a text-free image control was also checked. The model called
334+
near-square rectangles squares, so exact shape classification can still need
335+
manual review. Inline image upload is enabled by default. Setting
336+
`DEEPSEEK_IMAGE_UPLOAD=0` disables uploads and leaves the existing image marker
337+
in the prompt.
329338

330339
Inline images (OpenAI `image_url` / Responses `input_image` data URLs,
331340
Anthropic base64 `image` blocks, also inside `tool_result`) are uploaded with
@@ -393,7 +402,7 @@ loaded automatically (see `.env.example` for the full list).
393402
| `DEEPSEEK_STREAM_KEEPALIVE_MS` | `10000` | Silence before a `: keep-alive` comment on streamed responses |
394403
| `DEEPSEEK_REQUEST_DEADLINE_MS` | `120000` | Budget for retry/continuation loops |
395404
| `DEEPSEEK_MAX_CONCURRENT` | `24` | In-flight completions before 503 |
396-
| `DEEPSEEK_IMAGE_UPLOAD` | `0` | Upload inline images to DeepSeek (experimental) |
405+
| `DEEPSEEK_IMAGE_UPLOAD` | `1` (unset enables; `0` disables) | Upload inline images to DeepSeek |
397406
| `DEEPSEEK_MAX_IMAGES` / `DEEPSEEK_MAX_IMAGE_BYTES` | `4` / 7 MB | Image limits per new chat / per image |
398407
| `DEEPSEEK_FILE_TIMEOUT_MS` / `DEEPSEEK_FILE_POLL_MS` | `60000` / `1000` | Wait for DeepSeek to process an image |
399408
| `NON_INTERACTIVE` / `SKIP_ACCOUNT_MENU` | `0` | Start without the menu |
@@ -439,6 +448,6 @@ Auth file format (`npm run auth` / `npm run auth:import`, keep it `0600`):
439448

440449
- Depends on the private DeepSeek Web contract; DeepSeek can change it at any time.
441450
- Tool calling is prompt-emulated: one call per turn, the model can still ignore the format.
442-
- Image upload is experimental and off by default; other files are not uploaded.
451+
- Image upload is live-verified but relies on the private Web API contract, which DeepSeek can change; other files are not uploaded.
443452
- Usage numbers are estimates.
444453
- All agents on one account share its rate limits; use an account pool for parallel agents.

‎server.js‎

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -294,9 +294,14 @@ const MAX_SESSION_CONTEXT_CHARS = Math.max(
294294
const RATE_LIMIT_COOLDOWN_MS = envNumber('DEEPSEEK_RATE_LIMIT_COOLDOWN_MS', 60 * 1000, 1000);
295295
const DS_CLIENT_VERSION = process.env.DEEPSEEK_CLIENT_VERSION || '2.0.0';
296296
// Image understanding: DeepSeek Web reads images uploaded through its file
297-
// API and referenced by ref_file_ids. Opt-in until verified against the live
298-
// Web API; without it images become short text markers.
299-
const IMAGE_UPLOAD_ENABLED = isTruthy(process.env.DEEPSEEK_IMAGE_UPLOAD || '');
297+
// API and referenced by ref_file_ids. Live image upload/status/reference flow
298+
// was verified on 2026-10-07. Keep the opt-out explicit for deployments that
299+
// need to prevent upstream image uploads.
300+
function imageUploadEnabled(env = process.env) {
301+
const value = env.DEEPSEEK_IMAGE_UPLOAD;
302+
return value === undefined ? true : isTruthy(String(value));
303+
}
304+
const IMAGE_UPLOAD_ENABLED = imageUploadEnabled();
300305
const MAX_IMAGES_PER_REQUEST = Math.floor(envNumber('DEEPSEEK_MAX_IMAGES', 4, 1));
301306
// A 10 MB request body carries at most ~7.5 MB of base64-decoded images.
302307
const MAX_IMAGE_BYTES = Math.floor(envNumber('DEEPSEEK_MAX_IMAGE_BYTES', 7 * 1024 * 1024, 1024));
@@ -720,6 +725,22 @@ function extractUploadedFileStatus(json, fileId) {
720725
return { status: String(entry?.status || '').toUpperCase(), error: entry?.error_msg || entry?.error || null };
721726
}
722727

728+
// DeepSeek can report throttling inside an HTTP 200 business-error envelope.
729+
// Read a message only when the numeric biz_code says the envelope is an error;
730+
// normal successful response content is never inspected for rate-limit text.
731+
function throwIfImageBusinessRateLimited(account, resp, json) {
732+
const bizCode = json?.data?.biz_code;
733+
if (!Number.isSafeInteger(bizCode) || bizCode === 0) return;
734+
const bizData = json?.data?.biz_data;
735+
const message = [
736+
json?.data?.biz_msg, json?.data?.biz_message, json?.data?.message, json?.data?.msg,
737+
bizData?.biz_msg, bizData?.biz_message, bizData?.message, bizData?.msg, bizData?.error_msg,
738+
].find(value => typeof value === 'string' && value.length <= 2048);
739+
if (!message || !isRateLimitError(message)) return;
740+
// Do not expose upstream envelope text in a proxy error or log.
741+
throwIfRateLimited(account, resp.status, `Too many messages (DeepSeek business code ${bizCode})`, resp.headers.get('retry-after'));
742+
}
743+
723744
const FILE_READY_STATUS = /^(?:SUCCESS|PARSED|DONE|COMPLETED?|FINISHED|READY)$/;
724745
const FILE_FAILED_STATUS = /FAIL|ERROR|REJECT|UNSUPPORT|INVALID|EMPTY/;
725746

@@ -733,7 +754,11 @@ async function uploadImageToDeepSeek(account, image, signal, deadlineAt = Infini
733754
delete headers['Content-Type']; // fetch sets the multipart boundary
734755
const resp = await dsFetch(dsUrl(FILE_UPLOAD_PATH), { method: 'POST', headers, body: form, signal }, DS_FILE_TIMEOUT_MS);
735756
const { json, text } = await readDeepSeekJsonResponse(resp, 'file upload', account);
736-
if (!resp.ok) throw createUpstreamHttpError(resp.status, text, resp.headers.get('retry-after'));
757+
if (!resp.ok) {
758+
throwIfRateLimited(account, resp.status, text, resp.headers.get('retry-after'));
759+
throw createUpstreamHttpError(resp.status, text, resp.headers.get('retry-after'));
760+
}
761+
throwIfImageBusinessRateLimited(account, resp, json);
737762
const biz = json?.data?.biz_data;
738763
const fileId = biz?.id || biz?.file_id || biz?.file?.id;
739764
if (!fileId) throw new Error(`DeepSeek file upload returned no file id. First chars: ${String(text || '').substring(0, 160)}`);
@@ -748,7 +773,11 @@ async function uploadImageToDeepSeek(account, image, signal, deadlineAt = Infini
748773
await sleepWithSignal(DS_FILE_POLL_MS, signal);
749774
const pr = await dsFetch(dsUrl(`/api/v0/file/fetch_files?file_ids=${encodeURIComponent(fileId)}`), { method: 'GET', headers: statusHeaders, signal });
750775
const polled = await readDeepSeekJsonResponse(pr, 'file status', account);
751-
if (!pr.ok) throw createUpstreamHttpError(pr.status, polled.text);
776+
if (!pr.ok) {
777+
throwIfRateLimited(account, pr.status, polled.text, pr.headers.get('retry-after'));
778+
throw createUpstreamHttpError(pr.status, polled.text, pr.headers.get('retry-after'));
779+
}
780+
throwIfImageBusinessRateLimited(account, pr, polled.json);
752781
const state = extractUploadedFileStatus(polled.json, fileId);
753782
status = state.status;
754783
if (state.error && FILE_FAILED_STATUS.test(status)) throw new Error(`DeepSeek could not process the image: ${state.error}`);
@@ -3423,6 +3452,7 @@ module.exports = {
34233452
collectImages,
34243453
uploadedFileCache,
34253454
isRateLimitError,
3455+
imageUploadEnabled,
34263456
loadDotEnv,
34273457
MODEL_CONFIGS,
34283458
SUPPORTED_MODEL_IDS,

‎tests/integration.test.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ test('legacy Expert/V4 Pro aliases run on the unified Web model with DeepThink (
261261
const ids = models.data.map(m => m.id);
262262
assert.ok(ids.includes('deepseek-v4-flash'));
263263
assert.ok(ids.includes('deepseek-expert-search'));
264-
assert.ok(!ids.includes('deepseek-vision'));
264+
assert.equal(ids.includes('deepseek-vision'), internals.imageUploadEnabled());
265265
});
266266

267267
test('long generations outlive the connect timeout, stalled streams time out', async () => {

‎tests/unit.test.js‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -786,7 +786,9 @@ test('message normalization omits inline images and earlier reasoning blocks', (
786786
]);
787787
assert.ok(text.length < 300, text);
788788
assert.match(text, /look/);
789-
assert.match(text, /\[Image attached; not visible through this proxy\]/);
789+
assert.match(text, serverInternals.imageUploadEnabled()
790+
? /\[Image [0-9a-f]{12} attached\]/
791+
: /\[Image attached; not visible through this proxy\]/);
790792
assert.match(text, /\[Image: https:\/\/example\.com\/cat\.png\]/);
791793
assert.doesNotMatch(text, /secret|sig/);
792794
});
@@ -866,7 +868,31 @@ test('every supported model alias targets the unified DeepSeek Web mode (#31)',
866868
assert.equal(serverInternals.resolveModelConfig('deepseek-v4-pro').thinking_enabled, true);
867869
assert.equal(serverInternals.resolveModelConfig('deepseek-v4-flash').thinking_enabled, false);
868870
assert.equal(serverInternals.resolveModelConfig('deepseek-expert-search').supported, true);
869-
assert.equal(serverInternals.resolveModelConfig('deepseek-vision').supported, false);
871+
assert.equal(serverInternals.resolveModelConfig('deepseek-vision').supported, serverInternals.imageUploadEnabled());
872+
});
873+
874+
test('image uploads default on and retain the explicit zero opt-out', () => {
875+
assert.equal(serverInternals.imageUploadEnabled({}), true);
876+
assert.equal(serverInternals.imageUploadEnabled({ DEEPSEEK_IMAGE_UPLOAD: '1' }), true);
877+
assert.equal(serverInternals.imageUploadEnabled({ DEEPSEEK_IMAGE_UPLOAD: '0' }), false);
878+
assert.equal(serverInternals.imageUploadEnabled({ DEEPSEEK_IMAGE_UPLOAD: 'false' }), false, 'existing false-like opt-outs remain disabled');
879+
880+
// Check the flag at module initialization, including its actual model and
881+
// message-normalization behavior, rather than only the parsing helper.
882+
for (const enabled of [true, false]) {
883+
const result = runNode(['-e', `
884+
if (${enabled}) delete process.env.DEEPSEEK_IMAGE_UPLOAD;
885+
else process.env.DEEPSEEK_IMAGE_UPLOAD = '0';
886+
const api = require('./server.js').__test;
887+
const text = api.normalizeMessageContent([{ type: 'image_url', image_url: { url: 'data:image/png;base64,QUJD' } }]);
888+
console.log(JSON.stringify({ supported: api.resolveModelConfig('deepseek-vision').supported, text }));
889+
`]);
890+
assert.equal(result.status, 0, result.stderr);
891+
const actual = JSON.parse(result.stdout);
892+
assert.equal(actual.supported, enabled);
893+
assert.match(actual.text, enabled ? /\[Image [0-9a-f]{12} attached\]/ : /not visible through this proxy/);
894+
assert.doesNotMatch(actual.text, /base64|QUJD/);
895+
}
870896
});
871897

872898
test('rate-limit detector recognizes DeepSeek throttling messages', () => {

‎tests/vision.test.js‎

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const PNG_2 = Buffer.concat([PNG, Buffer.from('second image')]);
1010
const dataUrl = (buffer) => `data:image/png;base64,${buffer.toString('base64')}`;
1111

1212
function startMock() {
13-
const state = { pows: [], uploads: [], polls: 0, completions: [], fileStatus: () => 'SUCCESS', files: 0, chats: 0 };
13+
const state = { pows: [], uploads: [], polls: 0, completions: [], fileStatus: () => 'SUCCESS', uploadReply: null, pollReply: null, files: 0, chats: 0 };
1414
const server = http.createServer((req, res) => {
1515
const chunks = [];
1616
req.on('data', chunk => chunks.push(chunk));
@@ -25,11 +25,13 @@ function startMock() {
2525
if (req.url === '/api/v0/file/upload_file') {
2626
const id = `file-${++state.files}`;
2727
state.uploads.push({ id, contentType: req.headers['content-type'], pow: req.headers['x-ds-pow-response'], body: raw });
28+
if (state.uploadReply) return json(state.uploadReply.status, state.uploadReply.body);
2829
return json(200, { code: 0, data: { biz_code: 0, biz_data: { id, status: 'PENDING', file_name: 'image.png' } } });
2930
}
3031
if (req.url.startsWith('/api/v0/file/fetch_files')) {
3132
state.polls++;
3233
const id = new URL(req.url, 'http://x').searchParams.get('file_ids');
34+
if (state.pollReply) return json(state.pollReply.status, state.pollReply.body);
3335
return json(200, { code: 0, data: { biz_code: 0, biz_data: { files: [{ id, status: state.fileStatus(id, state.polls) }] } } });
3436
}
3537
if (req.url === '/api/v0/chat/completion') {
@@ -78,7 +80,7 @@ test.beforeEach(() => {
7880
});
7981
internals.sessions.clear();
8082
internals.uploadedFileCache.clear();
81-
Object.assign(mock.state, { pows: [], uploads: [], polls: 0, completions: [], files: 0, fileStatus: () => 'SUCCESS' });
83+
Object.assign(mock.state, { pows: [], uploads: [], polls: 0, completions: [], files: 0, uploadReply: null, pollReply: null, fileStatus: () => 'SUCCESS' });
8284
});
8385

8486
async function post(path, body) {
@@ -108,6 +110,57 @@ test('OpenAI image_url data URLs are uploaded and referenced by ref_file_ids', a
108110
assert.doesNotMatch(completion.prompt, /base64/);
109111
});
110112

113+
test('rate-limit messages in HTTP 200 business errors and HTTP 400 upload errors are classified', async t => {
114+
const body = { model: 'deepseek-vision', user: 'vision-upload-rate-limit', messages: [
115+
{ role: 'user', content: [{ type: 'image_url', image_url: { url: dataUrl(PNG) } }] },
116+
] };
117+
118+
const resetCase = () => {
119+
internals.accounts[0].cooldownUntil = 0;
120+
internals.accounts[0].failures = 0;
121+
internals.sessions.clear();
122+
internals.uploadedFileCache.clear();
123+
Object.assign(mock.state, { pows: [], uploads: [], polls: 0, completions: [], files: 0, uploadReply: null, pollReply: null });
124+
};
125+
126+
await t.test('HTTP 200 nonzero biz_code on upload', async () => {
127+
resetCase();
128+
mock.state.uploadReply = {
129+
status: 200,
130+
body: { code: 0, data: { biz_code: 1234, biz_msg: 'Too many messages in a short period' } },
131+
};
132+
const res = await post('/v1/chat/completions', body);
133+
assert.equal(res.status, 429, res.text);
134+
assert.equal(res.json.error.type, 'rate_limit_error');
135+
assert.equal(mock.state.completions.length, 0);
136+
});
137+
138+
await t.test('HTTP 200 nonzero biz_code on file poll', async () => {
139+
resetCase();
140+
mock.state.pollReply = {
141+
status: 200,
142+
body: { code: 0, data: { biz_code: 1234, biz_msg: 'Too many messages in a short period' } },
143+
};
144+
const res = await post('/v1/chat/completions', body);
145+
assert.equal(res.status, 429, res.text);
146+
assert.equal(res.json.error.type, 'rate_limit_error');
147+
assert.equal(mock.state.polls, 1);
148+
assert.equal(mock.state.completions.length, 0);
149+
});
150+
151+
await t.test('HTTP 400 upload error body', async () => {
152+
resetCase();
153+
mock.state.uploadReply = {
154+
status: 400,
155+
body: { error: { message: 'Too many messages in a short period' } },
156+
};
157+
const res = await post('/v1/chat/completions', body);
158+
assert.equal(res.status, 429, res.text);
159+
assert.equal(res.json.error.type, 'rate_limit_error');
160+
assert.equal(mock.state.completions.length, 0);
161+
});
162+
});
163+
111164
test('Anthropic images: follow-up turns upload only new images', async () => {
112165
const image = data => ({ type: 'image', source: { type: 'base64', media_type: 'image/png', data: data.toString('base64') } });
113166
const turn1 = [{ role: 'user', content: [image(PNG), { type: 'text', text: 'Describe it' }] }];

0 commit comments

Comments
 (0)