@reaatech/media-pipeline-mcp-provider-core 0.3.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/index.js ADDED
@@ -0,0 +1,498 @@
1
+ // src/base-provider.ts
2
+ import { createHash } from "crypto";
3
+ var MediaProvider = class {
4
+ /** F2: per-provider cache config. Subclasses override to declare deterministic & non-deterministic params. */
5
+ static cacheConfig = {
6
+ deterministicParams: [],
7
+ nonDeterministicParams: [],
8
+ normalize: (inputs) => {
9
+ const normalized = {};
10
+ for (const [key, value] of Object.entries(inputs)) {
11
+ if (typeof value === "string") {
12
+ normalized[key] = value.trim().replace(/\s+/g, " ");
13
+ } else {
14
+ normalized[key] = value;
15
+ }
16
+ }
17
+ return normalized;
18
+ }
19
+ };
20
+ /** F6: set of operations that support streaming; absent = no streaming */
21
+ supportsStreaming;
22
+ /** F7: whether this provider supports webhook callbacks */
23
+ supportsWebhooks;
24
+ storage;
25
+ retryConfig = {
26
+ maxRetries: 3,
27
+ baseDelay: 1e3,
28
+ maxDelay: 3e4
29
+ };
30
+ /** F2: in-memory cache store */
31
+ cacheStore = /* @__PURE__ */ new Map();
32
+ setStorage(storage) {
33
+ this.storage = storage;
34
+ }
35
+ /**
36
+ * F2: execute with optional caching layer.
37
+ *
38
+ * Resolves cacheConfig:
39
+ * - explicit cacheConfig wins
40
+ * - else fall back to `defaultCacheConfigForOperation(input)` (per-op default; plan §F2)
41
+ *
42
+ * Mode semantics:
43
+ * - 'skip' — bypass cache entirely (no read, no write).
44
+ * - 'use' — read first; on miss, execute and store.
45
+ * - 'refresh' — always execute and store the fresh result (replace any existing entry).
46
+ *
47
+ * Key formula:
48
+ * sha256(provider :: modelId :: modelVersion :: scopeTag :: canonicalJson(deterministicInputs))
49
+ * where deterministicInputs = normalize(input.params filtered by ProviderCacheConfig.deterministicParams).
50
+ * When deterministicParams is empty (provider didn't override), all params participate.
51
+ */
52
+ async executeWithCache(input, cacheConfig) {
53
+ const effective = cacheConfig ?? this.defaultCacheConfigForOperation(input);
54
+ if (effective.mode === "skip") {
55
+ return this.execute(input);
56
+ }
57
+ const cacheKey = this.computeCacheKey(input, effective);
58
+ if (effective.mode === "use") {
59
+ const cached = this.cacheStore.get(cacheKey);
60
+ if (cached) {
61
+ const expires = new Date(cached.expiresAt).getTime();
62
+ if (expires > Date.now()) {
63
+ cached.hitCount++;
64
+ const out = ProviderOutputFromCacheEntry(cached, cached.outputs);
65
+ return {
66
+ ...out,
67
+ costUsd: 0,
68
+ metadata: { ...out.metadata ?? {}, cached: true, originalCostUsd: cached.costUsd }
69
+ };
70
+ }
71
+ this.cacheStore.delete(cacheKey);
72
+ }
73
+ }
74
+ const result = await this.execute(input);
75
+ const ttl = effective.ttlSeconds ?? 2592e3;
76
+ this.cacheStore.set(cacheKey, {
77
+ key: cacheKey,
78
+ artifactIds: [],
79
+ outputs: result,
80
+ costUsd: result.costUsd ?? 0,
81
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
82
+ expiresAt: new Date(Date.now() + ttl * 1e3).toISOString(),
83
+ hitCount: 0
84
+ });
85
+ return result;
86
+ }
87
+ /** Per-plan defaults when caller omits cacheConfig (F2 §"Backwards-compat" table). */
88
+ defaultCacheConfigForOperation(input) {
89
+ const op = input.operation;
90
+ const seed = input.params?.seed;
91
+ if (typeof seed === "number" && seed < 0) {
92
+ return { mode: "skip" };
93
+ }
94
+ if (op === "audio.tts") {
95
+ return { mode: "skip" };
96
+ }
97
+ if (op === "image.generate" || op === "audio.stt" || op === "document.extract") {
98
+ return { mode: "use" };
99
+ }
100
+ return { mode: "skip" };
101
+ }
102
+ /**
103
+ * F2: cache key — provider :: modelId :: modelVersion :: scopeTag :: deterministic-only inputs.
104
+ *
105
+ * `modelId` is read from input.params.model (or input.config.model); `modelVersion` from
106
+ * input.params.model_version (or model_id when it embeds a version like 'flux-pro-1.1').
107
+ * Scope tag: 'global' or `tenant:<id>` based on cacheConfig.scope. Tenant scope requires
108
+ * a tenantId on input.config; absence falls back to 'global' with a warning.
109
+ */
110
+ computeCacheKey(input, cacheConfig) {
111
+ const params = input.params;
112
+ const config = input.config;
113
+ const modelId = String(params?.model ?? config?.model ?? "unknown");
114
+ const modelVersion = String(
115
+ params?.model_version ?? params?.modelVersion ?? config?.model_version ?? "v0"
116
+ );
117
+ const providerCacheConfig = this.constructor.cacheConfig;
118
+ const filtered = this.filterDeterministic(params, providerCacheConfig);
119
+ const normalized = providerCacheConfig.normalize(filtered);
120
+ let scopeTag = "global";
121
+ if (cacheConfig?.scope === "tenant") {
122
+ const tenantId = config?.tenantId;
123
+ if (typeof tenantId === "string" && tenantId.length > 0) {
124
+ scopeTag = `tenant:${tenantId}`;
125
+ } else {
126
+ scopeTag = "tenant:UNKNOWN";
127
+ }
128
+ }
129
+ const hash = createHash("sha256");
130
+ hash.update(this.name);
131
+ hash.update("::");
132
+ hash.update(modelId);
133
+ hash.update("::");
134
+ hash.update(modelVersion);
135
+ hash.update("::");
136
+ hash.update(scopeTag);
137
+ hash.update("::");
138
+ hash.update(input.operation);
139
+ hash.update("::");
140
+ hash.update(this.canonicalJson(normalized));
141
+ return hash.digest("hex");
142
+ }
143
+ /**
144
+ * Drop params not in `deterministicParams`. If the list is empty (provider didn't override),
145
+ * fall back to "all params except those in `nonDeterministicParams`" — and if both lists are
146
+ * empty, hash every param (legacy behavior).
147
+ */
148
+ filterDeterministic(params, pcc) {
149
+ if (!params) return {};
150
+ if (pcc.deterministicParams.length > 0) {
151
+ const out = {};
152
+ for (const k of pcc.deterministicParams) {
153
+ if (k in params) out[k] = params[k];
154
+ }
155
+ return out;
156
+ }
157
+ if (pcc.nonDeterministicParams.length > 0) {
158
+ const out = {};
159
+ const skip = new Set(pcc.nonDeterministicParams);
160
+ for (const [k, v] of Object.entries(params)) {
161
+ if (!skip.has(k)) out[k] = v;
162
+ }
163
+ return out;
164
+ }
165
+ return params;
166
+ }
167
+ /** F2: canonical JSON: sorted keys, no whitespace, no trailing zeros */
168
+ canonicalJson(obj) {
169
+ if (obj === null || obj === void 0) return "null";
170
+ if (typeof obj === "string") return JSON.stringify(obj);
171
+ if (typeof obj === "number") return this.canonicalNumber(obj);
172
+ if (typeof obj === "boolean") return obj ? "true" : "false";
173
+ if (Array.isArray(obj)) {
174
+ const items = obj.map((item) => this.canonicalJson(item));
175
+ return `[${items.join(",")}]`;
176
+ }
177
+ if (typeof obj === "object") {
178
+ const keys = Object.keys(obj).sort();
179
+ const pairs = keys.map(
180
+ (k) => `${this.canonicalJson(k)}:${this.canonicalJson(obj[k])}`
181
+ );
182
+ return `{${pairs.join(",")}}`;
183
+ }
184
+ return String(obj);
185
+ }
186
+ /** F2: strip trailing zeros from numbers */
187
+ canonicalNumber(n) {
188
+ const s = n.toFixed(10);
189
+ const trimmed = s.replace(/\.?0+$/, "");
190
+ return trimmed;
191
+ }
192
+ async executeWithRetry(input) {
193
+ return this.executeWithRetryImpl(input);
194
+ }
195
+ async executeWithRetryImpl(input) {
196
+ let lastError;
197
+ for (let attempt = 0; attempt < this.retryConfig.maxRetries; attempt++) {
198
+ try {
199
+ if (attempt > 0) {
200
+ const delay = Math.min(
201
+ this.retryConfig.baseDelay * 2 ** (attempt - 1),
202
+ this.retryConfig.maxDelay
203
+ );
204
+ await new Promise((resolve) => setTimeout(resolve, delay));
205
+ }
206
+ return await this.executeWithCache(input);
207
+ } catch (error) {
208
+ lastError = error;
209
+ if (this.isNonRetryableError(error)) {
210
+ throw error;
211
+ }
212
+ }
213
+ }
214
+ throw lastError;
215
+ }
216
+ isNonRetryableError(error) {
217
+ const message = error.message.toLowerCase();
218
+ return message.includes("authentication") || message.includes("unauthorized") || message.includes("validation") || message.includes("invalid api key");
219
+ }
220
+ generateArtifactId() {
221
+ return `artifact-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
222
+ }
223
+ async storeArtifact(data, type, mimeType, metadata, sourceStep) {
224
+ if (!this.storage) {
225
+ throw new Error("Storage not configured for provider");
226
+ }
227
+ const id = this.generateArtifactId();
228
+ const uri = await this.storage.put(id, data, {
229
+ id,
230
+ type,
231
+ mimeType,
232
+ metadata,
233
+ sourceStep
234
+ });
235
+ return uri;
236
+ }
237
+ };
238
+ function ProviderOutputFromCacheEntry(_entry, outputs) {
239
+ return outputs;
240
+ }
241
+ function defineProvider(providerClass) {
242
+ return providerClass;
243
+ }
244
+
245
+ // src/router.ts
246
+ var RouterNoCandidatesError = class extends Error {
247
+ code = "ROUTER_NO_CANDIDATES";
248
+ constructor() {
249
+ super("No candidates provided for routing");
250
+ this.name = "RouterNoCandidatesError";
251
+ }
252
+ };
253
+ var RouterAllCandidatesFailedError = class extends Error {
254
+ code = "ROUTER_ALL_CANDIDATES_FAILED";
255
+ rejections;
256
+ constructor(rejections) {
257
+ super("All routing candidates failed");
258
+ this.name = "RouterAllCandidatesFailedError";
259
+ this.rejections = rejections;
260
+ }
261
+ };
262
+ var RouterFastestIneligibleError = class extends Error {
263
+ code = "ROUTER_FASTEST_INELIGIBLE";
264
+ ineligibleCandidates;
265
+ constructor(ineligible) {
266
+ super(
267
+ `'fastest' strategy requires all candidates to declare expectedDurationMs < 5000; ${ineligible.length} candidate(s) failed this check`
268
+ );
269
+ this.name = "RouterFastestIneligibleError";
270
+ this.ineligibleCandidates = ineligible;
271
+ }
272
+ };
273
+ var FASTEST_MAX_DURATION_MS = 5e3;
274
+ var DEFAULT_HEALTH_TTL_MS = 3e4;
275
+ var Router = class {
276
+ constructor(ctx) {
277
+ this.ctx = ctx;
278
+ }
279
+ ctx;
280
+ healthCache = /* @__PURE__ */ new Map();
281
+ async route(config, inputs) {
282
+ if (config.candidates.length === 0) {
283
+ throw new RouterNoCandidatesError();
284
+ }
285
+ if (config.strategy === "fastest") {
286
+ const ineligible = [];
287
+ for (const c of config.candidates) {
288
+ const dur = this.ctx.expectedDurationMs?.(c, inputs);
289
+ if (dur === void 0 || dur >= FASTEST_MAX_DURATION_MS) {
290
+ ineligible.push(c);
291
+ }
292
+ }
293
+ if (ineligible.length > 0) {
294
+ throw new RouterFastestIneligibleError(ineligible);
295
+ }
296
+ }
297
+ switch (config.strategy) {
298
+ case "first-success":
299
+ return this.routeFirstSuccess(config, inputs);
300
+ case "cheapest-acceptable":
301
+ return this.routeCheapestAcceptable(config, inputs);
302
+ case "fastest":
303
+ return this.routeFastest(config, inputs);
304
+ default: {
305
+ const _exhaustive = config.strategy;
306
+ throw new Error(`Unknown router strategy: ${_exhaustive}`);
307
+ }
308
+ }
309
+ }
310
+ /**
311
+ * Cached health probe. Cache key is provider+model so we share the result across
312
+ * routes that mention the same candidate. TTL defaults to 30s; per-route override
313
+ * via RouteConfig.healthTtlMs.
314
+ */
315
+ async cachedHealth(candidate, ttlMs) {
316
+ const cacheKey = `${candidate.provider}::${candidate.model}`;
317
+ const now = Date.now();
318
+ const existing = this.healthCache.get(cacheKey);
319
+ if (existing && existing.expiresAtMs > now) {
320
+ return existing.result;
321
+ }
322
+ const result = await this.ctx.health(candidate);
323
+ this.healthCache.set(cacheKey, { result, expiresAtMs: now + ttlMs });
324
+ return result;
325
+ }
326
+ /** Merge candidate-specific input overrides into the step inputs. */
327
+ applyInputOverrides(candidate, inputs) {
328
+ if (!candidate.inputOverrides) return inputs;
329
+ return {
330
+ ...inputs,
331
+ params: { ...inputs.params, ...candidate.inputOverrides }
332
+ };
333
+ }
334
+ async routeFirstSuccess(config, inputs) {
335
+ const rejections = [];
336
+ for (const candidate of config.candidates) {
337
+ if (candidate.maxQueueMs !== void 0 && this.ctx.queueMs) {
338
+ const queue = await this.ctx.queueMs(candidate);
339
+ if (queue !== void 0 && queue > candidate.maxQueueMs) {
340
+ rejections.push({
341
+ candidate,
342
+ reason: "queue-full",
343
+ detail: `Queue ${queue}ms > max ${candidate.maxQueueMs}ms`
344
+ });
345
+ continue;
346
+ }
347
+ }
348
+ const controller = new AbortController();
349
+ const timer = config.timeoutMs ? setTimeout(() => controller.abort(), config.timeoutMs) : void 0;
350
+ try {
351
+ const resolved = this.applyInputOverrides(candidate, inputs);
352
+ const output = await this.ctx.execute(candidate, resolved, controller.signal);
353
+ clearTimeout(timer);
354
+ return {
355
+ decision: {
356
+ selected: candidate,
357
+ rejected: rejections,
358
+ reason: "first-success: candidate succeeded",
359
+ decidedAtMs: Date.now()
360
+ },
361
+ output
362
+ };
363
+ } catch (error) {
364
+ clearTimeout(timer);
365
+ rejections.push({
366
+ candidate,
367
+ reason: "error",
368
+ detail: error.message
369
+ });
370
+ }
371
+ }
372
+ throw new RouterAllCandidatesFailedError(rejections);
373
+ }
374
+ async routeCheapestAcceptable(config, inputs) {
375
+ const rejections = [];
376
+ const healthTtl = config.healthTtlMs ?? DEFAULT_HEALTH_TTL_MS;
377
+ const results = await Promise.all(
378
+ config.candidates.map(async (candidate) => {
379
+ const resolved = this.applyInputOverrides(candidate, inputs);
380
+ const [estimate, healthResult, queueMs] = await Promise.all([
381
+ this.ctx.estimateCost(candidate, resolved),
382
+ this.cachedHealth(candidate, healthTtl),
383
+ candidate.maxQueueMs !== void 0 && this.ctx.queueMs ? this.ctx.queueMs(candidate) : Promise.resolve(void 0)
384
+ ]);
385
+ return { candidate, estimate, health: healthResult, queueMs, resolved };
386
+ })
387
+ );
388
+ const valid = [];
389
+ for (const r of results) {
390
+ if (!r.health.healthy) {
391
+ rejections.push({
392
+ candidate: r.candidate,
393
+ reason: "unhealthy",
394
+ detail: "Health check failed"
395
+ });
396
+ continue;
397
+ }
398
+ if (r.candidate.maxUsd !== void 0 && r.estimate.costUsd > r.candidate.maxUsd) {
399
+ rejections.push({
400
+ candidate: r.candidate,
401
+ reason: "over-budget",
402
+ detail: `Estimate ${r.estimate.costUsd} > max ${r.candidate.maxUsd}`
403
+ });
404
+ continue;
405
+ }
406
+ if (r.candidate.maxQueueMs !== void 0 && r.queueMs !== void 0 && r.queueMs > r.candidate.maxQueueMs) {
407
+ rejections.push({
408
+ candidate: r.candidate,
409
+ reason: "queue-full",
410
+ detail: `Queue ${r.queueMs}ms > max ${r.candidate.maxQueueMs}ms`
411
+ });
412
+ continue;
413
+ }
414
+ valid.push(r);
415
+ }
416
+ if (valid.length === 0) {
417
+ throw new RouterAllCandidatesFailedError(rejections);
418
+ }
419
+ valid.sort((a, b) => {
420
+ const costDelta = a.estimate.costUsd - b.estimate.costUsd;
421
+ if (costDelta !== 0) return costDelta;
422
+ return (b.candidate.weight ?? 1) - (a.candidate.weight ?? 1);
423
+ });
424
+ const best = valid[0];
425
+ const controller = new AbortController();
426
+ const timer = config.timeoutMs ? setTimeout(() => controller.abort(), config.timeoutMs) : void 0;
427
+ try {
428
+ const output = await this.ctx.execute(best.candidate, best.resolved, controller.signal);
429
+ clearTimeout(timer);
430
+ return {
431
+ decision: {
432
+ selected: best.candidate,
433
+ rejected: rejections,
434
+ estimate: best.estimate,
435
+ reason: "cheapest-acceptable: lowest cost among healthy candidates",
436
+ decidedAtMs: Date.now()
437
+ },
438
+ output
439
+ };
440
+ } catch (error) {
441
+ clearTimeout(timer);
442
+ rejections.push({
443
+ candidate: best.candidate,
444
+ reason: "error",
445
+ detail: error.message
446
+ });
447
+ throw new RouterAllCandidatesFailedError(rejections);
448
+ }
449
+ }
450
+ async routeFastest(config, inputs) {
451
+ const rejections = [];
452
+ const controller = new AbortController();
453
+ const eligible = config.candidates;
454
+ const timer = config.timeoutMs ? setTimeout(() => controller.abort(), config.timeoutMs) : void 0;
455
+ return new Promise((resolve, reject) => {
456
+ let settled = false;
457
+ let remaining = eligible.length;
458
+ for (const candidate of eligible) {
459
+ const resolved = this.applyInputOverrides(candidate, inputs);
460
+ this.ctx.execute(candidate, resolved, controller.signal).then((output) => {
461
+ if (settled) return;
462
+ settled = true;
463
+ clearTimeout(timer);
464
+ controller.abort();
465
+ resolve({
466
+ decision: {
467
+ selected: candidate,
468
+ rejected: rejections,
469
+ reason: "fastest: first candidate to complete",
470
+ decidedAtMs: Date.now()
471
+ },
472
+ output
473
+ });
474
+ }).catch((error) => {
475
+ if (settled) return;
476
+ rejections.push({
477
+ candidate,
478
+ reason: "error",
479
+ detail: error.message
480
+ });
481
+ remaining--;
482
+ if (remaining === 0) {
483
+ settled = true;
484
+ clearTimeout(timer);
485
+ reject(new RouterAllCandidatesFailedError(rejections));
486
+ }
487
+ });
488
+ }
489
+ });
490
+ }
491
+ };
492
+ export {
493
+ MediaProvider,
494
+ Router,
495
+ RouterAllCandidatesFailedError,
496
+ RouterNoCandidatesError,
497
+ defineProvider
498
+ };
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@reaatech/media-pipeline-mcp-provider-core",
3
+ "version": "0.3.0",
4
+ "description": "Abstract base class and shared interfaces for all media provider implementations",
5
+ "license": "MIT",
6
+ "author": "Rick Somers <rick@reaatech.com> (https://reaatech.com)",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/reaatech/media-pipeline-mcp.git",
10
+ "directory": "packages/provider-core"
11
+ },
12
+ "homepage": "https://github.com/reaatech/media-pipeline-mcp/tree/main/packages/provider-core#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/reaatech/media-pipeline-mcp/issues"
15
+ },
16
+ "type": "module",
17
+ "main": "./dist/index.cjs",
18
+ "module": "./dist/index.js",
19
+ "types": "./dist/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "import": "./dist/index.js",
24
+ "require": "./dist/index.cjs"
25
+ }
26
+ },
27
+ "files": [
28
+ "dist"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "dependencies": {
34
+ "@reaatech/media-pipeline-mcp-storage": "0.3.0",
35
+ "@reaatech/media-pipeline-mcp-core": "0.3.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^20.11.0",
39
+ "tsup": "^8.4.0",
40
+ "typescript": "^5.8.3",
41
+ "vitest": "^3.1.1"
42
+ },
43
+ "scripts": {
44
+ "build": "tsup src/index.ts --format cjs,esm --dts --clean",
45
+ "test": "vitest run",
46
+ "test:coverage": "vitest run --coverage",
47
+ "clean": "rm -rf dist"
48
+ }
49
+ }