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