@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.
Files changed (42) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +198 -0
  3. package/dist/src/cli.d.ts +2 -0
  4. package/dist/src/cli.js +28 -0
  5. package/dist/src/deepseek-budget.d.ts +51 -0
  6. package/dist/src/deepseek-budget.js +152 -0
  7. package/dist/src/deepseek-cache.d.ts +56 -0
  8. package/dist/src/deepseek-cache.js +151 -0
  9. package/dist/src/deepseek-capabilities.d.ts +4 -0
  10. package/dist/src/deepseek-capabilities.js +1520 -0
  11. package/dist/src/deepseek-conformance.d.ts +14 -0
  12. package/dist/src/deepseek-conformance.js +473 -0
  13. package/dist/src/deepseek-connector.d.ts +168 -0
  14. package/dist/src/deepseek-connector.js +386 -0
  15. package/dist/src/deepseek-models.d.ts +30 -0
  16. package/dist/src/deepseek-models.js +38 -0
  17. package/dist/src/deepseek-scenario.d.ts +55 -0
  18. package/dist/src/deepseek-scenario.js +170 -0
  19. package/dist/src/deepseek-server.d.ts +16 -0
  20. package/dist/src/deepseek-server.js +191 -0
  21. package/dist/src/deepseek-stub.d.ts +75 -0
  22. package/dist/src/deepseek-stub.js +191 -0
  23. package/dist/src/deepseek-twin.d.ts +77 -0
  24. package/dist/src/deepseek-twin.js +1103 -0
  25. package/dist/src/deepseek-types.d.ts +172 -0
  26. package/dist/src/deepseek-types.js +26 -0
  27. package/dist/src/index.d.ts +15 -0
  28. package/dist/src/index.js +93 -0
  29. package/package.json +68 -0
  30. package/src/cli.ts +27 -0
  31. package/src/deepseek-budget.ts +178 -0
  32. package/src/deepseek-cache.ts +159 -0
  33. package/src/deepseek-capabilities.ts +1443 -0
  34. package/src/deepseek-conformance.ts +512 -0
  35. package/src/deepseek-connector.ts +440 -0
  36. package/src/deepseek-models.ts +65 -0
  37. package/src/deepseek-scenario.ts +188 -0
  38. package/src/deepseek-server.ts +201 -0
  39. package/src/deepseek-stub.ts +200 -0
  40. package/src/deepseek-twin.ts +1163 -0
  41. package/src/deepseek-types.ts +201 -0
  42. 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
+ }