@volter/twin-deepseek 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +198 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +28 -0
- package/dist/src/deepseek-budget.d.ts +51 -0
- package/dist/src/deepseek-budget.js +152 -0
- package/dist/src/deepseek-cache.d.ts +56 -0
- package/dist/src/deepseek-cache.js +151 -0
- package/dist/src/deepseek-capabilities.d.ts +4 -0
- package/dist/src/deepseek-capabilities.js +1520 -0
- package/dist/src/deepseek-conformance.d.ts +14 -0
- package/dist/src/deepseek-conformance.js +473 -0
- package/dist/src/deepseek-connector.d.ts +168 -0
- package/dist/src/deepseek-connector.js +386 -0
- package/dist/src/deepseek-models.d.ts +30 -0
- package/dist/src/deepseek-models.js +38 -0
- package/dist/src/deepseek-scenario.d.ts +55 -0
- package/dist/src/deepseek-scenario.js +170 -0
- package/dist/src/deepseek-server.d.ts +16 -0
- package/dist/src/deepseek-server.js +191 -0
- package/dist/src/deepseek-stub.d.ts +75 -0
- package/dist/src/deepseek-stub.js +191 -0
- package/dist/src/deepseek-twin.d.ts +77 -0
- package/dist/src/deepseek-twin.js +1103 -0
- package/dist/src/deepseek-types.d.ts +172 -0
- package/dist/src/deepseek-types.js +26 -0
- package/dist/src/index.d.ts +15 -0
- package/dist/src/index.js +93 -0
- package/package.json +68 -0
- package/src/cli.ts +27 -0
- package/src/deepseek-budget.ts +178 -0
- package/src/deepseek-cache.ts +159 -0
- package/src/deepseek-capabilities.ts +1443 -0
- package/src/deepseek-conformance.ts +512 -0
- package/src/deepseek-connector.ts +440 -0
- package/src/deepseek-models.ts +65 -0
- package/src/deepseek-scenario.ts +188 -0
- package/src/deepseek-server.ts +201 -0
- package/src/deepseek-stub.ts +200 -0
- package/src/deepseek-twin.ts +1163 -0
- package/src/deepseek-types.ts +201 -0
- package/src/index.ts +133 -0
|
@@ -0,0 +1,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
|
+
}
|