@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,440 @@
1
+ // DeepSeek CONNECTOR — the live-vendor pull/push path that gives the DeepSeek twin the full
2
+ // "git for SaaS" lifecycle (pull real state → mirror; push local writes → real).
3
+ //
4
+ // PULL (real → twin): fetch the real Models / Files / user balance, map them to SyncResource[], and
5
+ // fold into the event log via syncPull (shadow-diff dedup, so re-pulling
6
+ // identical state appends nothing).
7
+ // PUSH (twin → real): for every PENDING local action (create / delete), call the real DeepSeek
8
+ // REST API and confirmAction on success.
9
+ //
10
+ // The vendor I/O is an INJECTED executor (B3 auth boundary): the kernel + this pack hold NO DeepSeek
11
+ // key and import NO SDK at runtime. Tests pass a fake executor; live runs pass
12
+ // `liveDeepSeekExecute(apiKey)`. Same code path either way — fully exercisable offline.
13
+ import { assertBudgetGuardIntact, confirmAction, pendingActions, projectResources, syncPull } from '@volter/world-core';
14
+ import type { SyncResource, TwinAction } from '@volter/world-core';
15
+ import { DeepSeekBudget, DeepSeekBudgetError, deepseekCallWeight, type DeepSeekBudgetOptions } from './deepseek-budget.ts';
16
+
17
+ const SERVICE = 'deepseek';
18
+
19
+ /**
20
+ * The injected real-DeepSeek boundary. `request` issues ONE DeepSeek REST call:
21
+ * method — 'GET' | 'POST' | 'DELETE'
22
+ * path — e.g. '/files' or '/files/file-api-abc'. NOTE: DeepSeek's base_url carries NO `/v1`
23
+ * segment (api-docs.deepseek.com, "Your First API Call") — an executor built on the
24
+ * OpenAI habit of prefixing `/v1` would 404 every call.
25
+ * body — JSON body for POST (omitted otherwise)
26
+ * Returns the parsed JSON (an object, a `{ data }` list, or an `{ error }` envelope).
27
+ */
28
+ export type DeepSeekExecute = (
29
+ method: 'GET' | 'POST' | 'DELETE',
30
+ path: string,
31
+ body?: Record<string, unknown>,
32
+ ) => Promise<{ data?: any; error?: { message?: string; type?: string }; [k: string]: unknown }>;
33
+
34
+ /** Construction options for the live executor. `budget` cannot be null and cannot be loosened. */
35
+ export type LiveDeepSeekOptions = {
36
+ /** Injected `fetch`, so a test can COUNT the requests the guard did or did not let through. */
37
+ fetchImpl?: typeof fetch;
38
+ /** An existing budget to share across executors. Omit and one is constructed. Cannot be null. */
39
+ budget?: DeepSeekBudget;
40
+ /** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
41
+ budgetOptions?: DeepSeekBudgetOptions;
42
+ };
43
+
44
+ /**
45
+ * The ONLY real paths this connector may address. An executor that will happily issue any URL is
46
+ * how a careless script reaches an unpriced endpoint — and, on this vendor, how an OpenAI-shaped
47
+ * `/v1/...` path silently escapes both the budget rules and the vendor's own routing.
48
+ */
49
+ const MODELED_LIVE_PATHS: RegExp[] = [
50
+ /^\/models$/,
51
+ /^\/user\/balance$/,
52
+ /^\/files(\?|$)/,
53
+ /^\/files\/[^/]+$/,
54
+ ];
55
+
56
+ /**
57
+ * A live executor against the real DeepSeek REST API (the user's own API key). Sends the required
58
+ * `Authorization: Bearer` header. Never imported by the pack's own serve path — only constructed
59
+ * by a caller that opts into real I/O.
60
+ *
61
+ * THIS IS THE ONE PLACE this pack issues a live `api.deepseek.com` request, and therefore the one place
62
+ * the rate budget has to be enforced. EVERY call is guarded: the budget is charged BEFORE the
63
+ * request goes out (`checkBudget`, which THROWS `DeepSeekBudgetError` instead of returning when the
64
+ * ceiling or a cooldown says stop) and the response is fed back (`recordCall`) so a `retry-after` /
65
+ * 429 / `x-ratelimit-remaining-requests: 0` signal becomes a persisted cooldown that makes every
66
+ * later call fail fast WITHOUT touching DeepSeek. There is deliberately no OPTION to disable the guard,
67
+ * and no value a caller can pass for `budget` that yields an unguarded client. What that does NOT
68
+ * claim is immunity from a caller who WANTS one: a fresh `budgetOptions.path` per construction, or
69
+ * an injected clock, restores the allowance, because the same seam tests need cannot be denied to a
70
+ * determined caller in the same process. See `deepseek-budget.ts` and the kernel header for the limits
71
+ * of the guarantee.
72
+ */
73
+ export function liveDeepSeekExecute(
74
+ apiKey: string,
75
+ base = 'https://api.deepseek.com',
76
+ opts: LiveDeepSeekOptions = {},
77
+ ): DeepSeekExecute {
78
+ const doFetch = opts.fetchImpl ?? fetch;
79
+ // ONE expression decides which budget is used, so there is no second, weaker test that could
80
+ // disagree with the first. `null`/`undefined` (or omitting it) build the default; anything else
81
+ // must be an UNMODIFIED DeepSeekBudget — a duck-typed stand-in, a SUBCLASS overriding `checkBudget`,
82
+ // and a Proxy trapping it are ALL refused, because each is a one-liner that would otherwise hand
83
+ // back a client with no ceiling. The default ledger is keyed by a hash of THIS key: DeepSeek limits
84
+ // per organization, so a cwd-scoped ledger would hand the same key a fresh allowance per
85
+ // checkout/worktree/CI leg.
86
+ const budget = opts.budget !== undefined && opts.budget !== null
87
+ ? assertBudgetGuardIntact(opts.budget, DeepSeekBudget, 'liveDeepSeekExecute')
88
+ : new DeepSeekBudget({ token: apiKey, ...(opts.budgetOptions ?? {}) });
89
+ return async (method, path, body) => {
90
+ // A path this pack never modelled is refused BEFORE the budget is even charged: an executor
91
+ // that will happily issue any URL is how a careless script reaches an unpriced endpoint.
92
+ if (!MODELED_LIVE_PATHS.some((re) => re.test(path))) {
93
+ throw new Error(`deepseek: refusing to call an unmodeled path "${path}" — this connector only addresses ${MODELED_LIVE_PATHS.map((re) => re.source).join(', ')}`);
94
+ }
95
+ const headers: Record<string, string> = { authorization: `Bearer ${apiKey}` };
96
+ const init: { method: string; headers: Record<string, string>; body?: string } = { method, headers };
97
+ if (method === 'POST') {
98
+ headers['content-type'] = 'application/json';
99
+ init.body = JSON.stringify(body ?? {});
100
+ }
101
+ const weight = deepseekCallWeight(method, path);
102
+ // THROWS instead of calling. Nothing below this line runs when the budget refuses.
103
+ const reservation = budget.checkBudget(weight);
104
+ const res = await doFetch(`${base}${path}`, init);
105
+ const resHeaders: Record<string, string> = {};
106
+ res.headers.forEach((v: string, k: string) => { resHeaders[k.toLowerCase()] = v; });
107
+ const parsed = (await res.json()) as { data?: any; error?: { message?: string; type?: string } };
108
+ // Settles the reservation and, on a back-off signal, arms the cooldown.
109
+ // recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
110
+ // call that louder refusal wins; an answer DeepSeek ACCEPTED is kept, so a write that landed is
111
+ // never recorded as failed and performed again on retry.
112
+ try {
113
+ budget.recordCall(weight, resHeaders, { status: res.status, reservation });
114
+ } catch (error) {
115
+ if (!(error instanceof DeepSeekBudgetError) || !res.ok) throw error;
116
+ }
117
+ return parsed;
118
+ };
119
+ }
120
+
121
+ function listOf(res: { data?: unknown }): any[] {
122
+ return Array.isArray(res.data) ? res.data : [];
123
+ }
124
+ /**
125
+ * A REFUSED pull is NOT an empty account. DeepSeek answers failures with its documented
126
+ * `{ error: { message, type } }` envelope, so a status check alone is not enough — throw on the
127
+ * envelope rather than folding an empty list over real observed state.
128
+ */
129
+ function throwIfError(res: { error?: { message?: string; type?: string } }, ctx: string): void {
130
+ if (res.error) throw new Error(`deepseek ${ctx} failed: ${res.error.message ?? res.error.type ?? 'unknown error'}`);
131
+ }
132
+
133
+ // ── PULL ────────────────────────────────────────────────────────────────────
134
+
135
+ /**
136
+ * Map a real-DeepSeek Model object → a twin sync resource.
137
+ *
138
+ * THREE keys, because that is the whole object DeepSeek's `GET /models` returns
139
+ * (api-docs.deepseek.com/api/list-models). The exemplar this pack was cloned from also folded
140
+ * `created`, `active` and `context_window`; carrying those over would have written nulls into the
141
+ * projection for fields the vendor never sends and then served them back as if observed.
142
+ */
143
+ export function mapModel(m: Record<string, unknown>): SyncResource {
144
+ return {
145
+ type: 'model',
146
+ id: String(m.id),
147
+ fields: { object: 'model', owned_by: (m.owned_by as string) ?? null },
148
+ };
149
+ }
150
+
151
+ /** Map a real-DeepSeek File object → a twin sync resource. */
152
+ export function mapFile(f: Record<string, unknown>): SyncResource {
153
+ return {
154
+ type: 'file',
155
+ id: String(f.id),
156
+ fields: {
157
+ object: 'file',
158
+ bytes: (f.bytes as number) ?? 0,
159
+ created_at: (f.created_at as number) ?? null,
160
+ filename: (f.filename as string) ?? null,
161
+ purpose: (f.purpose as string) ?? null,
162
+ // Present only when the upload requested an expiry — folding `null` in would make a permanent
163
+ // file look like an expiring one whose `expires_at` the vendor forgot.
164
+ ...(f.expires_at !== undefined ? { expires_at: f.expires_at as number } : {}),
165
+ },
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Map `GET /user/balance` → the SINGLETON `balance/account` resource. Unlike models and files this
171
+ * endpoint answers ONE object, not a `{ data: [] }` list, so it gets its own pull step. It is real
172
+ * observable remote state and it is load-bearing: the twin's 402 "You have run out of balance" path
173
+ * reads `is_available` off this projection, so pulling a drained account makes the twin refuse
174
+ * completions exactly the way the real one would.
175
+ */
176
+ export function mapBalance(b: Record<string, unknown>): SyncResource {
177
+ return {
178
+ type: 'balance',
179
+ id: 'account',
180
+ fields: {
181
+ is_available: b.is_available === true,
182
+ balance_infos: Array.isArray(b.balance_infos) ? b.balance_infos : [],
183
+ },
184
+ };
185
+ }
186
+
187
+ /** List endpoints whose reply is `{ object: 'list', data: [...] }`. */
188
+ const COLLECTIONS: Array<{ path: string; map: (r: Record<string, unknown>) => SyncResource }> = [
189
+ { path: '/models', map: mapModel },
190
+ { path: '/files', map: mapFile },
191
+ ];
192
+ /** Singleton endpoints whose reply IS the object. */
193
+ const SINGLETONS: Array<{ path: string; map: (r: Record<string, unknown>) => SyncResource }> = [
194
+ { path: '/user/balance', map: mapBalance },
195
+ ];
196
+
197
+ /** Pull all modeled real collections via the executor and map them to twin sync resources. */
198
+ export async function pullDeepSeekState(execute: DeepSeekExecute): Promise<SyncResource[]> {
199
+ const out: SyncResource[] = [];
200
+ for (const c of COLLECTIONS) {
201
+ const res = await execute('GET', c.path);
202
+ throwIfError(res, `pull ${c.path}`);
203
+ for (const item of listOf(res)) out.push(c.map(item));
204
+ }
205
+ for (const sgl of SINGLETONS) {
206
+ const res = await execute('GET', sgl.path);
207
+ throwIfError(res, `pull ${sgl.path}`);
208
+ out.push(sgl.map(res as Record<string, unknown>));
209
+ }
210
+ return out;
211
+ }
212
+
213
+ /**
214
+ * Pull from real DeepSeek and fold into the twin (mirror seeding). syncPull's shadow-diff makes a
215
+ * re-pull of identical state a no-op.
216
+ *
217
+ * `occurredAt` has NO pinned default on purpose: the kernel hashes an observed event over
218
+ * (occurredAt + post-state), so under a fixed poll time a vendor value that REVERTS across polls
219
+ * collides with its own earlier observation and `syncPull` reports a phantom delta while the
220
+ * projection keeps the stale value (ADDING_A_TWIN.md §6). Callers pass a moving timestamp.
221
+ */
222
+ export async function syncDeepSeekFromReal(
223
+ execute: DeepSeekExecute,
224
+ opts: { root?: string; occurredAt: string },
225
+ ): Promise<{ observed: number; deltasAppended: number }> {
226
+ const resources = await pullDeepSeekState(execute);
227
+ const result = syncPull({ service: SERVICE, resources, occurredAt: opts.occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
228
+ return { observed: result.observed, deltasAppended: result.deltasAppended };
229
+ }
230
+
231
+ // ── PUSH ────────────────────────────────────────────────────────────────────
232
+
233
+ // The twin operations this connector knows how to push to real DeepSeek. Anything not here must FAIL
234
+ // LOUDLY rather than be silently dropped — pushing an unrecognized op risks hitting the wrong
235
+ // endpoint or no-op'ing a real change.
236
+ const PUSHABLE_VERBS = new Set(['create', 'delete']);
237
+
238
+ /**
239
+ * Subject types that are the TWIN'S OWN bookkeeping and have no vendor counterpart at all.
240
+ *
241
+ * `cache_prefix` is the context-cache ledger (deepseek-cache.ts). DeepSeek's cache is server-side
242
+ * and exposed only as two numbers on `usage` — there is no endpoint that creates, reads or deletes
243
+ * a cache prefix, so these actions are not a push GAP, they are not vendor writes. They are skipped
244
+ * by NAME and reported in `skippedInternal`, never silently dropped and never counted as refusals:
245
+ * every chat completion mints one, so folding them into `refused` would bury the real gaps.
246
+ */
247
+ const INTERNAL_SUBJECT_TYPES = new Set(['cache_prefix']);
248
+
249
+ /** Throw if `op` is not a write operation this connector can faithfully push. */
250
+ function assertPushable(op: string): void {
251
+ const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
252
+ if (!PUSHABLE_VERBS.has(verb)) {
253
+ throw new Error(`deepseek push: unsupported operation '${op}' — refusing to silently drop a local write`);
254
+ }
255
+ const why = UNPUSHABLE[op];
256
+ if (why) throw new Error(`deepseek push: cannot push '${op}' — ${why}`);
257
+ }
258
+
259
+ /** Why this action cannot be pushed, or null if it can. Pure — no vendor call on its path. */
260
+ export function unpushableReason(op: string): string | null {
261
+ const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
262
+ if (!PUSHABLE_VERBS.has(verb)) return `unsupported operation '${op}'`;
263
+ return UNPUSHABLE[op] ?? null;
264
+ }
265
+
266
+ // Map a subject type → its REST collection path.
267
+ const COLLECTION_PATH: Record<string, string> = {
268
+ file: '/files',
269
+ };
270
+
271
+ /**
272
+ * Pushes this connector must REFUSE rather than fake, keyed `"<type>.<verb>"`.
273
+ *
274
+ * `file.create` is the case: real DeepSeek's `POST /files` is multipart/form-data carrying the
275
+ * actual image bytes (`@ai-sdk/deepseek` builds a `FormData` with the blob and `purpose=user_data`,
276
+ * src/files/deepseek-files.ts), while this executor sends JSON. Pushing `{purpose, filename}` as
277
+ * JSON would be refused at the vendor, and the twin stores no real image bytes to upload anyway
278
+ * (the twin stores no uploaded bytes yet — `deepseek.files.binary_content`). Refusing loudly is the honest answer; the gap is
279
+ * filed as `deepseek.connector.push_file_create`.
280
+ */
281
+ const UNPUSHABLE: Record<string, string> = {
282
+ 'file.create': "real DeepSeek's POST /files is multipart/form-data carrying the image bytes; this JSON executor cannot express it, and the twin stores no real bytes to send",
283
+ };
284
+
285
+ /**
286
+ * The twin's LOCALLY-MINTED id namespace (`file-api-twin000000000001` — see `nextFileId` in
287
+ * deepseek-twin.ts). A subject still bearing one has no counterpart in the real account. The
288
+ * pattern is anchored on the `twin` infix a real DeepSeek id (`file-api-<16 chars>`) cannot
289
+ * produce for a local mint, so a PULLED vendor id is never mistaken for one.
290
+ */
291
+ const LOCAL_ID = /^file-api-twin\d{12}$/;
292
+
293
+ /**
294
+ * Resolve the id the VENDOR knows this subject by.
295
+ *
296
+ * A locally-minted subject has no counterpart in the real account, so the vendor path can never be
297
+ * built from `action.subject.id`. A create's real id is written into the resource as
298
+ * `_external_id` at confirm time, and every later delete addresses the vendor by THAT — otherwise
299
+ * a local `file.delete` would issue `DELETE /files/file-api-twin000000000001` against the REAL
300
+ * account for a file this connector explicitly refused to create there (the exact class of defect
301
+ * the exemplar's own §9 round two caught: a fix that made the rest of the push sweep reachable also
302
+ * made the twin's own minted ids reachable as live vendor paths).
303
+ *
304
+ * Now a create's real id is written into the resource as `_external_id` at confirm time, and every
305
+ * later delete/cancel addresses the vendor by THAT. A locally-minted subject with no recorded
306
+ * external id is refused rather than guessed at.
307
+ */
308
+ export function externalIdFor(subjectType: string, subjectId: string, root?: string): string | null {
309
+ if (!LOCAL_ID.test(subjectId)) return subjectId; // already a vendor id (e.g. observed by a pull)
310
+ const row = projectResources(SERVICE, root).find((r) => r.type === subjectType && r.id === subjectId);
311
+ const ext = row?._external_id;
312
+ return typeof ext === 'string' && ext ? ext : null;
313
+ }
314
+
315
+ /**
316
+ * Resolve the REST (method, path) for ONE pending action — faithful to the real DeepSeek REST surface:
317
+ * - <type>.create → POST <collection>
318
+ * - <type>.cancel → POST <collection>/:id/cancel
319
+ * - <type>.delete → DELETE <collection>/:id
320
+ */
321
+ export function deepseekRequestForAction(
322
+ action: Pick<TwinAction, 'operation' | 'subject'>,
323
+ /** The id the VENDOR knows this subject by. Required for anything but a create — see
324
+ * `externalIdFor`; passing the twin's own mint would address a resource the vendor never had. */
325
+ externalId?: string,
326
+ ): { method: 'GET' | 'POST' | 'DELETE'; path: string } {
327
+ const op = action.operation ?? `${action.subject.type}.update`;
328
+ const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
329
+ const collection = COLLECTION_PATH[action.subject.type];
330
+ if (!collection) throw new Error(`deepseek push: no REST collection for subject type '${action.subject.type}'`);
331
+ if (verb === 'create') return { method: 'POST', path: collection };
332
+ const vendorId = externalId ?? action.subject.id;
333
+ if (LOCAL_ID.test(vendorId)) {
334
+ throw new Error(`deepseek push: refusing to address the real account by the twin's own id '${vendorId}' — no vendor id was ever recorded for this subject`);
335
+ }
336
+ if (verb === 'delete') return { method: 'DELETE', path: `${collection}/${vendorId}` };
337
+ // assertPushable rejects anything else, so this is only reached for the pushable verbs above.
338
+ return { method: 'POST', path: collection };
339
+ }
340
+
341
+ /**
342
+ * Push ONE pending action to REAL DeepSeek via the injected executor. Returns the real external id
343
+ * (the object id from the response; for a create that's a freshly minted id, otherwise it echoes
344
+ * the subject). WRITES TO THE REAL ACCOUNT.
345
+ */
346
+ export async function pushDeepSeekAction(
347
+ execute: DeepSeekExecute,
348
+ action: Pick<TwinAction, 'operation' | 'subject' | 'fields'>,
349
+ opts: { externalId?: string } = {},
350
+ ): Promise<{ externalId: string }> {
351
+ assertPushable(action.operation ?? `${action.subject.type}.update`);
352
+ const { method, path } = deepseekRequestForAction(action, opts.externalId);
353
+ const verb = (action.operation ?? '').includes('.') ? action.operation!.slice(action.operation!.indexOf('.') + 1) : '';
354
+ const payload = verb === 'create' ? createPayload(action) : undefined;
355
+ const res = await execute(method, path, payload);
356
+ throwIfError(res, `push ${action.subject.type}`);
357
+ const id = (res as { id?: unknown }).id;
358
+ return { externalId: typeof id === 'string' && id ? id : action.subject.id };
359
+ }
360
+
361
+ /**
362
+ * The JSON body a create push sends. Every create this pack can record is a `file.create`, and that
363
+ * one is in UNPUSHABLE — so this function is only ever reached if a new pushable create type is
364
+ * added later, and it deliberately returns an EMPTY body rather than inventing one for a shape it
365
+ * has never seen.
366
+ */
367
+ function createPayload(_action: Pick<TwinAction, 'subject' | 'fields'>): Record<string, unknown> {
368
+ return {};
369
+ }
370
+
371
+ /**
372
+ * Push the twin's PENDING local actions to real DeepSeek and CONFIRM each. Idempotency: a confirmed
373
+ * action is no longer pending, so a re-push enacts NOTHING.
374
+ *
375
+ * Every action this pack records is SINGLE-RESOURCE (one file, one cache prefix), so confirming with
376
+ * `fields` alone is correct — see ADDING_A_TWIN.md §5 on compound actions, which this pack has
377
+ * none of. If a compound write is ever added here, it must ride its extra resources in
378
+ * `additionalObservations` or the push will silently delete them.
379
+ */
380
+ export async function pushPendingDeepSeekActions(
381
+ execute: DeepSeekExecute,
382
+ opts: { root?: string; occurredAt: string },
383
+ ): Promise<{ pushed: number; confirmed: string[]; externalIds: Record<string, string>; refused: Array<{ actionId: string; operation: string; reason: string }>; skippedInternal: string[] }> {
384
+ const confirmed: string[] = [];
385
+ const externalIds: Record<string, string> = {};
386
+ const refused: Array<{ actionId: string; operation: string; reason: string }> = [];
387
+ const skippedInternal: string[] = [];
388
+ for (const action of pendingActions(SERVICE, opts.root)) {
389
+ const op = action.operation ?? `${action.subject.type}.update`;
390
+ // Twin-internal bookkeeping (the context-cache ledger) is not a vendor write and is skipped by
391
+ // NAME — reported, never silently dropped, and never mixed into `refused` where the real gaps live.
392
+ if (INTERNAL_SUBJECT_TYPES.has(action.subject.type)) { skippedInternal.push(action.id); continue; }
393
+ // An action this connector cannot faithfully push is SKIPPED AND REPORTED, never confirmed
394
+ // and never silently dropped: it stays pending, and it is named in `refused` so a caller can
395
+ // see it. Throwing instead (the first cut) meant ONE unpushable action — e.g. the local file
396
+ // create, whose real endpoint is multipart — aborted the whole sweep and took every unrelated
397
+ // pending write down with it (§9 round one, finding 5).
398
+ const why = unpushableReason(op);
399
+ if (why) { refused.push({ actionId: action.id, operation: op, reason: why }); continue; }
400
+ const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
401
+ // Anything but a create must address the vendor by the id the vendor issued. A locally-minted
402
+ // subject with none recorded is REFUSED, never guessed (§9 round two, blocker).
403
+ let external: string | undefined;
404
+ if (verb !== 'create') {
405
+ const resolved = externalIdFor(action.subject.type, action.subject.id, opts.root);
406
+ if (resolved === null) {
407
+ refused.push({ actionId: action.id, operation: op, reason: `no vendor id recorded for ${action.subject.type} '${action.subject.id}' — it was never pushed to the real account, so there is nothing there to ${verb}` });
408
+ continue;
409
+ }
410
+ external = resolved;
411
+ }
412
+ const { externalId } = await pushDeepSeekAction(execute, action, ...(external !== undefined ? [{ externalId: external }] as const : [] as const));
413
+ // Record the id the vendor issued ON the resource, so a later delete/cancel can address it.
414
+ confirmAction({
415
+ service: SERVICE, actionId: action.id, subject: action.subject,
416
+ fields: { ...(action.fields ?? {}), ...(verb === 'create' ? { _external_id: externalId } : {}) },
417
+ occurredAt: opts.occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}),
418
+ });
419
+ confirmed.push(action.id);
420
+ externalIds[action.id] = externalId;
421
+ }
422
+ return { pushed: confirmed.length, confirmed, externalIds, refused, skippedInternal };
423
+ }
424
+
425
+ // ── FULL bi-directional sync ─────────────────────────────────────────────────
426
+ /**
427
+ * FULL bi-directional sync over the injected client: (1) PUSH every pending local action to real
428
+ * DeepSeek and confirm it, then (2) PULL all modeled collections back and fold them into the event
429
+ * log. Pushing first means the pull observes the twin's own writes as confirmed external state (no
430
+ * double-count). Re-running with no pending writes and identical real state is a no-op.
431
+ */
432
+ export async function fullSyncDeepSeek(
433
+ execute: DeepSeekExecute,
434
+ opts: { root?: string; occurredAt: string },
435
+ ): Promise<{ pushed: number; observed: number; deltasAppended: number; collections: number; refused: Array<{ actionId: string; operation: string; reason: string }>; skippedInternal: string[] }> {
436
+ const push = await pushPendingDeepSeekActions(execute, { occurredAt: opts.occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
437
+ const resources = await pullDeepSeekState(execute);
438
+ const pull = syncPull({ service: SERVICE, resources, occurredAt: opts.occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
439
+ return { pushed: push.pushed, observed: pull.observed, deltasAppended: pull.deltasAppended, collections: COLLECTIONS.length + SINGLETONS.length, refused: push.refused, skippedInternal: push.skippedInternal };
440
+ }
@@ -0,0 +1,65 @@
1
+ // DeepSeek model catalog — the static `GET /models` surface.
2
+ //
3
+ // PROVENANCE (what is sourced, and what is deliberately ABSENT):
4
+ // • the three ids are the ones DeepSeek's own docs list as the current models
5
+ // (api-docs.deepseek.com "Your First API Call" + /quick_start/pricing, read 2026-08-31) and are
6
+ // the exact strings `@ai-sdk/deepseek@3.0.37` gives first-class suggestions for
7
+ // (`DeepSeekChatModelId`, src/chat/deepseek-chat-language-model-options.ts);
8
+ // • a row is `{id, object, owned_by}` and NOTHING else — that is the whole object in DeepSeek's
9
+ // own `GET /models` example response (api-docs.deepseek.com/api/list-models). OpenAI's row also
10
+ // carries `created`; DeepSeek's does not, so this twin does not invent one. `DeepSeekModel` in
11
+ // deepseek-types.ts has exactly those three keys, so an added field would not typecheck.
12
+ // • `deepseek-chat` and `deepseek-reasoner` are NOT here. `@ai-sdk/deepseek`'s own docs page
13
+ // (docs/30-deepseek.mdx, shipped inside the npm tarball) states: "DeepSeek retired the
14
+ // `deepseek-chat` and `deepseek-reasoner` aliases on July 24, 2026." A catalog that still
15
+ // served them would be surface the vendor no longer has.
16
+ //
17
+ // This is a STATIC surface (the registry does not change at runtime), so it is a plain data table
18
+ // rather than kernel state — listing models is a pure read. A connector pull can still OBSERVE an
19
+ // account's rows and those override/extend this table (deepseek-twin.ts `servedModels`).
20
+ import type { DeepSeekModel } from './deepseek-types.ts';
21
+
22
+ const model = (id: string): DeepSeekModel => ({ id, object: 'model', owned_by: 'deepseek' });
23
+
24
+ /** The catalog DeepSeek publishes. */
25
+ export const DEEPSEEK_MODELS: DeepSeekModel[] = [
26
+ model('deepseek-v4-flash'),
27
+ model('deepseek-v4-pro'),
28
+ model('deepseek-v4-flash-vision-exp'),
29
+ ];
30
+
31
+ /** Resolve a model object by id, or undefined if DeepSeek does not publish it. */
32
+ export function findModel(id: string): DeepSeekModel | undefined {
33
+ return DEEPSEEK_MODELS.find((m) => m.id === id);
34
+ }
35
+
36
+ /**
37
+ * The ids DeepSeek retired on 2026-07-24 (`@ai-sdk/deepseek` docs/30-deepseek.mdx). They are kept
38
+ * here ONLY so the twin can refuse them by name with an accurate message — never to serve them.
39
+ */
40
+ export const RETIRED_MODEL_IDS = new Set(['deepseek-chat', 'deepseek-reasoner']);
41
+
42
+ /**
43
+ * The one model DeepSeek's beta FIM endpoint accepts. "Only value: `deepseek-v4-pro`"
44
+ * (api-docs.deepseek.com/api/create-completion, read 2026-08-31) — a closed set of one, so any
45
+ * other id is a checkable rejection rather than a stub.
46
+ */
47
+ export const FIM_MODELS = new Set(['deepseek-v4-pro']);
48
+
49
+ /**
50
+ * The models that accept image inputs. DeepSeek's own model-capability table marks image input on
51
+ * `deepseek-v4-flash-vision-exp` ONLY (`@ai-sdk/deepseek` docs/30-deepseek.mdx "Model
52
+ * Capabilities"), and the Files API guide says uploaded files "work with
53
+ * `deepseek-v4-flash-vision-exp`".
54
+ */
55
+ export const VISION_MODELS = new Set(['deepseek-v4-flash-vision-exp']);
56
+
57
+ /**
58
+ * Every model in the catalog is a DeepSeek V4 model, and `@ai-sdk/deepseek` keys its thinking
59
+ * behaviour on the id containing `deepseek-v4` (src/chat/deepseek-chat-language-model.ts:
60
+ * `this.modelId.includes('deepseek-v4')`). The twin uses the SAME predicate so its thinking rules
61
+ * and the SDK's request shaping can never disagree about which turn is a thinking turn.
62
+ */
63
+ export function isThinkingModel(id: string): boolean {
64
+ return id.includes('deepseek-v4');
65
+ }