dsh-strata-sync 0.0.0-stage → 0.9.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/dist/core.mjs ADDED
@@ -0,0 +1,285 @@
1
+ /** Read-only Strata discovery and client-side token budgeting. No inference APIs. */
2
+ import { createHash } from 'node:crypto';
3
+ export class StrataSyncError extends Error {
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.name = 'StrataSyncError';
7
+ this.code = code;
8
+ }
9
+ }
10
+
11
+ const fail = (code, message) => { throw new StrataSyncError(code, message); };
12
+ const MAX_RESPONSE_BYTES = 256 * 1024;
13
+ const MAX_CONTEXT = 16 * 1024 * 1024;
14
+ const isContext = value => Number.isSafeInteger(value) && value > 0 && value <= MAX_CONTEXT;
15
+ const safeLabel = value => typeof value === 'string' && value.length > 0 && value.length <= 256 && !/[\u0000-\u001f\u007f]/u.test(value);
16
+
17
+ // Reviewed template bytes, not a model-name heuristic. See docs/CAPABILITIES.md.
18
+ const TEMPLATE_PROFILES = {
19
+ '12827f24b742ea4e80cdc12dbcf9622227056b9f797252a3149263d4f9aaadce': {
20
+ family: 'Qwen3.8-Flash-Next', efforts: { off: 'none', low: 'low', medium: 'medium', xhigh: 'xhigh' },
21
+ },
22
+ '6f9bd1f87d9dad376b4c79683934b4254396d826ae514ab0244efa904ce1d1a4': {
23
+ family: 'DeepSeek-V4.1-Flash', efforts: { off: 'none', low: 'low', high: 'high', max: 'max' },
24
+ },
25
+ };
26
+
27
+ export function planModelCapabilities(modalities, evidence = {}) {
28
+ const validInput = Array.isArray(modalities) && modalities.length > 0 && modalities.length <= 2
29
+ && modalities.includes('text') && new Set(modalities).size === modalities.length
30
+ && modalities.every(value => value === 'text' || value === 'image');
31
+ const input = validInput ? ['text', ...(modalities.includes('image') ? ['image'] : [])] : null;
32
+ const profile = evidence.verifiedStrata && evidence.modelMatched ? TEMPLATE_PROFILES[evidence.templateHash] : null;
33
+ return {
34
+ input, inputSource: input ? 'models.architecture.input_modalities' : null,
35
+ reasoning: profile ? { family: profile.family, templateHash: evidence.templateHash,
36
+ efforts: { ...profile.efforts },
37
+ // Native pi-ai dispatch: enable thinking AND select effort. Off sends false,
38
+ // never just omits a field and accidentally inherits server thinking defaults.
39
+ compat: { thinkingFormat: 'chat-template', supportsReasoningEffort: false,
40
+ chatTemplateKwargs: { thinking: { $var: 'thinking.enabled' }, enable_thinking: { $var: 'thinking.enabled' },
41
+ reasoning_effort: { $var: 'thinking.effort', omitWhenOff: true } } },
42
+ } : null,
43
+ warnings: [...(!input ? ['INPUT_UNCONFIRMED'] : []), ...(!profile ? ['REASONING_UNCONFIRMED'] : [])],
44
+ };
45
+ }
46
+
47
+ /** Accept either the server root or an OpenAI-compatible /v1 base URL. */
48
+ export function normalizeBaseURL(value) {
49
+ let url;
50
+ try { url = new URL(value); } catch { fail('INVALID_URL', 'Enter a valid HTTP or HTTPS Strata server URL.'); }
51
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
52
+ fail('INVALID_URL', 'The server URL must use HTTP or HTTPS without embedded credentials, a query, or a fragment.');
53
+ }
54
+ let pathname = url.pathname.replace(/\/+$/u, '');
55
+ if (!pathname.endsWith('/v1')) pathname += '/v1';
56
+ url.pathname = pathname;
57
+ return url.href.replace(/\/$/u, '');
58
+ }
59
+
60
+ /**
61
+ * Select the output allowance, then derive DSH's client-side compaction policy.
62
+ * The 4096 headroom also matches the native pi-ai context safety margin.
63
+ */
64
+ export function planBudget(contextWindow, { outputTokens, serverOutputTokens } = {}) {
65
+ if (!isContext(contextWindow) || contextWindow < 8192) {
66
+ fail('UNSUPPORTED_CONTEXT', 'A verified context window of at least 8,192 tokens is required.');
67
+ }
68
+ const output = outputTokens ?? serverOutputTokens ?? Math.min(16384, Math.floor(contextWindow * 0.3));
69
+ const outputSource = outputTokens != null ? 'manual' : serverOutputTokens != null ? 'server' : 'calculated';
70
+ if (!Number.isSafeInteger(output) || output < 1 || output >= contextWindow) {
71
+ fail('INVALID_OUTPUT_BUDGET', 'The output budget must be a positive integer smaller than the context window.');
72
+ }
73
+ const headroomTokens = 4096;
74
+ const thresholdRatio = 0.8;
75
+ const retainRatio = 0.16;
76
+ const messageBudgetTokens = contextWindow - output;
77
+ const thresholdTokens = Math.floor(Math.min(contextWindow * thresholdRatio, messageBudgetTokens - headroomTokens));
78
+ const retainTokens = Math.floor(messageBudgetTokens * retainRatio);
79
+ if (thresholdTokens <= 0 || retainTokens >= thresholdTokens) {
80
+ fail('INVALID_OUTPUT_BUDGET', 'This output budget leaves too little room for automatic compaction; lower it.');
81
+ }
82
+ return Object.freeze({
83
+ source: 'client-derived', contextWindow, outputTokens: output, outputSource,
84
+ headroomTokens, summaryMaxTokens: headroomTokens, thresholdRatio, retainRatio,
85
+ messageBudgetTokens, thresholdTokens,
86
+ thresholdPercent: thresholdTokens / contextWindow * 100,
87
+ retainTokens, clientSafetyTokens: 4096,
88
+ });
89
+ }
90
+
91
+ async function readJSON(url, apiKey, { fetch: fetchImpl, timeout, signal }) {
92
+ if (signal?.aborted) fail('ABORTED', 'Strata discovery was cancelled.');
93
+ const controller = new AbortController();
94
+ const timer = setTimeout(() => controller.abort(), timeout);
95
+ timer.unref?.();
96
+ try {
97
+ const response = await fetchImpl(url, {
98
+ method: 'GET', redirect: 'error', signal: signal ? AbortSignal.any([controller.signal, signal]) : controller.signal,
99
+ headers: { Accept: 'application/json', ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}) },
100
+ });
101
+ if (response.redirected || (response.status >= 300 && response.status < 400)) {
102
+ fail('REDIRECT_REJECTED', 'Strata discovery does not follow redirects. Use the final server URL.');
103
+ }
104
+ if (!response.ok) fail('HTTP_ERROR', `Strata discovery returned HTTP ${response.status}.`);
105
+ const declaredLength = response.headers.get('content-length');
106
+ if (declaredLength && Number(declaredLength) > MAX_RESPONSE_BYTES) {
107
+ fail('RESPONSE_TOO_LARGE', 'Strata discovery returned an oversized response.');
108
+ }
109
+ if (!response.body?.getReader) fail('INVALID_RESPONSE', 'Strata discovery returned an unreadable response.');
110
+ const reader = response.body.getReader();
111
+ const chunks = [];
112
+ let total = 0;
113
+ try {
114
+ while (true) {
115
+ const { value, done } = await reader.read();
116
+ if (done) break;
117
+ total += value.byteLength;
118
+ if (total > MAX_RESPONSE_BYTES) {
119
+ await reader.cancel();
120
+ fail('RESPONSE_TOO_LARGE', 'Strata discovery returned an oversized response.');
121
+ }
122
+ chunks.push(value);
123
+ }
124
+ } finally { reader.releaseLock(); }
125
+ const bytes = new Uint8Array(total);
126
+ let offset = 0;
127
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
128
+ try { return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); }
129
+ catch { fail('INVALID_JSON', 'Strata discovery did not return valid JSON.'); }
130
+ } catch (error) {
131
+ if (error instanceof StrataSyncError) throw error;
132
+ if (signal?.aborted) fail('ABORTED', 'Strata discovery was cancelled.');
133
+ if (controller.signal.aborted) fail('TIMEOUT', 'Strata discovery timed out.');
134
+ // Never propagate fetch errors, server bodies, request headers, or credentials.
135
+ fail('CONNECTION_FAILED', 'Unable to read Strata metadata. Check the URL, connection, and credentials.');
136
+ } finally { clearTimeout(timer); controller.abort(); }
137
+ }
138
+
139
+ function modelRecord(row) {
140
+ if (!row || typeof row !== 'object' || !safeLabel(row.id)) return null;
141
+ const aliasOf = row.alias_of;
142
+ if (aliasOf !== undefined && (!safeLabel(aliasOf) || aliasOf === row.id)) {
143
+ fail('INVALID_MODELS', 'Strata returned an invalid model alias.');
144
+ }
145
+ const outputModalities = row.architecture?.output_modalities;
146
+ if (outputModalities !== undefined && (!Array.isArray(outputModalities) || !outputModalities.length
147
+ || !outputModalities.every(safeLabel) || new Set(outputModalities).size !== outputModalities.length)) {
148
+ fail('INVALID_MODELS', 'Strata returned invalid model output modalities.');
149
+ }
150
+ return {
151
+ id: row.id,
152
+ name: safeLabel(row.name) ? row.name : row.id,
153
+ status: (row.status === 'loaded' || row.status?.value === 'loaded') ? 'loaded' : 'not-loaded',
154
+ contextWindow: isContext(row.meta?.n_ctx) ? row.meta.n_ctx : null,
155
+ modalities: row.architecture?.input_modalities,
156
+ aliasOf,
157
+ outputModalities,
158
+ };
159
+ }
160
+
161
+ function loadedTextModels(models) {
162
+ const byId = new Map(models.map(model => [model.id, model]));
163
+ const identities = new Map();
164
+ const roots = new Map();
165
+ for (const model of models.filter(row => row.status === 'loaded')) {
166
+ let root = model;
167
+ const visited = new Set();
168
+ while (root.aliasOf !== undefined) {
169
+ if (visited.has(root.id)) fail('INVALID_MODELS', 'Strata returned a cyclic model alias.');
170
+ visited.add(root.id);
171
+ root = byId.get(root.aliasOf);
172
+ if (!root || root.status !== 'loaded') {
173
+ fail('INVALID_MODELS', 'Strata returned a loaded alias without a loaded canonical model.');
174
+ }
175
+ }
176
+ // Older Strata builds omit output_modalities. Explicit image/audio outputs
177
+ // are separate generators, not additional loaded chat models.
178
+ if (root.outputModalities !== undefined && !root.outputModalities.includes('text')) continue;
179
+ if (model.outputModalities !== undefined && !model.outputModalities.includes('text')) {
180
+ fail('INVALID_MODELS', 'Strata returned conflicting model alias output modalities.');
181
+ }
182
+ roots.set(root.id, root);
183
+ identities.set(model.id, root.id);
184
+ }
185
+ return { loaded: [...roots.values()], identities };
186
+ }
187
+
188
+ function slotContext(payload, modelIds, loadedCount) {
189
+ const rows = Array.isArray(payload) ? payload : Array.isArray(payload?.slots) ? payload.slots : null;
190
+ if (!rows || rows.length > 1024) return null;
191
+ const associated = rows.filter(row => row && [row.model, row.model_name, row.model_id].some(id => modelIds.has(id)));
192
+ // Unlabelled slots are only safe when the server exposes exactly one loaded model.
193
+ const candidates = associated.length ? associated : loadedCount === 1
194
+ ? rows.filter(row => row && !row.model && !row.model_name && !row.model_id) : [];
195
+ const values = candidates.map(row => row.n_ctx).filter(isContext);
196
+ if (new Set(values).size > 1) {
197
+ fail('AMBIGUOUS_CONTEXT', 'Strata slots report different context sizes for this model; automatic alignment is unavailable.');
198
+ }
199
+ if (values.length !== candidates.length || !values.length) return null;
200
+ return values[0];
201
+ }
202
+
203
+ /**
204
+ * Fetch models and optional slots, selecting explicitly loaded text models.
205
+ * Aliases resolve to the canonical model; multiple canonical text models require
206
+ * options.modelId. Credentials are never returned.
207
+ */
208
+ export async function discoverStrata(baseURL, apiKey = '', options = {}) {
209
+ const normalized = normalizeBaseURL(baseURL);
210
+ if (typeof apiKey !== 'string' || apiKey.length > 8192 || /[\r\n\u0000]/u.test(apiKey)) {
211
+ fail('INVALID_CREDENTIAL', 'The API key contains invalid characters.');
212
+ }
213
+ const fetchImpl = options.fetch ?? globalThis.fetch;
214
+ const timeout = options.timeout ?? 15000;
215
+ if (typeof fetchImpl !== 'function' || !Number.isInteger(timeout) || timeout < 1 || timeout > 120000) {
216
+ fail('INVALID_OPTIONS', 'A fetch implementation and timeout between 1 and 120000 milliseconds are required.');
217
+ }
218
+ const request = { fetch: fetchImpl, timeout, signal: options.signal };
219
+ const listing = await readJSON(`${normalized}/models`, apiKey, request);
220
+ if (!listing || !Array.isArray(listing.data) || listing.data.length > 1024) {
221
+ fail('INVALID_MODELS', 'Strata did not return a valid model list.');
222
+ }
223
+ const models = listing.data.map(modelRecord).filter(Boolean);
224
+ if (new Set(models.map(model => model.id)).size !== models.length) {
225
+ fail('INVALID_MODELS', 'Strata returned duplicate model identifiers.');
226
+ }
227
+ const { loaded, identities } = loadedTextModels(models);
228
+ if (!loaded.length) fail('NO_LOADED_MODEL', 'Strata has no explicitly loaded text model. Load a text model in Strata first.');
229
+ let selected;
230
+ if (options.modelId !== undefined) {
231
+ selected = loaded.find(model => model.id === identities.get(options.modelId));
232
+ if (!selected) fail('MODEL_NOT_LOADED', 'The selected model is not loaded on this server.');
233
+ } else {
234
+ if (loaded.length !== 1) fail('AMBIGUOUS_MODEL', 'Multiple models are loaded. Select a model before syncing.');
235
+ selected = loaded[0];
236
+ }
237
+ const selectedIds = new Set([...identities].filter(([, id]) => id === selected.id).map(([id]) => id));
238
+ const warnings = [];
239
+ let liveContext = null;
240
+ try {
241
+ const root = normalized.slice(0, -3);
242
+ const slots = await readJSON(`${root}/slots`, apiKey, request);
243
+ liveContext = slotContext(slots, selectedIds, loaded.length);
244
+ if (liveContext === null) warnings.push('SLOTS_CONTEXT_UNAVAILABLE');
245
+ } catch (error) {
246
+ if (error.code === 'AMBIGUOUS_CONTEXT') throw error;
247
+ warnings.push(`SLOTS_${error.code ?? 'UNAVAILABLE'}`);
248
+ }
249
+ const contextWindow = liveContext ?? selected.contextWindow;
250
+ if (!isContext(contextWindow)) fail('CONTEXT_UNAVAILABLE', 'The loaded model has no verified context size; automatic alignment is unavailable.');
251
+ if (liveContext !== null && selected.contextWindow !== null && liveContext !== selected.contextWindow) {
252
+ warnings.push('SLOTS_OVERRIDE_MODEL_CONTEXT');
253
+ }
254
+ let evidence = {};
255
+ let props;
256
+ try {
257
+ props = await readJSON(`${normalized.slice(0, -3)}/props?model=${encodeURIComponent(selected.id)}`, apiKey, request);
258
+ evidence = {
259
+ verifiedStrata: typeof props.build_info === 'string' && /^Strata \d+\.\d+(?:\.\d+)?(?:\b|$)/u.test(props.build_info),
260
+ modelMatched: selectedIds.has(props.model_alias),
261
+ templateHash: typeof props.chat_template === 'string'
262
+ ? createHash('sha256').update(props.chat_template.replace(/\r\n/g, '\n')).digest('hex') : null,
263
+ };
264
+ } catch { /* Optional metadata: preserve reasoning and calculate output on failure. */ }
265
+ let serverOutputTokens = null;
266
+ let outputLimitStatus = props == null ? 'unavailable' : 'unverified';
267
+ if (evidence.verifiedStrata && evidence.modelMatched) {
268
+ const value = props.default_generation_settings?.params?.n_predict;
269
+ outputLimitStatus = value == null ? 'missing' : value === -1 || value === 0 ? 'unlimited' : 'server';
270
+ if (outputLimitStatus === 'server') {
271
+ if (!Number.isSafeInteger(value) || value < 1) {
272
+ fail('INVALID_SERVER_OUTPUT_BUDGET', 'Strata returned an invalid output limit. Review the server setting before syncing.');
273
+ }
274
+ serverOutputTokens = value;
275
+ }
276
+ }
277
+ const capabilities = planModelCapabilities(selected.modalities, evidence);
278
+ warnings.push(...capabilities.warnings);
279
+ return {
280
+ baseURL: normalized,
281
+ verifiedServer: evidence.verifiedStrata === true && evidence.modelMatched === true,
282
+ model: { id: selected.id, name: selected.name, contextWindow, contextSource: liveContext !== null ? 'slots.n_ctx' : 'models.meta.n_ctx', serverOutputTokens, outputLimitStatus, capabilities },
283
+ models: models.map(({modalities, ...model}) => model), warnings,
284
+ };
285
+ }