@volter/twin-deepseek 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +198 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +28 -0
- package/dist/src/deepseek-budget.d.ts +51 -0
- package/dist/src/deepseek-budget.js +152 -0
- package/dist/src/deepseek-cache.d.ts +56 -0
- package/dist/src/deepseek-cache.js +151 -0
- package/dist/src/deepseek-capabilities.d.ts +4 -0
- package/dist/src/deepseek-capabilities.js +1520 -0
- package/dist/src/deepseek-conformance.d.ts +14 -0
- package/dist/src/deepseek-conformance.js +473 -0
- package/dist/src/deepseek-connector.d.ts +168 -0
- package/dist/src/deepseek-connector.js +386 -0
- package/dist/src/deepseek-models.d.ts +30 -0
- package/dist/src/deepseek-models.js +38 -0
- package/dist/src/deepseek-scenario.d.ts +55 -0
- package/dist/src/deepseek-scenario.js +170 -0
- package/dist/src/deepseek-server.d.ts +16 -0
- package/dist/src/deepseek-server.js +191 -0
- package/dist/src/deepseek-stub.d.ts +75 -0
- package/dist/src/deepseek-stub.js +191 -0
- package/dist/src/deepseek-twin.d.ts +77 -0
- package/dist/src/deepseek-twin.js +1103 -0
- package/dist/src/deepseek-types.d.ts +172 -0
- package/dist/src/deepseek-types.js +26 -0
- package/dist/src/index.d.ts +15 -0
- package/dist/src/index.js +93 -0
- package/package.json +68 -0
- package/src/cli.ts +27 -0
- package/src/deepseek-budget.ts +178 -0
- package/src/deepseek-cache.ts +159 -0
- package/src/deepseek-capabilities.ts +1443 -0
- package/src/deepseek-conformance.ts +512 -0
- package/src/deepseek-connector.ts +440 -0
- package/src/deepseek-models.ts +65 -0
- package/src/deepseek-scenario.ts +188 -0
- package/src/deepseek-server.ts +201 -0
- package/src/deepseek-stub.ts +200 -0
- package/src/deepseek-twin.ts +1163 -0
- package/src/deepseek-types.ts +201 -0
- package/src/index.ts +133 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
// DeepSeek twin conformance — an offline check with REAL TEETH: delete a handler branch and it goes
|
|
2
|
+
// RED. DeepSeek publishes no OpenAPI document, so the harness does not compare two constants (the
|
|
3
|
+
// false-green ADDING_A_TWIN.md §6 documents). Instead it runs FIVE passes:
|
|
4
|
+
//
|
|
5
|
+
// 1. PROBES — one real request per CLAIMED endpoint, graded on the OUTCOME a live handler
|
|
6
|
+
// produces: an expected status set PLUS a predicate over the body. A handler that returned
|
|
7
|
+
// `{}`, or whose branch was deleted (so the router falls through to its not-found), fails.
|
|
8
|
+
// 2. BIJECTION — the probe table and the claimed-endpoint snapshot must match two ways, so a
|
|
9
|
+
// claim with no probe and a probe with no claim are both RED.
|
|
10
|
+
// 3. ROUTER_SURFACE — a HAND-AUTHORED census of every method/path pair `routeDeepSeek` branches
|
|
11
|
+
// on, each with a LIVE request that must actually reach that branch.
|
|
12
|
+
// 4. MUST_NOT_SERVE — the paths DeepSeek does not serve (above all the OpenAI-shaped `/v1/...`
|
|
13
|
+
// prefix, which this vendor genuinely does not have) and the ones this twin does not model
|
|
14
|
+
// yet must answer the vendor-shaped not-found envelope. This closes the served-but-unclaimed
|
|
15
|
+
// direction that probe⇄claim alone is blind to.
|
|
16
|
+
// 5. REJECTIONS — the pass that matters most on an OpenAI-COMPATIBLE vendor. What distinguishes
|
|
17
|
+
// DeepSeek is what it REFUSES, so every documented refusal gets a live request asserting the
|
|
18
|
+
// exact status DeepSeek's own error table publishes: 422 for a bad parameter (NOT OpenAI's
|
|
19
|
+
// 400), 400 for a body-format problem, 402 for a drained balance. Its mirror image is checked
|
|
20
|
+
// too — the parameters DeepSeek explicitly DEPRECATES but still accepts must NOT error, or the
|
|
21
|
+
// twin would be refusing surface the vendor has.
|
|
22
|
+
//
|
|
23
|
+
// Fully offline + deterministic (drives the local handler against a temp root) so it runs without
|
|
24
|
+
// an API key. Honest scope: it checks the protocol envelope, NOT model output (a deterministic stub
|
|
25
|
+
// by design).
|
|
26
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
27
|
+
import { tmpdir } from 'node:os';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import { handleDeepSeekTwinRequest, type DeepSeekResponseEnvelope } from './deepseek-twin.ts';
|
|
30
|
+
import type { SseEvent } from './deepseek-types.ts';
|
|
31
|
+
|
|
32
|
+
export type ConformanceViolation = { check: string; detail: string };
|
|
33
|
+
export type DeepSeekConformanceReport = {
|
|
34
|
+
ok: boolean;
|
|
35
|
+
checksRun: number;
|
|
36
|
+
probes: number;
|
|
37
|
+
violations: ConformanceViolation[];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type Body = Record<string, any>;
|
|
41
|
+
const isObj = (v: unknown): v is Body => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* THE CLAIMED SURFACE. Every entry is a real request plus the OUTCOME a live handler produces.
|
|
45
|
+
* The expected values are LITERALS here, never imported from the handler — an assertion against
|
|
46
|
+
* the module's own constant is a tautology that cannot catch the value drifting.
|
|
47
|
+
*/
|
|
48
|
+
type Probe = {
|
|
49
|
+
/** `"<METHOD> <path>"` — the claim key, and the bijection key. */
|
|
50
|
+
key: string;
|
|
51
|
+
method: string;
|
|
52
|
+
path: string;
|
|
53
|
+
body?: unknown;
|
|
54
|
+
/** Which statuses a live handler may answer with. */
|
|
55
|
+
statuses: number[];
|
|
56
|
+
/** What a live handler's body must look like. */
|
|
57
|
+
ok: (body: unknown, status: number) => boolean;
|
|
58
|
+
/** Seed the root before probing (returns nothing; failures surface as probe failures). */
|
|
59
|
+
seed?: (h: (m: string, p: string, b?: unknown) => Promise<DeepSeekResponseEnvelope>) => Promise<void>;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const CHAT = { model: 'deepseek-v4-flash', messages: [{ role: 'user', content: 'hi' }] };
|
|
63
|
+
/** The image upload the Files API accepts (a real DeepSeek upload is multipart; the server maps it
|
|
64
|
+
* into this JSON contract). */
|
|
65
|
+
const UPLOAD = { purpose: 'user_data', filename: 'shot.png', media_type: 'image/png', content: 'AAA=', bytes: 3 };
|
|
66
|
+
/** The twin's first locally-minted file id (`nextFileId`). */
|
|
67
|
+
const FILE_1 = 'file-api-twin000000000001';
|
|
68
|
+
|
|
69
|
+
const PROBES: Probe[] = [
|
|
70
|
+
{
|
|
71
|
+
key: 'POST /chat/completions',
|
|
72
|
+
method: 'POST', path: '/chat/completions', body: CHAT, statuses: [200],
|
|
73
|
+
ok: (b) => isObj(b) && b.object === 'chat.completion' && Array.isArray(b.choices)
|
|
74
|
+
&& (b.choices as Body[])[0]?.message?.role === 'assistant'
|
|
75
|
+
&& typeof (b.choices as Body[])[0]?.message?.content === 'string'
|
|
76
|
+
// Thinking is ON by default on every V4 model, so a real turn carries reasoning_content.
|
|
77
|
+
&& typeof (b.choices as Body[])[0]?.message?.reasoning_content === 'string'
|
|
78
|
+
&& isObj(b.usage)
|
|
79
|
+
// DeepSeek's KV-cache split — the field pair OpenAI does not have, and the invariant its own
|
|
80
|
+
// docs state (hit + miss === prompt_tokens).
|
|
81
|
+
&& typeof b.usage.prompt_cache_hit_tokens === 'number'
|
|
82
|
+
&& typeof b.usage.prompt_cache_miss_tokens === 'number'
|
|
83
|
+
&& b.usage.prompt_cache_hit_tokens + b.usage.prompt_cache_miss_tokens === b.usage.prompt_tokens
|
|
84
|
+
&& typeof b.system_fingerprint === 'string',
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
key: 'POST /beta/chat/completions',
|
|
88
|
+
method: 'POST', path: '/beta/chat/completions', statuses: [200],
|
|
89
|
+
// The beta base URL is what unlocks assistant-prefix completion: DeepSeek CONTINUES the final
|
|
90
|
+
// assistant message rather than starting a new turn.
|
|
91
|
+
body: { model: 'deepseek-v4-flash', messages: [{ role: 'user', content: 'the sky is' }, { role: 'assistant', content: 'The sky is', prefix: true }] },
|
|
92
|
+
ok: (b) => isObj(b) && b.object === 'chat.completion'
|
|
93
|
+
&& String((b.choices as Body[])[0]?.message?.content ?? '').startsWith('The sky is'),
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
key: 'POST /beta/completions',
|
|
97
|
+
method: 'POST', path: '/beta/completions', body: { model: 'deepseek-v4-pro', prompt: 'def fib(', suffix: '\n return n' }, statuses: [200],
|
|
98
|
+
ok: (b) => isObj(b) && b.object === 'text_completion'
|
|
99
|
+
&& typeof (b.choices as Body[])[0]?.text === 'string'
|
|
100
|
+
&& String((b.choices as Body[])[0]!.text).includes('[twin-stub:deepseek-v4-pro]')
|
|
101
|
+
&& (b.choices as Body[])[0]?.finish_reason === 'stop',
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
key: 'GET /models',
|
|
105
|
+
method: 'GET', path: '/models', statuses: [200],
|
|
106
|
+
// Three keys and no more: DeepSeek's row has no `created` (OpenAI's does).
|
|
107
|
+
ok: (b) => isObj(b) && b.object === 'list' && Array.isArray(b.data)
|
|
108
|
+
&& (b.data as Body[]).some((m) => m.id === 'deepseek-v4-flash' && m.object === 'model' && m.owned_by === 'deepseek')
|
|
109
|
+
&& (b.data as Body[]).every((m) => Object.keys(m).sort().join(',') === 'id,object,owned_by'),
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
key: 'GET /user/balance',
|
|
113
|
+
method: 'GET', path: '/user/balance', statuses: [200],
|
|
114
|
+
ok: (b) => isObj(b) && b.is_available === true && Array.isArray(b.balance_infos)
|
|
115
|
+
// Balances are STRINGS on this vendor, not numbers.
|
|
116
|
+
&& typeof (b.balance_infos as Body[])[0]?.total_balance === 'string'
|
|
117
|
+
&& ['CNY', 'USD'].includes(String((b.balance_infos as Body[])[0]?.currency)),
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
key: 'POST /files',
|
|
121
|
+
method: 'POST', path: '/files', body: UPLOAD, statuses: [200],
|
|
122
|
+
ok: (b) => isObj(b) && b.object === 'file' && b.purpose === 'user_data' && b.filename === 'shot.png'
|
|
123
|
+
&& String(b.id).startsWith('file-api-twin'),
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
key: 'GET /files',
|
|
127
|
+
method: 'GET', path: '/files', statuses: [200],
|
|
128
|
+
seed: async (h) => { await h('POST', '/files', { ...UPLOAD, filename: 'seed.webp', media_type: 'image/webp' }); },
|
|
129
|
+
ok: (b) => isObj(b) && b.object === 'list' && (b.data as Body[]).some((f) => f.filename === 'seed.webp') && b.has_more === false,
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
key: 'GET /files/{file_id}',
|
|
133
|
+
method: 'GET', path: `/files/${FILE_1}`, statuses: [200],
|
|
134
|
+
seed: async (h) => { await h('POST', '/files', { ...UPLOAD, filename: 'one.jpg', media_type: 'image/jpeg' }); },
|
|
135
|
+
ok: (b) => isObj(b) && b.id === FILE_1 && b.filename === 'one.jpg' && b.purpose === 'user_data',
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
key: 'DELETE /files/{file_id}',
|
|
139
|
+
method: 'DELETE', path: `/files/${FILE_1}`, statuses: [200],
|
|
140
|
+
seed: async (h) => { await h('POST', '/files', UPLOAD); },
|
|
141
|
+
ok: (b) => isObj(b) && b.deleted === true && b.id === FILE_1 && b.object === 'file',
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
// A REAL DeepSeek surface this twin does not model. Probed because "unmodeled ops fail like the
|
|
145
|
+
// vendor" is a testable property, and because the branch that says so is a real router branch.
|
|
146
|
+
key: 'POST /anthropic/v1/messages',
|
|
147
|
+
method: 'POST', path: '/anthropic/v1/messages', body: { model: 'claude-3-5-sonnet', messages: [] }, statuses: [404],
|
|
148
|
+
ok: (b) => isObj(b) && isObj(b.error) && String(b.error.message).includes('Anthropic-compatible'),
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
// FIM exists ONLY under /beta. On the standard base URL it must fail, not quietly work.
|
|
152
|
+
key: 'POST /completions',
|
|
153
|
+
method: 'POST', path: '/completions', body: { model: 'deepseek-v4-pro', prompt: 'x' }, statuses: [404],
|
|
154
|
+
ok: (b) => isObj(b) && isObj(b.error) && String(b.error.message).includes('beta base URL'),
|
|
155
|
+
},
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* THE ROUTER CENSUS — hand-authored by READING every dispatch branch in `routeDeepSeek`, paired
|
|
160
|
+
* with a live request AND ITS OWN EXPECTED OUTCOME.
|
|
161
|
+
*
|
|
162
|
+
* §9 ROUND ONE, SHOULD-FIX 7: the first version was a character-for-character copy of the probe
|
|
163
|
+
* keys, compared against a second copy of those same keys — two hand-written constants in the same
|
|
164
|
+
* file asserting about each other, which is §6's named false-green. Worse, the run-time half graded
|
|
165
|
+
* only "not the router's own miss", which is exactly tinybird's REPLACEMENT defect: teeth at the
|
|
166
|
+
* dispatch and nowhere deeper, so a branch could lose its body and stay green.
|
|
167
|
+
*
|
|
168
|
+
* Each entry now carries `expect`, a predicate over (status, body) written INDEPENDENTLY of the
|
|
169
|
+
* probe table. Deleting a branch reddens its census row by name whether or not its probe exists,
|
|
170
|
+
* and adding a branch nobody claimed is caught by the router-miss check plus the claim bijection.
|
|
171
|
+
*/
|
|
172
|
+
type CensusEntry = {
|
|
173
|
+
key: string; method: string; path: string; body?: unknown;
|
|
174
|
+
/** What reaching this branch looks like — NOT merely "did not fall through". */
|
|
175
|
+
expect: (status: number, b: Body) => boolean;
|
|
176
|
+
};
|
|
177
|
+
const isErr = (b: Body) => isObj(b) && isObj(b.error) && typeof b.error.message === 'string';
|
|
178
|
+
|
|
179
|
+
const ROUTER_SURFACE: CensusEntry[] = [
|
|
180
|
+
{ key: 'POST /chat/completions', method: 'POST', path: '/chat/completions', body: CHAT,
|
|
181
|
+
expect: (st, b) => st === 200 && b.object === 'chat.completion' && typeof (b.choices as Body[])[0]?.message?.content === 'string' },
|
|
182
|
+
// BETA-ONLY expectation, deliberately: `/chat/completions` and `/beta/chat/completions` are the
|
|
183
|
+
// SAME router branch, so a row whose predicate any chat response satisfies grades nothing the
|
|
184
|
+
// previous row already did (§9 round two, NIT 3). Prefix continuation is reachable ONLY under
|
|
185
|
+
// /beta, so this row dies if the beta gate stops being computed.
|
|
186
|
+
{ key: 'POST /beta/chat/completions', method: 'POST', path: '/beta/chat/completions',
|
|
187
|
+
body: { model: 'deepseek-v4-flash', messages: [{ role: 'user', content: 'the sky' }, { role: 'assistant', content: 'The sky is', prefix: true }] },
|
|
188
|
+
expect: (st, b) => st === 200 && b.object === 'chat.completion'
|
|
189
|
+
&& String((b.choices as Body[])[0]?.message?.content ?? '').startsWith('The sky is') },
|
|
190
|
+
{ key: 'POST /beta/completions', method: 'POST', path: '/beta/completions', body: { model: 'deepseek-v4-pro', prompt: 'x' },
|
|
191
|
+
expect: (st, b) => st === 200 && b.object === 'text_completion' && typeof (b.choices as Body[])[0]?.text === 'string' },
|
|
192
|
+
{ key: 'POST /completions', method: 'POST', path: '/completions', body: { model: 'deepseek-v4-pro', prompt: 'x' },
|
|
193
|
+
// The non-beta FIM branch exists to REFUSE, and it must say why.
|
|
194
|
+
expect: (st, b) => st >= 400 && st < 500 && isErr(b) && String(b.error.message).includes('beta base URL') },
|
|
195
|
+
{ key: 'GET /models', method: 'GET', path: '/models',
|
|
196
|
+
expect: (st, b) => st === 200 && b.object === 'list' && (b.data as Body[]).some((m) => m.id === 'deepseek-v4-flash') },
|
|
197
|
+
{ key: 'GET /user/balance', method: 'GET', path: '/user/balance',
|
|
198
|
+
expect: (st, b) => st === 200 && typeof b.is_available === 'boolean' && Array.isArray(b.balance_infos) },
|
|
199
|
+
{ key: 'POST /files', method: 'POST', path: '/files', body: UPLOAD,
|
|
200
|
+
expect: (st, b) => st === 200 && b.object === 'file' && String(b.id).startsWith('file-api-') },
|
|
201
|
+
{ key: 'GET /files', method: 'GET', path: '/files',
|
|
202
|
+
expect: (st, b) => st === 200 && b.object === 'list' && Array.isArray(b.data) && b.has_more === false },
|
|
203
|
+
{ key: 'GET /files/{file_id}', method: 'GET', path: `/files/${FILE_1}`,
|
|
204
|
+
// No file exists in this cell's fresh root, so reaching the branch means its OWN not-found —
|
|
205
|
+
// which is a different message from the router's, and that difference is the tooth.
|
|
206
|
+
expect: (st, b) => st >= 400 && st < 500 && isErr(b) && String(b.error.message).includes('No such File object') },
|
|
207
|
+
{ key: 'DELETE /files/{file_id}', method: 'DELETE', path: `/files/${FILE_1}`,
|
|
208
|
+
expect: (st, b) => st >= 400 && st < 500 && isErr(b) && String(b.error.message).includes('No such File object') },
|
|
209
|
+
{ key: 'POST /anthropic/v1/messages', method: 'POST', path: '/anthropic/v1/messages', body: {},
|
|
210
|
+
expect: (st, b) => st >= 400 && st < 500 && isErr(b) && String(b.error.message).includes('Anthropic-compatible') },
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
/** The literal the router falls through to when NO branch matched. A census entry answering this
|
|
214
|
+
* means its branch is gone. Kept as a LITERAL — importing the handler's string would be a
|
|
215
|
+
* tautology that could not catch the message drifting. */
|
|
216
|
+
const ROUTER_MISS = 'Unknown request URL';
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Surface the twin must NOT serve. The first three entries are the headline OpenAI-divergence:
|
|
220
|
+
* DeepSeek's base_url has NO version segment, so a twin answering on `/v1/...` would be asserting a
|
|
221
|
+
* route the vendor does not have. The rest are OpenAI endpoints DeepSeek genuinely lacks.
|
|
222
|
+
*/
|
|
223
|
+
const MUST_NOT_SERVE: Array<{ method: string; path: string; why: string }> = [
|
|
224
|
+
// NOTE what is deliberately NOT in this list: `/responses` and `/anthropic/*`. Those are REAL
|
|
225
|
+
// DeepSeek surface this twin has not modeled yet — they belong in the manifest as todos and in
|
|
226
|
+
// the unmodeled-ops capability, not in a table whose whole claim is "the vendor does not have
|
|
227
|
+
// this". Putting them here would assert a vendor fact that is false.
|
|
228
|
+
{ method: 'POST', path: '/v1/chat/completions', why: "the OpenAI-shaped `/v1` prefix — DeepSeek's own base_url carries no version segment" },
|
|
229
|
+
{ method: 'GET', path: '/v1/models', why: 'same: there is no /v1 segment on this vendor' },
|
|
230
|
+
{ method: 'GET', path: '/v1/user/balance', why: 'same: there is no /v1 segment on this vendor' },
|
|
231
|
+
{ method: 'POST', path: '/embeddings', why: 'OpenAI has embeddings; DeepSeek does not — @ai-sdk/deepseek throws NoSuchModelError for embeddingModel' },
|
|
232
|
+
{ method: 'POST', path: '/images/generations', why: 'OpenAI has image generation; DeepSeek does not — the SDK throws NoSuchModelError for imageModel' },
|
|
233
|
+
{ method: 'POST', path: '/audio/transcriptions', why: 'OpenAI has audio; DeepSeek publishes no audio endpoints at all' },
|
|
234
|
+
{ method: 'POST', path: '/moderations', why: 'OpenAI has this endpoint; DeepSeek does not' },
|
|
235
|
+
{ method: 'POST', path: '/batches', why: 'OpenAI has a Batch API; DeepSeek does not, and its Files API accepts images only' },
|
|
236
|
+
{ method: 'GET', path: '/models/deepseek-v4-flash', why: 'DeepSeek documents only the LIST models endpoint; there is no per-model retrieve' },
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* THE REJECTION TABLE — the fidelity surface of an OpenAI-compatible vendor.
|
|
241
|
+
*
|
|
242
|
+
* Each row is a live request that must be REFUSED with the exact status DeepSeek's own error table
|
|
243
|
+
* publishes (api-docs.deepseek.com/quick_start/error_codes: 400 Invalid Format, 422 Invalid
|
|
244
|
+
* Parameters). The statuses are LITERALS, and the split between them is the point: OpenAI answers
|
|
245
|
+
* 400 for everything here, so a twin copied from an OpenAI-shaped exemplar would pass a 400-only
|
|
246
|
+
* check while being wrong on every single row.
|
|
247
|
+
*/
|
|
248
|
+
const REJECTIONS: Array<{ name: string; method: string; path: string; body: unknown; status: number; why: string }> = [
|
|
249
|
+
// ── parameters DeepSeek's closed table does not declare (OpenAI's do) ──
|
|
250
|
+
{ name: 'n', method: 'POST', path: '/chat/completions', body: { ...CHAT, n: 2 }, status: 422, why: "'n' is not a DeepSeek parameter" },
|
|
251
|
+
{ name: 'seed', method: 'POST', path: '/chat/completions', body: { ...CHAT, seed: 42 }, status: 422, why: "'seed' is not a DeepSeek parameter" },
|
|
252
|
+
{ name: 'logit_bias', method: 'POST', path: '/chat/completions', body: { ...CHAT, logit_bias: { '1': 1 } }, status: 422, why: "'logit_bias' is not a DeepSeek parameter" },
|
|
253
|
+
{ name: 'top_k', method: 'POST', path: '/chat/completions', body: { ...CHAT, top_k: 5 }, status: 422, why: "'top_k' is not a DeepSeek parameter" },
|
|
254
|
+
{ name: 'user', method: 'POST', path: '/chat/completions', body: { ...CHAT, user: 'u1' }, status: 422, why: "DeepSeek's end-user identifier is 'user_id', not OpenAI's 'user'" },
|
|
255
|
+
{ name: 'max_completion_tokens', method: 'POST', path: '/chat/completions', body: { ...CHAT, max_completion_tokens: 10 }, status: 422, why: "DeepSeek declares 'max_tokens' only" },
|
|
256
|
+
{ name: 'service_tier', method: 'POST', path: '/chat/completions', body: { ...CHAT, service_tier: 'flex' }, status: 422, why: 'DeepSeek has no service tiers' },
|
|
257
|
+
{ name: 'parallel_tool_calls', method: 'POST', path: '/chat/completions', body: { ...CHAT, parallel_tool_calls: false }, status: 422, why: 'not in DeepSeek\'s parameter table' },
|
|
258
|
+
{ name: 'functions', method: 'POST', path: '/chat/completions', body: { ...CHAT, functions: [{ name: 'f' }] }, status: 422, why: "OpenAI's deprecated 'functions' has no DeepSeek counterpart" },
|
|
259
|
+
// ── closed value sets ──
|
|
260
|
+
{ name: 'response_format json_schema', method: 'POST', path: '/chat/completions', body: { ...CHAT, response_format: { type: 'json_schema', json_schema: { name: 'x', schema: {} } } }, status: 422, why: "DeepSeek's response_format accepts 'text' and 'json_object' only" },
|
|
261
|
+
{ name: 'thinking.type', method: 'POST', path: '/chat/completions', body: { ...CHAT, thinking: { type: 'adaptive' } }, status: 422, why: "thinking.type is 'enabled' | 'disabled'" },
|
|
262
|
+
{ name: 'reasoning_effort', method: 'POST', path: '/chat/completions', body: { ...CHAT, reasoning_effort: 'ultra' }, status: 422, why: "reasoning_effort is 'low' | 'high' | 'max'" },
|
|
263
|
+
{ name: 'tool_choice', method: 'POST', path: '/chat/completions', body: { ...CHAT, tool_choice: 'any' }, status: 422, why: "tool_choice is 'none' | 'auto' | 'required' or a named function" },
|
|
264
|
+
{ name: 'retired model', method: 'POST', path: '/chat/completions', body: { ...CHAT, model: 'deepseek-chat' }, status: 422, why: 'deepseek-chat was retired on 2026-07-24' },
|
|
265
|
+
{ name: 'unknown model', method: 'POST', path: '/chat/completions', body: { ...CHAT, model: 'gpt-4o' }, status: 422, why: 'not a DeepSeek model' },
|
|
266
|
+
// ── documented numeric bounds ──
|
|
267
|
+
{ name: 'top_logprobs range', method: 'POST', path: '/chat/completions', body: { ...CHAT, logprobs: true, top_logprobs: 21 }, status: 422, why: 'top_logprobs is 0..20' },
|
|
268
|
+
{ name: 'top_logprobs without logprobs', method: 'POST', path: '/chat/completions', body: { ...CHAT, top_logprobs: 5 }, status: 422, why: 'top_logprobs requires logprobs: true' },
|
|
269
|
+
{ name: 'temperature range', method: 'POST', path: '/chat/completions', body: { ...CHAT, temperature: 3 }, status: 422, why: 'temperature is 0..2' },
|
|
270
|
+
{ name: 'stop count', method: 'POST', path: '/chat/completions', body: { ...CHAT, stop: Array.from({ length: 17 }, (_, i) => `s${i}`) }, status: 422, why: 'up to 16 stop sequences' },
|
|
271
|
+
{ name: 'user_id charset', method: 'POST', path: '/chat/completions', body: { ...CHAT, user_id: 'a b' }, status: 422, why: 'user_id must match /^[a-zA-Z0-9_-]+$/' },
|
|
272
|
+
{ name: 'user_id length', method: 'POST', path: '/chat/completions', body: { ...CHAT, user_id: 'a'.repeat(513) }, status: 422, why: 'user_id is at most 512 characters' },
|
|
273
|
+
// ── beta-only features refused on the standard base URL ──
|
|
274
|
+
{ name: 'strict tools off beta', method: 'POST', path: '/chat/completions', body: { ...CHAT, tools: [{ type: 'function', function: { name: 'f', strict: true, parameters: {} } }], messages: [{ role: 'user', content: 'x' }] }, status: 422, why: 'strict tool calls require the /beta base URL' },
|
|
275
|
+
{ name: 'prefix off beta', method: 'POST', path: '/chat/completions', body: { model: 'deepseek-v4-flash', messages: [{ role: 'user', content: 'x' }, { role: 'assistant', content: 'y', prefix: true }] }, status: 422, why: 'prefix completion requires the /beta base URL' },
|
|
276
|
+
{ name: 'prefix not final', method: 'POST', path: '/beta/chat/completions', body: { model: 'deepseek-v4-flash', messages: [{ role: 'assistant', content: 'y', prefix: true }, { role: 'user', content: 'x' }] }, status: 422, why: 'the prefixed message must be the final message' },
|
|
277
|
+
{ name: 'mixed strict tools', method: 'POST', path: '/beta/chat/completions', body: { ...CHAT, tools: [{ type: 'function', function: { name: 'a', strict: true, parameters: {} } }, { type: 'function', function: { name: 'b', parameters: {} } }], messages: [{ role: 'user', content: 'x' }] }, status: 422, why: 'strict mode is all-or-nothing' },
|
|
278
|
+
// ── the reasoning_content hand-back rule: a documented 400, NOT a 422 ──
|
|
279
|
+
{
|
|
280
|
+
name: 'tools without reasoning_content', method: 'POST', path: '/chat/completions', status: 400,
|
|
281
|
+
body: {
|
|
282
|
+
model: 'deepseek-v4-flash',
|
|
283
|
+
messages: [{ role: 'user', content: 'x' }, { role: 'assistant', content: 'y' }, { role: 'user', content: 'z' }],
|
|
284
|
+
tools: [{ type: 'function', function: { name: 'f', parameters: {} } }],
|
|
285
|
+
},
|
|
286
|
+
why: 'with tools set, previous assistant turns must carry back reasoning_content (documented 400)',
|
|
287
|
+
},
|
|
288
|
+
// ── FIM's own closed set, and its logprobs TYPE divergence from chat ──
|
|
289
|
+
{ name: 'fim model', method: 'POST', path: '/beta/completions', body: { model: 'deepseek-v4-flash', prompt: 'x' }, status: 422, why: 'FIM accepts deepseek-v4-pro only' },
|
|
290
|
+
{ name: 'fim logprobs is an integer', method: 'POST', path: '/beta/completions', body: { model: 'deepseek-v4-pro', prompt: 'x', logprobs: true }, status: 422, why: "the FIM endpoint's logprobs is an integer, unlike chat's boolean" },
|
|
291
|
+
{ name: 'fim messages', method: 'POST', path: '/beta/completions', body: { model: 'deepseek-v4-pro', messages: [] }, status: 422, why: 'FIM takes prompt/suffix, not messages' },
|
|
292
|
+
// ── the Files API is IMAGE-ONLY ──
|
|
293
|
+
{ name: 'file purpose', method: 'POST', path: '/files', body: { ...UPLOAD, purpose: 'batch' }, status: 422, why: "purpose must be 'user_data'" },
|
|
294
|
+
{ name: 'file type', method: 'POST', path: '/files', body: { purpose: 'user_data', filename: 'in.jsonl', media_type: 'application/jsonl', content: 'x', bytes: 1 }, status: 422, why: 'DeepSeek file uploads support JPEG, PNG, GIF and WebP only' },
|
|
295
|
+
{ name: 'file expiry range', method: 'POST', path: '/files', body: { ...UPLOAD, 'expires_after[anchor]': 'created_at', 'expires_after[seconds]': 60 }, status: 422, why: 'expires_after[seconds] is 3600..2592000' },
|
|
296
|
+
{ name: 'file list limit', method: 'GET', path: '/files?limit=5000', body: undefined, status: 422, why: 'limit is 1..1000' },
|
|
297
|
+
];
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* THE MIRROR IMAGE of the rejection table, and just as load-bearing: parameters DeepSeek documents
|
|
301
|
+
* as DEPRECATED-but-accepted, or as ignored while thinking is enabled, MUST NOT error. "Setting
|
|
302
|
+
* these parameters will not trigger an error but will also have no effect"
|
|
303
|
+
* (api-docs.deepseek.com/guides/thinking_mode). A twin that 4xx'd them would be refusing surface the
|
|
304
|
+
* vendor has — the same infidelity as serving surface it does not, pointing the other way.
|
|
305
|
+
*/
|
|
306
|
+
const MUST_ACCEPT: Array<{ name: string; body: unknown }> = [
|
|
307
|
+
{ name: 'frequency_penalty (deprecated, no effect)', body: { ...CHAT, frequency_penalty: 1.5 } },
|
|
308
|
+
{ name: 'presence_penalty (deprecated, no effect)', body: { ...CHAT, presence_penalty: 1.5 } },
|
|
309
|
+
{ name: 'temperature while thinking is enabled (ignored, not refused)', body: { ...CHAT, temperature: 0.7 } },
|
|
310
|
+
{ name: 'top_p while thinking is enabled (ignored, not refused)', body: { ...CHAT, top_p: 0.9 } },
|
|
311
|
+
{ name: 'thinking.reasoning_effort placement (the API reference\'s form)', body: { ...CHAT, thinking: { type: 'enabled', reasoning_effort: 'max' } } },
|
|
312
|
+
{ name: 'top-level reasoning_effort (the form @ai-sdk/deepseek sends)', body: { ...CHAT, reasoning_effort: 'low' } },
|
|
313
|
+
{ name: 'legacy reasoning_effort medium (documented as mapped to high)', body: { ...CHAT, reasoning_effort: 'medium' } },
|
|
314
|
+
{ name: 'message name on a system turn', body: { model: 'deepseek-v4-flash', messages: [{ role: 'system', content: 's', name: 'planner' }, { role: 'user', content: 'x' }] } },
|
|
315
|
+
];
|
|
316
|
+
|
|
317
|
+
/** Run the offline conformance checks against a fresh temp root. */
|
|
318
|
+
export async function checkDeepSeekConformance(opts: { root?: string } = {}): Promise<DeepSeekConformanceReport> {
|
|
319
|
+
const violations: ConformanceViolation[] = [];
|
|
320
|
+
let checksRun = 0;
|
|
321
|
+
const fail = (check: string, detail: string) => violations.push({ check, detail });
|
|
322
|
+
const freshRoot = () => mkdtempSync(join(opts.root ?? tmpdir(), 'deepseek-conf-'));
|
|
323
|
+
|
|
324
|
+
// ── 1. PROBES: each in its OWN throwaway root, so a probe's seed can't leak into another. ──
|
|
325
|
+
for (const probe of PROBES) {
|
|
326
|
+
checksRun++;
|
|
327
|
+
// A FRESH root per probe, always. `opts.root` is the PARENT directory, never a shared root:
|
|
328
|
+
// sharing it would let `nextFileId` ratchet across probes, so the `/files/{id}` probe would read
|
|
329
|
+
// an id an earlier probe minted and report a false violation.
|
|
330
|
+
const root = freshRoot();
|
|
331
|
+
const h = (method: string, path: string, body?: unknown) =>
|
|
332
|
+
handleDeepSeekTwinRequest({ method, path, ...(body === undefined ? {} : { body: JSON.stringify(body) }), root });
|
|
333
|
+
try {
|
|
334
|
+
if (probe.seed) await probe.seed(h);
|
|
335
|
+
const res = await h(probe.method, probe.path, probe.body);
|
|
336
|
+
if (!probe.statuses.includes(res.status)) {
|
|
337
|
+
fail(`probe:${probe.key}`, `status ${res.status} (expected one of ${probe.statuses.join('/')}) body=${JSON.stringify(res.body).slice(0, 200)}`);
|
|
338
|
+
} else if (!probe.ok(res.body, res.status)) {
|
|
339
|
+
fail(`probe:${probe.key}`, `body did not satisfy the live-handler predicate: ${JSON.stringify(res.body).slice(0, 300)}`);
|
|
340
|
+
}
|
|
341
|
+
} finally {
|
|
342
|
+
rmSync(root, { recursive: true, force: true });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── 2. BIJECTION: probe table ⇄ router census, both directions. ──
|
|
347
|
+
checksRun++;
|
|
348
|
+
// The bijection is between the CLAIMED endpoints (probes) and the branches the router actually
|
|
349
|
+
// dispatches on (the census) — two independently authored tables, not one table compared with a
|
|
350
|
+
// copy of itself. A census row with no probe is unclaimed surface; a probe with no census row is
|
|
351
|
+
// a claim about a branch nobody enumerated.
|
|
352
|
+
const probeKeys = new Set(PROBES.map((p) => p.key));
|
|
353
|
+
const censusKeys = new Set(ROUTER_SURFACE.map((e) => e.key));
|
|
354
|
+
for (const key of censusKeys) if (!probeKeys.has(key)) fail('bijection', `router branch "${key}" has no probe`);
|
|
355
|
+
for (const key of probeKeys) if (!censusKeys.has(key)) fail('bijection', `probe "${key}" is not in the hand-authored router census`);
|
|
356
|
+
if (PROBES.length !== probeKeys.size) fail('bijection', 'duplicate probe key');
|
|
357
|
+
if (ROUTER_SURFACE.length !== censusKeys.size) fail('bijection', 'duplicate router census key');
|
|
358
|
+
|
|
359
|
+
// ── 3. THE CENSUS HAS TEETH: every declared branch must actually be REACHED. A branch whose
|
|
360
|
+
// handler was deleted falls through to the router's own not-found, which is what this
|
|
361
|
+
// catches — independently of that endpoint's own probe.
|
|
362
|
+
for (const entry of ROUTER_SURFACE) {
|
|
363
|
+
checksRun++;
|
|
364
|
+
const root = freshRoot();
|
|
365
|
+
try {
|
|
366
|
+
const res = await handleDeepSeekTwinRequest({ method: entry.method, path: entry.path, ...(entry.body === undefined ? {} : { body: JSON.stringify(entry.body) }), root });
|
|
367
|
+
const message = String(((res.body as Body)?.error as Body)?.message ?? '');
|
|
368
|
+
if (message.includes(ROUTER_MISS)) {
|
|
369
|
+
fail('router_surface', `${entry.key} fell through to the router's not-found — the branch is gone (answered ${res.status}: ${message})`);
|
|
370
|
+
} else if (!entry.expect(res.status, res.body as Body)) {
|
|
371
|
+
// The tooth that reaches PAST the dispatch: a branch whose body was emptied still avoids
|
|
372
|
+
// the router's miss, so "not the miss" alone is not proof it works.
|
|
373
|
+
fail('router_surface', `${entry.key} reached its branch but the outcome is wrong: ${res.status} ${JSON.stringify(res.body).slice(0, 200)}`);
|
|
374
|
+
}
|
|
375
|
+
} finally {
|
|
376
|
+
rmSync(root, { recursive: true, force: true });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
{
|
|
381
|
+
// §9 ROUND ONE, SHOULD-FIX 9: `opts.root` used to mean two incompatible things — a PARENT
|
|
382
|
+
// directory for the per-probe temp roots above, and the state root itself here (which was then
|
|
383
|
+
// never cleaned up when supplied). It now has exactly ONE meaning, the parent directory, and
|
|
384
|
+
// every state root is a temp dir this function creates and removes.
|
|
385
|
+
const root = freshRoot();
|
|
386
|
+
try {
|
|
387
|
+
// ── 4. The twin must not serve surface the vendor does not have. ──
|
|
388
|
+
for (const entry of MUST_NOT_SERVE) {
|
|
389
|
+
checksRun++;
|
|
390
|
+
const res = await handleDeepSeekTwinRequest({ method: entry.method, path: entry.path, body: JSON.stringify(CHAT), root });
|
|
391
|
+
if (res.status !== 404) fail('must_not_serve', `${entry.method} ${entry.path} answered ${res.status}, not 404 — ${entry.why}`);
|
|
392
|
+
else if (!isObj(res.body) || !isObj((res.body as Body).error)) fail('must_not_serve', `${entry.method} ${entry.path} 404 body is not the vendor error envelope`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── 5. THE REJECTIONS — the fidelity surface. Status is asserted EXACTLY, because the whole
|
|
396
|
+
// point is DeepSeek's 422 where OpenAI answers 400.
|
|
397
|
+
for (const r of REJECTIONS) {
|
|
398
|
+
checksRun++;
|
|
399
|
+
const res = await handleDeepSeekTwinRequest({ method: r.method, path: r.path, ...(r.body === undefined ? {} : { body: JSON.stringify(r.body) }), root });
|
|
400
|
+
if (res.status !== r.status) {
|
|
401
|
+
fail('rejections', `${r.name}: answered ${res.status}, expected ${r.status} — ${r.why} (body=${JSON.stringify(res.body).slice(0, 160)})`);
|
|
402
|
+
} else if (!isObj(res.body) || typeof ((res.body as Body).error as Body)?.message !== 'string') {
|
|
403
|
+
fail('rejections', `${r.name}: refusal body is not DeepSeek's { error: { message } } envelope`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ── 5b. …and its mirror image: what DeepSeek deprecates but still ACCEPTS must not error. ──
|
|
408
|
+
for (const a of MUST_ACCEPT) {
|
|
409
|
+
checksRun++;
|
|
410
|
+
const res = await handleDeepSeekTwinRequest({ method: 'POST', path: '/chat/completions', body: JSON.stringify(a.body), root });
|
|
411
|
+
if (res.status !== 200) fail('must_accept', `${a.name}: answered ${res.status}, but DeepSeek accepts this (deprecated/ignored, not an error)`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ── 6. Body-format 400 is DISTINCT from parameter 422 on this vendor. ──
|
|
415
|
+
checksRun++;
|
|
416
|
+
const malformed = await handleDeepSeekTwinRequest({ method: 'POST', path: '/chat/completions', body: '{not json', root });
|
|
417
|
+
if (malformed.status !== 400) fail('error.status_split', `a malformed body answered ${malformed.status}, not DeepSeek's 400 "Invalid request body format"`);
|
|
418
|
+
checksRun++;
|
|
419
|
+
const missingParam = await handleDeepSeekTwinRequest({ method: 'POST', path: '/chat/completions', body: JSON.stringify({ model: 'deepseek-v4-flash' }), root });
|
|
420
|
+
if (missingParam.status !== 422) fail('error.status_split', `a MISSING required parameter answered ${missingParam.status}, not 422 — a missing parameter is the same class as a bad one and must not be reported two ways`);
|
|
421
|
+
checksRun++;
|
|
422
|
+
const badParam = await handleDeepSeekTwinRequest({ method: 'POST', path: '/chat/completions', body: JSON.stringify({ ...CHAT, model: 'nope' }), root });
|
|
423
|
+
if (badParam.status !== 422) fail('error.status_split', `a bad parameter answered ${badParam.status}, not DeepSeek's 422 "Invalid Parameters"`);
|
|
424
|
+
|
|
425
|
+
// ── 7. The error envelope carries only keys `@ai-sdk/deepseek`'s decoder declares. The key
|
|
426
|
+
// list is a LITERAL here — asserting against the handler's own constant would be a
|
|
427
|
+
// tautology that could not catch it drifting.
|
|
428
|
+
checksRun++;
|
|
429
|
+
const ERROR_KEYS = new Set(['message', 'type', 'param', 'code']);
|
|
430
|
+
const eb = badParam.body as Body;
|
|
431
|
+
if (!isObj(eb?.error)) {
|
|
432
|
+
fail('error.envelope', 'a refusal did not carry an error envelope');
|
|
433
|
+
} else {
|
|
434
|
+
const undeclared = Object.keys(eb.error).filter((k) => !ERROR_KEYS.has(k));
|
|
435
|
+
if (undeclared.length) fail('error.envelope', `error object carries key(s) deepSeekErrorSchema does not declare: ${undeclared.join(', ')}`);
|
|
436
|
+
if (typeof eb.error.message !== 'string' || !eb.error.message) fail('error.envelope', 'error.message is not a non-empty string');
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ── 8. 402 Insufficient Balance — DeepSeek's own status, with no OpenAI counterpart, and
|
|
440
|
+
// STATE-DRIVEN: a drained account refuses completions. Asserted here rather than only in
|
|
441
|
+
// the manifest because it is the one refusal that depends on the projection.
|
|
442
|
+
checksRun++;
|
|
443
|
+
const drained = freshRoot();
|
|
444
|
+
try {
|
|
445
|
+
const { applyTwinWrite } = await import('@volter/world-core');
|
|
446
|
+
await applyTwinWrite('deepseek', {
|
|
447
|
+
operation: 'balance.update', subjectType: 'balance', subjectId: 'account',
|
|
448
|
+
fields: { is_available: false, balance_infos: [{ currency: 'CNY', total_balance: '0.00', granted_balance: '0.00', topped_up_balance: '0.00' }] },
|
|
449
|
+
occurredAt: '2026-08-31T00:00:00.000Z', actor: { kind: 'agent' },
|
|
450
|
+
}, drained);
|
|
451
|
+
const res = await handleDeepSeekTwinRequest({ method: 'POST', path: '/chat/completions', body: JSON.stringify(CHAT), root: drained });
|
|
452
|
+
if (res.status !== 402) fail('balance.402', `a drained account answered ${res.status}, not DeepSeek's 402 "You have run out of balance"`);
|
|
453
|
+
else if (!String(((res.body as Body).error as Body)?.message ?? '').includes('run out of balance')) fail('balance.402', '402 message is not the documented cause');
|
|
454
|
+
} finally {
|
|
455
|
+
rmSync(drained, { recursive: true, force: true });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ── 9. The streaming chunk sequence, including DeepSeek's OPT-IN usage tail chunk. ──
|
|
459
|
+
checksRun++;
|
|
460
|
+
const events: SseEvent[] = [];
|
|
461
|
+
await handleDeepSeekTwinRequest({
|
|
462
|
+
method: 'POST', path: '/chat/completions', root,
|
|
463
|
+
body: JSON.stringify({ ...CHAT, stream: true, stream_options: { include_usage: true } }),
|
|
464
|
+
sseSink: (e) => events.push(e),
|
|
465
|
+
});
|
|
466
|
+
const hasRole = events.some((e) => !e.done && ((e.data!.choices as Body[])?.[0]?.delta as Body)?.role === 'assistant');
|
|
467
|
+
const hasReasoningDelta = events.some((e) => !e.done && typeof ((e.data!.choices as Body[])?.[0]?.delta as Body)?.reasoning_content === 'string');
|
|
468
|
+
const done = events.length > 0 && events[events.length - 1]!.done === true;
|
|
469
|
+
const firstObj = events[0]?.data?.object;
|
|
470
|
+
const tail = events[events.length - 2]?.data as Body | undefined;
|
|
471
|
+
if (!hasRole) fail('chat.stream', 'no role delta chunk');
|
|
472
|
+
if (!hasReasoningDelta) fail('chat.stream', 'no reasoning_content delta — a V4 turn thinks by default');
|
|
473
|
+
if (!done) fail('chat.stream', 'stream did not end with [DONE]');
|
|
474
|
+
if (firstObj !== 'chat.completion.chunk') fail('chat.stream', `first chunk object is ${String(firstObj)}`);
|
|
475
|
+
if (!tail || !Array.isArray(tail.choices) || tail.choices.length !== 0 || !isObj(tail.usage)) {
|
|
476
|
+
fail('chat.stream', "final chunk is not DeepSeek's empty-choices usage tail");
|
|
477
|
+
}
|
|
478
|
+
// …and the tail is OPT-IN: without stream_options.include_usage there must be no usage chunk.
|
|
479
|
+
checksRun++;
|
|
480
|
+
const noUsage: SseEvent[] = [];
|
|
481
|
+
await handleDeepSeekTwinRequest({
|
|
482
|
+
method: 'POST', path: '/chat/completions', root,
|
|
483
|
+
body: JSON.stringify({ ...CHAT, stream: true }), sseSink: (e) => noUsage.push(e),
|
|
484
|
+
});
|
|
485
|
+
if (noUsage.some((e) => !e.done && isObj((e.data as Body)?.usage))) {
|
|
486
|
+
fail('chat.stream', 'a usage chunk was emitted without stream_options.include_usage');
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ── 10. tool_calls envelope when tools are provided. ──
|
|
490
|
+
checksRun++;
|
|
491
|
+
const tool = await handleDeepSeekTwinRequest({
|
|
492
|
+
method: 'POST', path: '/chat/completions', root,
|
|
493
|
+
body: JSON.stringify({
|
|
494
|
+
model: 'deepseek-v4-flash',
|
|
495
|
+
messages: [{ role: 'user', content: 'weather?' }],
|
|
496
|
+
tools: [{ type: 'function', function: { name: 'get_weather', parameters: { type: 'object', properties: { city: { type: 'string' } } } } }],
|
|
497
|
+
}),
|
|
498
|
+
});
|
|
499
|
+
const choice = ((tool.body as Body).choices as Body[])?.[0];
|
|
500
|
+
if (choice?.finish_reason !== 'tool_calls') fail('chat.tool_calls', `finish_reason is ${String(choice?.finish_reason)}, not tool_calls`);
|
|
501
|
+
const calls = choice?.message?.tool_calls as Body[];
|
|
502
|
+
if (!Array.isArray(calls) || calls[0]?.type !== 'function' || calls[0]?.function?.name !== 'get_weather') fail('chat.tool_calls', 'no get_weather function tool_call');
|
|
503
|
+
// DeepSeek's assistant message has NO `refusal` field (OpenAI's does) — serving one would be
|
|
504
|
+
// the inverse false-green.
|
|
505
|
+
if (choice && 'refusal' in (choice.message as Body)) fail('chat.message_shape', "assistant message carries a `refusal` field deepseekChatResponseSchema does not define");
|
|
506
|
+
} finally {
|
|
507
|
+
rmSync(root, { recursive: true, force: true });
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return { ok: violations.length === 0, checksRun, probes: PROBES.length, violations };
|
|
512
|
+
}
|