@volter/twin-fireworks 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 (39) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +184 -0
  3. package/dist/src/cli.d.ts +2 -0
  4. package/dist/src/cli.js +28 -0
  5. package/dist/src/fireworks-budget.d.ts +54 -0
  6. package/dist/src/fireworks-budget.js +146 -0
  7. package/dist/src/fireworks-capabilities.d.ts +4 -0
  8. package/dist/src/fireworks-capabilities.js +1205 -0
  9. package/dist/src/fireworks-conformance.d.ts +14 -0
  10. package/dist/src/fireworks-conformance.js +514 -0
  11. package/dist/src/fireworks-connector.d.ts +168 -0
  12. package/dist/src/fireworks-connector.js +641 -0
  13. package/dist/src/fireworks-models.d.ts +11 -0
  14. package/dist/src/fireworks-models.js +53 -0
  15. package/dist/src/fireworks-scenario.d.ts +55 -0
  16. package/dist/src/fireworks-scenario.js +171 -0
  17. package/dist/src/fireworks-server.d.ts +16 -0
  18. package/dist/src/fireworks-server.js +144 -0
  19. package/dist/src/fireworks-stub.d.ts +26 -0
  20. package/dist/src/fireworks-stub.js +78 -0
  21. package/dist/src/fireworks-twin.d.ts +51 -0
  22. package/dist/src/fireworks-twin.js +1426 -0
  23. package/dist/src/fireworks-types.d.ts +212 -0
  24. package/dist/src/fireworks-types.js +4 -0
  25. package/dist/src/index.d.ts +9 -0
  26. package/dist/src/index.js +105 -0
  27. package/package.json +52 -0
  28. package/src/cli.ts +27 -0
  29. package/src/fireworks-budget.ts +172 -0
  30. package/src/fireworks-capabilities.ts +1229 -0
  31. package/src/fireworks-conformance.ts +542 -0
  32. package/src/fireworks-connector.ts +700 -0
  33. package/src/fireworks-models.ts +63 -0
  34. package/src/fireworks-scenario.ts +191 -0
  35. package/src/fireworks-server.ts +153 -0
  36. package/src/fireworks-stub.ts +83 -0
  37. package/src/fireworks-twin.ts +1427 -0
  38. package/src/fireworks-types.ts +165 -0
  39. package/src/index.ts +134 -0
@@ -0,0 +1,641 @@
1
+ // Fireworks CONNECTOR — the live-vendor pull/push path that gives the Fireworks twin the full
2
+ // "git for SaaS" lifecycle, over an INJECTED executor (the auth boundary; the kernel + this pack
3
+ // hold NO key and import NO SDK at runtime).
4
+ //
5
+ // PULL (real → twin): enumerate the CONTROL plane (the stateful surface — deployments, datasets,
6
+ // batch-inference and supervised-fine-tuning jobs, users, models, secrets)
7
+ // through the Gateway REST list endpoints and fold into the event log via
8
+ // the kernel's `observeResources` (protocol 2 — one batch, one instant,
9
+ // shadow-diff dedup, so a re-pull of identical state appends ZERO deltas).
10
+ // The INFERENCE plane stores nothing (chat/completions, embeddings, rerank
11
+ // are generative), so there is nothing there to pull.
12
+ // PUSH (twin → real): replay each pending local write against the real Gateway REST surface and
13
+ // confirm it. Inference operations are NOT pushes — replaying a completion
14
+ // would spend real money and store nothing — so they are refused by name.
15
+ //
16
+ // The vendor I/O is an INJECTED executor (`FireworksExecute`): a fake in tests, `liveFireworksExecute`
17
+ // in prod. Same code path either way — fully exercisable offline.
18
+ import { assertBudgetGuardIntact, confirmAction, deployableEntries, observeResources, projectResources } from '@volter/world-core';
19
+ import { FireworksBudget, FireworksBudgetError, fireworksBudgetPath, fireworksCallWeight } from "./fireworks-budget.js";
20
+ const SERVICE = 'fireworks';
21
+ const DEFAULT_OCCURRED_AT = '1970-01-01T00:00:00.000Z';
22
+ /** The account every Gateway REST path is addressed under when no caller (or subject namespace)
23
+ * supplies one. The vendor keys every control-plane path by account id; the twin models one
24
+ * default account, and the perform path derives the account from the SUBJECT's own namespace
25
+ * first, falling back to this constant only for a bare id. */
26
+ export const FIREWORKS_ACCOUNT_ID = 'my-account';
27
+ /**
28
+ * A live executor against the real Fireworks 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.fireworks.ai` request, and therefore the one
33
+ * place the rate budget is enforced. EVERY call is guarded: the budget is charged BEFORE the
34
+ * request goes out (`checkBudget`, which THROWS `FireworksBudgetError` instead of returning when
35
+ * the ceiling or a cooldown says stop) and the response is fed back (`recordCall`) so a
36
+ * `retry-after` / 429 becomes a persisted cooldown that makes every later call fail fast WITHOUT
37
+ * touching Fireworks. There is deliberately no OPTION to disable the guard, and no value a caller
38
+ * can pass for `budget` that yields an unguarded client. What that does NOT claim is immunity from
39
+ * a caller who WANTS one: a fresh `budgetOptions.path` per construction, or an injected clock,
40
+ * restores the allowance, because the same seam tests need cannot be denied to a determined caller
41
+ * in the same process. See `fireworks-budget.ts` and the kernel header for the limits of the
42
+ * guarantee.
43
+ */
44
+ export function liveFireworksExecute(apiKey, base = 'https://api.fireworks.ai', 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 FireworksBudget — a duck-typed stand-in, a SUBCLASS overriding
49
+ // `checkBudget`, and a Proxy trapping it are ALL refused, because each is a one-liner that would
50
+ // otherwise hand back a client with no ceiling. The default ledger is keyed by a hash of THIS
51
+ // key: Fireworks meters per ACCOUNT, so a cwd-scoped ledger would hand the same key a fresh
52
+ // allowance per checkout/worktree/CI leg.
53
+ const budget = opts.budget !== undefined && opts.budget !== null
54
+ ? assertBudgetGuardIntact(opts.budget, FireworksBudget, 'liveFireworksExecute')
55
+ : new FireworksBudget({ 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 (!path.startsWith('/inference/v1/') && !path.startsWith('/v1/accounts/')) {
60
+ throw new Error('fireworks: refusing to call an unmodeled path — Fireworks serves the inference plane under /inference/v1/ and the control plane under /v1/accounts/');
61
+ }
62
+ const headers = { authorization: `Bearer ${apiKey}`, accept: 'application/json' };
63
+ const init = { method, headers };
64
+ if (method === 'POST' || method === 'PATCH') {
65
+ headers['content-type'] = 'application/json';
66
+ init.body = JSON.stringify(body ?? {});
67
+ }
68
+ const weight = fireworksCallWeight(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
+ let data = {};
75
+ try {
76
+ data = await res.json();
77
+ }
78
+ catch {
79
+ data = {};
80
+ }
81
+ // Settles the reservation and, on a back-off signal, arms the cooldown.
82
+ // recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
83
+ // call that louder refusal wins; an answer Fireworks ACCEPTED is kept, so a write that landed is
84
+ // never recorded as failed and performed again on retry.
85
+ try {
86
+ budget.recordCall(weight, resHeaders, { status: res.status, reservation });
87
+ }
88
+ catch (error) {
89
+ if (!(error instanceof FireworksBudgetError) || !res.ok)
90
+ throw error;
91
+ }
92
+ return { status: res.status, data };
93
+ };
94
+ }
95
+ // ── PULL: real → twin (protocol 2 — the refresh adapter's fold) ─────────────────────────────
96
+ /** The gateway list envelope: `{ <plural>: [...], nextPageToken, totalSize }`. */
97
+ function envelopeList(data, plural) {
98
+ if (data === null || typeof data !== 'object')
99
+ return [];
100
+ const items = data[plural];
101
+ return Array.isArray(items) ? items : [];
102
+ }
103
+ /** The kernel subject id for a pulled row: `{account}/{id}` from the vendor's own resource
104
+ * `name` (`accounts/{account}/{collection}/{id}`). The twin's create path writes the same
105
+ * namespace, so created and pulled rows share ONE id space per (type, account). */
106
+ function namespacedId(name) {
107
+ const seg = name.split('/');
108
+ return seg.length >= 4 ? `${seg[1]}/${seg[seg.length - 1]}` : (seg[seg.length - 1] ?? name);
109
+ }
110
+ /** Map one gateway deployment row → a twin sync resource. Vendor `name` is
111
+ * `accounts/{account}/deployments/{id}`; the twin keys the resource by the id segment. */
112
+ export function mapDeployment(d) {
113
+ const name = typeof d.name === 'string' ? d.name : '';
114
+ // The kernel subject is the ACCOUNT-NAMESPACED id (`{account}/{id}`) — the same space the
115
+ // twin's own create path writes — so a pulled row and a created row of one vendor resource are
116
+ // ONE subject, never two.
117
+ const id = namespacedId(name);
118
+ return {
119
+ type: 'deployment',
120
+ id,
121
+ fields: {
122
+ // The vendor's own identity field — the SAME shape the twin's own creates store, so one
123
+ // collection never has two shapes (round-four review: a pulled row served `vendor_name`
124
+ // and every official client reading `.name` got undefined).
125
+ name,
126
+ // The account the row belongs to (the vendor name's own second segment) — the twin's
127
+ // reads are account-scoped, so a pulled row must carry the tenancy it was pulled under.
128
+ _account: name.split('/')[1] ?? null,
129
+ displayName: d.displayName ?? null,
130
+ baseModel: d.baseModel ?? null,
131
+ state: d.state ?? 'STATE_UNSPECIFIED',
132
+ region: d.region ?? null,
133
+ replicaCount: d.replicaCount ?? null,
134
+ minReplicaCount: d.minReplicaCount ?? null,
135
+ maxReplicaCount: d.maxReplicaCount ?? null,
136
+ precision: d.precision ?? null,
137
+ createTime: d.createTime ?? null,
138
+ updateTime: d.updateTime ?? null,
139
+ status: d.status ?? null,
140
+ },
141
+ };
142
+ }
143
+ export function mapDataset(ds) {
144
+ const name = typeof ds.name === 'string' ? ds.name : '';
145
+ // The kernel subject is the ACCOUNT-NAMESPACED id (`{account}/{id}`) — the same space the
146
+ // twin's own create path writes — so a pulled row and a created row of one vendor resource are
147
+ // ONE subject, never two.
148
+ const id = namespacedId(name);
149
+ return {
150
+ type: 'dataset',
151
+ id,
152
+ fields: {
153
+ // The vendor's own identity field — the SAME shape the twin's own creates store, so one
154
+ // collection never has two shapes (round-four review: a pulled row served `vendor_name`
155
+ // and every official client reading `.name` got undefined).
156
+ name,
157
+ // The account the row belongs to (the vendor name's own second segment) — the twin's
158
+ // reads are account-scoped, so a pulled row must carry the tenancy it was pulled under.
159
+ _account: name.split('/')[1] ?? null,
160
+ displayName: ds.displayName ?? null,
161
+ state: ds.state ?? 'STATE_UNSPECIFIED',
162
+ format: ds.format ?? null,
163
+ exampleCount: ds.exampleCount ?? null,
164
+ userUploaded: ds.userUploaded ?? null,
165
+ createTime: ds.createTime ?? null,
166
+ updateTime: ds.updateTime ?? null,
167
+ status: ds.status ?? null,
168
+ },
169
+ };
170
+ }
171
+ export function mapBatchInferenceJob(j) {
172
+ const name = typeof j.name === 'string' ? j.name : '';
173
+ // The kernel subject is the ACCOUNT-NAMESPACED id (`{account}/{id}`) — the same space the
174
+ // twin's own create path writes — so a pulled row and a created row of one vendor resource are
175
+ // ONE subject, never two.
176
+ const id = namespacedId(name);
177
+ return {
178
+ type: 'batchInferenceJob',
179
+ id,
180
+ fields: {
181
+ // The vendor's own identity field — the SAME shape the twin's own creates store, so one
182
+ // collection never has two shapes (round-four review: a pulled row served `vendor_name`
183
+ // and every official client reading `.name` got undefined).
184
+ name,
185
+ // The account the row belongs to (the vendor name's own second segment) — the twin's
186
+ // reads are account-scoped, so a pulled row must carry the tenancy it was pulled under.
187
+ _account: name.split('/')[1] ?? null,
188
+ displayName: j.displayName ?? null,
189
+ model: j.model ?? null,
190
+ inputDatasetId: j.inputDatasetId ?? null,
191
+ outputDatasetId: j.outputDatasetId ?? null,
192
+ state: j.state ?? 'JOB_STATE_UNSPECIFIED',
193
+ createTime: j.createTime ?? null,
194
+ updateTime: j.updateTime ?? null,
195
+ status: j.status ?? null,
196
+ },
197
+ };
198
+ }
199
+ export function mapSupervisedFineTuningJob(j) {
200
+ const name = typeof j.name === 'string' ? j.name : '';
201
+ // The kernel subject is the ACCOUNT-NAMESPACED id (`{account}/{id}`) — the same space the
202
+ // twin's own create path writes — so a pulled row and a created row of one vendor resource are
203
+ // ONE subject, never two.
204
+ const id = namespacedId(name);
205
+ return {
206
+ type: 'supervisedFineTuningJob',
207
+ id,
208
+ fields: {
209
+ // The vendor's own identity field — the SAME shape the twin's own creates store, so one
210
+ // collection never has two shapes (round-four review: a pulled row served `vendor_name`
211
+ // and every official client reading `.name` got undefined).
212
+ name,
213
+ // The account the row belongs to (the vendor name's own second segment) — the twin's
214
+ // reads are account-scoped, so a pulled row must carry the tenancy it was pulled under.
215
+ _account: name.split('/')[1] ?? null,
216
+ displayName: j.displayName ?? null,
217
+ baseModel: j.baseModel ?? null,
218
+ dataset: j.dataset ?? null,
219
+ state: j.state ?? 'JOB_STATE_UNSPECIFIED',
220
+ createTime: j.createTime ?? null,
221
+ updateTime: j.updateTime ?? null,
222
+ status: j.status ?? null,
223
+ },
224
+ };
225
+ }
226
+ export function mapUser(u) {
227
+ const name = typeof u.name === 'string' ? u.name : '';
228
+ // The kernel subject is the ACCOUNT-NAMESPACED id (`{account}/{id}`) — the same space the
229
+ // twin's own create path writes — so a pulled row and a created row of one vendor resource are
230
+ // ONE subject, never two.
231
+ const id = namespacedId(name);
232
+ return {
233
+ type: 'user',
234
+ id,
235
+ fields: {
236
+ // The vendor's own identity field — the SAME shape the twin's own creates store, so one
237
+ // collection never has two shapes (round-four review: a pulled row served `vendor_name`
238
+ // and every official client reading `.name` got undefined).
239
+ name,
240
+ // The account the row belongs to (the vendor name's own second segment) — the twin's
241
+ // reads are account-scoped, so a pulled row must carry the tenancy it was pulled under.
242
+ _account: name.split('/')[1] ?? null,
243
+ displayName: u.displayName ?? null,
244
+ email: u.email ?? null,
245
+ role: u.role ?? null,
246
+ state: u.state ?? 'STATE_UNSPECIFIED',
247
+ createTime: u.createTime ?? null,
248
+ updateTime: u.updateTime ?? null,
249
+ status: u.status ?? null,
250
+ },
251
+ };
252
+ }
253
+ export function mapModel(m) {
254
+ const name = typeof m.name === 'string' ? m.name : '';
255
+ // The kernel subject is the ACCOUNT-NAMESPACED id (`{account}/{id}`) — the same space the
256
+ // twin's own create path writes — so a pulled row and a created row of one vendor resource are
257
+ // ONE subject, never two.
258
+ const id = namespacedId(name);
259
+ return {
260
+ type: 'model',
261
+ id,
262
+ fields: {
263
+ // The vendor's own identity field — the SAME shape the twin's own creates store, so one
264
+ // collection never has two shapes (round-four review: a pulled row served `vendor_name`
265
+ // and every official client reading `.name` got undefined).
266
+ name,
267
+ // The account the row belongs to (the vendor name's own second segment) — the twin's
268
+ // reads are account-scoped, so a pulled row must carry the tenancy it was pulled under.
269
+ _account: name.split('/')[1] ?? null,
270
+ displayName: m.displayName ?? null,
271
+ state: m.state ?? 'STATE_UNSPECIFIED',
272
+ public: m.public ?? null,
273
+ contextLength: m.contextLength ?? null,
274
+ createTime: m.createTime ?? null,
275
+ updateTime: m.updateTime ?? null,
276
+ status: m.status ?? null,
277
+ },
278
+ };
279
+ }
280
+ /** Secrets never carry their value on a list read (the vendor redacts it), so the map stores the
281
+ * metadata only — mirroring the vendor's own read surface. */
282
+ export function mapSecret(s) {
283
+ const name = typeof s.name === 'string' ? s.name : '';
284
+ // The kernel subject is the ACCOUNT-NAMESPACED id (`{account}/{id}`) — the same space the
285
+ // twin's own create path writes — so a pulled row and a created row of one vendor resource are
286
+ // ONE subject, never two.
287
+ const id = namespacedId(name);
288
+ return {
289
+ type: 'secret',
290
+ id,
291
+ fields: {
292
+ // The vendor's own identity field — the SAME shape the twin's own creates store, so one
293
+ // collection never has two shapes (round-four review: a pulled row served `vendor_name`
294
+ // and every official client reading `.name` got undefined).
295
+ name,
296
+ // The account the row belongs to (the vendor name's own second segment) — the twin's
297
+ // reads are account-scoped, so a pulled row must carry the tenancy it was pulled under.
298
+ _account: name.split('/')[1] ?? null,
299
+ keyName: s.keyName ?? null,
300
+ state: s.state ?? 'STATE_UNSPECIFIED',
301
+ createTime: s.createTime ?? null,
302
+ updateTime: s.updateTime ?? null,
303
+ status: s.status ?? null,
304
+ },
305
+ };
306
+ }
307
+ /** Which collections a pull enumerates, with their mappers. Paginated with the vendor's own
308
+ * `pageToken` cursor so a large account is not truncated at page one. */
309
+ const PULL_COLLECTIONS = [
310
+ { plural: 'deployments', type: 'deployment', map: mapDeployment },
311
+ { plural: 'datasets', type: 'dataset', map: mapDataset },
312
+ { plural: 'batchInferenceJobs', type: 'batchInferenceJob', map: mapBatchInferenceJob },
313
+ { plural: 'supervisedFineTuningJobs', type: 'supervisedFineTuningJob', map: mapSupervisedFineTuningJob },
314
+ { plural: 'users', type: 'user', map: mapUser },
315
+ { plural: 'models', type: 'model', map: mapModel },
316
+ { plural: 'secrets', type: 'secret', map: mapSecret },
317
+ ];
318
+ /** Fetch the account's control-plane state through the injected client and map to SyncResource[]
319
+ * (no fold). `accountId` scopes every list; a missing/failed list throws — a REFUSED pull is NOT
320
+ * an empty account, and folding an empty list over real observed state would tombstone it. */
321
+ export async function pullFireworksState(execute, accountId) {
322
+ const resources = [];
323
+ for (const col of PULL_COLLECTIONS) {
324
+ let pageToken = null;
325
+ // An independent page ceiling: pagination terminates on `nextPageToken` becoming null, but a
326
+ // vendor (or a scenario handler) echoing a token forever must end in a thrown error, not an
327
+ // endless loop — no single response field can make this loop unbounded.
328
+ for (let page = 0; page < MAX_PULL_PAGES; page++) {
329
+ const q = pageToken ? `?pageToken=${encodeURIComponent(pageToken)}&pageSize=200` : '?pageSize=200';
330
+ const res = await execute('GET', `/v1/accounts/${encodeURIComponent(accountId)}/${col.plural}${q}`);
331
+ if (res.status < 200 || res.status >= 300) {
332
+ throw new Error(`fireworks pull ${col.plural} failed: HTTP ${res.status} ${JSON.stringify(res.data).slice(0, 200)}`);
333
+ }
334
+ for (const row of envelopeList(res.data, col.plural))
335
+ resources.push(col.map(row));
336
+ pageToken = typeof res.data?.nextPageToken === 'string' ? res.data.nextPageToken : null;
337
+ if (!pageToken)
338
+ break;
339
+ }
340
+ if (pageToken !== null)
341
+ throw new Error(`fireworks pull ${col.plural} did not terminate after ${MAX_PULL_PAGES} pages — refusing to loop forever on a vendor that keeps returning nextPageToken`);
342
+ }
343
+ return resources;
344
+ }
345
+ /** The pagination ceiling above: 10 000 rows at pageSize 200 per collection — far above any real
346
+ * account, far below forever. */
347
+ const MAX_PULL_PAGES = 50;
348
+ /** One fold onto the head: protocol 2's observe, one batch, one instant.
349
+ *
350
+ * COMPLETENESS IS PER ACCOUNT: the pull observes ONE account, so the tombstone sweep it arms
351
+ * must reach only that account's rows. The kernel's `complete` is type-level — it tombstones
352
+ * every subject of the type the batch does not hold — so folding account B's listing with
353
+ * `complete: ['deployment', …]` erased account A's rows (the sweep never saw them in the
354
+ * batch). The pack therefore expresses completeness itself, the way the kernel's explicit
355
+ * tombstones allow: every row the projection holds for THIS account's collections that the
356
+ * listing did not name is observed as `{deleted: true}` in the same batch, and NO type-level
357
+ * `complete` is armed. A foreign account's rows are invisible to the sweep by construction,
358
+ * and a vanished resource still gets its tombstone. */
359
+ function fold(resources, opts) {
360
+ const at = opts.occurredAt ?? DEFAULT_OCCURRED_AT;
361
+ const observed = resources.map((r) => ({ type: r.type, id: r.id, fields: r.fields }));
362
+ const seen = new Set(observed.map((r) => `${r.type}:${r.id}`));
363
+ for (const row of projectResources(SERVICE, opts.root)) {
364
+ if (!PULL_TYPES.has(row.type))
365
+ continue;
366
+ if (row._account !== opts.accountId)
367
+ continue; // another account's row — never this sweep's
368
+ if (seen.has(`${row.type}:${row.id}`))
369
+ continue;
370
+ if (row.deleted === true)
371
+ continue; // already tombstoned
372
+ observed.push({ type: row.type, id: row.id, fields: { deleted: true } });
373
+ }
374
+ return observeResources(SERVICE, observed, {
375
+ ...(opts.root !== undefined ? { root: opts.root } : {}), at, batch: `obs:fireworks:${at}`,
376
+ });
377
+ }
378
+ /** The collection types a pull enumerates — the ones completeness is expressed over. */
379
+ const PULL_TYPES = new Set(PULL_COLLECTIONS.map((c) => c.type));
380
+ /**
381
+ * D7 consumer-facing pull entry point: gather all Fireworks control-plane read domains and fold
382
+ * them into the twin through a SINGLE observed batch, returning the standard result shape.
383
+ * Idempotent: a re-pull of identical state appends no new deltas.
384
+ */
385
+ export async function syncFireworksFromReal(execute, opts = { accountId: '' }) {
386
+ const resources = await pullFireworksState(execute, opts.accountId);
387
+ const result = fold(resources, opts);
388
+ return { observed: result.observed, deltasAppended: result.appended };
389
+ }
390
+ // ── PUSH: twin → real ───────────────────────────────────────────────────────────────────────
391
+ /**
392
+ * The gateway create ids are QUERY params (deployments: deploymentId, users: userId) or body
393
+ * fields (datasets), per the vendor's own spec — not path segments. A body field is carried
394
+ * through the body; a query param is appended here.
395
+ */
396
+ function createQueryString(type, id) {
397
+ if (type === 'deployment' || type === 'user' || type === 'batchInferenceJob' || type === 'supervisedFineTuningJob') {
398
+ return `?${type}Id=${encodeURIComponent(id)}`;
399
+ }
400
+ return '';
401
+ }
402
+ /** Map a subject type → its Gateway REST collection segment. */
403
+ const COLLECTION_SEGMENT = {
404
+ deployment: 'deployments',
405
+ dataset: 'datasets',
406
+ batchInferenceJob: 'batchInferenceJobs',
407
+ supervisedFineTuningJob: 'supervisedFineTuningJobs',
408
+ user: 'users',
409
+ model: 'models',
410
+ secret: 'secrets',
411
+ };
412
+ /** The twin's LOCALLY-MINTED id namespace (see `nextId` in fireworks-twin.ts). A subject still
413
+ * bearing one has no counterpart in the real account. The `_twin_` infix is anchored so a PULLED
414
+ * vendor id can never match. */
415
+ const LOCAL_ID = /_twin_\d+$/;
416
+ /** The BARE id a vendor path segment carries: kernel subjects are account-namespaced
417
+ * (`{account}/{id}` — see `namespacedId`), and the Gateway REST path addresses the resource by
418
+ * the id segment alone. A bare id (a connector-verify action written directly) passes through. */
419
+ function bareId(subjectId) {
420
+ return subjectId.includes('/') ? subjectId.slice(subjectId.indexOf('/') + 1) : subjectId;
421
+ }
422
+ /** Why this operation cannot be pushed to the real vendor, or null if it can. Pure — no vendor
423
+ * call on its path. Inference operations are the big class: replaying a completion at the vendor
424
+ * spends real money and stores nothing, so a push of one is a category error, not a gap. */
425
+ export function unpushableReason(op) {
426
+ if (op.startsWith('response.') || op === 'chat.completion' || op === 'completion') {
427
+ return 'inference is generative and stores nothing — replaying it at the vendor would spend real money for no stored state; the twin records it locally only';
428
+ }
429
+ if (op.endsWith('.cancel') || op.endsWith('.resume') || op.endsWith('.undelete') || op.endsWith('.scale')) {
430
+ return `the custom verb '${op.slice(op.indexOf('.') + 1)}' has no push mapping in this pack — the twin models it for wire fidelity, the connector replays only create/delete`;
431
+ }
432
+ return null;
433
+ }
434
+ /**
435
+ * The REST (method, path, body) that CONFIRMS one local action against the real Gateway REST
436
+ * surface. Create ids go where the vendor's spec puts them (query params for deployments/users,
437
+ * the body for the rest). A locally-minted id on anything but a create is REFUSED — the twin's
438
+ * own mint is not an address the vendor knows.
439
+ */
440
+ export function fireworksRequestForAction(action, accountId, opts = {}) {
441
+ const op = action.operation ?? `${action.subject.type}.update`;
442
+ const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
443
+ const segment = COLLECTION_SEGMENT[action.subject.type];
444
+ if (!segment)
445
+ throw new Error(`fireworks push: no REST collection for subject type '${action.subject.type}'`);
446
+ const fields = (action.fields ?? {});
447
+ if (verb === 'create') {
448
+ const id = bareId(action.subject.id);
449
+ const path = `/v1/accounts/${encodeURIComponent(accountId)}/${segment}${createQueryString(action.subject.type, id)}`;
450
+ // The body IS the resource for deployments, jobs, users and secrets (the vendor's spec takes
451
+ // the resource directly); datasets and models wrap ({dataset, datasetId} / {model, modelId}).
452
+ if (action.subject.type === 'dataset')
453
+ return { method: 'POST', path, body: { dataset: fields, datasetId: id } };
454
+ if (action.subject.type === 'model')
455
+ return { method: 'POST', path, body: { model: fields, modelId: id } };
456
+ return { method: 'POST', path, body: fields };
457
+ }
458
+ if (verb === 'delete') {
459
+ const localId = bareId(action.subject.id);
460
+ const vendorId = !LOCAL_ID.test(localId) ? localId
461
+ : opts.externalId ?? null;
462
+ if (vendorId === null) {
463
+ throw new Error(`fireworks push: refusing to address the real account by the twin's own id '${localId}' — no vendor id was ever recorded for this subject`);
464
+ }
465
+ return { method: 'DELETE', path: `/v1/accounts/${encodeURIComponent(accountId)}/${segment}/${encodeURIComponent(vendorId)}` };
466
+ }
467
+ throw new Error(`fireworks push: unsupported operation '${op}' — refusing to silently drop a local write`);
468
+ }
469
+ /**
470
+ * Push ONE pending action to REAL Fireworks via the injected executor. Returns the real external
471
+ * id (the vendor's own id from the create response's `name`, or the addressed id on delete).
472
+ * WRITES TO THE REAL ACCOUNT.
473
+ */
474
+ export async function pushFireworksAction(execute, action, accountId, opts = {}) {
475
+ const req = fireworksRequestForAction(action, accountId, opts);
476
+ const res = await execute(req.method, req.path, req.body);
477
+ if (res.status < 200 || res.status >= 300) {
478
+ throw new Error(`fireworks push ${action.operation} refused: HTTP ${res.status} ${JSON.stringify(res.data).slice(0, 200)}`);
479
+ }
480
+ const data = res.data;
481
+ // The vendor's create response carries the resource with its `name`
482
+ // (accounts/{account}/<plural>/<id>); the id segment is the external id. A create response
483
+ // without one is REFUSED, never guessed: falling back to the twin's own mint would record a
484
+ // vendor address the vendor never issued. (A delete answers `{}` — its external id is the
485
+ // address it just acted on.)
486
+ const op = action.operation ?? `${action.subject.type}.update`;
487
+ const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
488
+ const name = typeof data?.name === 'string' ? data.name : '';
489
+ const externalId = name.split('/').pop() || opts.externalId;
490
+ if (verb === 'create' && !externalId) {
491
+ throw new Error(`fireworks push ${op}: the vendor response named no resource — refusing to record the twin's own id '${action.subject.id}' as the external id`);
492
+ }
493
+ return { externalId: externalId || action.subject.id };
494
+ }
495
+ /**
496
+ * Push the twin's PENDING local actions to real Fireworks and CONFIRM each. Idempotent: a
497
+ * confirmed action is no longer pending, so a re-push enacts NOTHING.
498
+ *
499
+ * An action this connector cannot faithfully push is SKIPPED AND REPORTED, never confirmed and
500
+ * never silently dropped: it stays pending and is named in `refused`. A create's real id is
501
+ * recorded on the resource as `_external_id` at confirm time, so a later delete addresses the
502
+ * VENDOR by it — never the twin's own mint.
503
+ */
504
+ export async function pushPendingFireworksActions(execute, opts) {
505
+ const confirmed = [];
506
+ const externalIds = {};
507
+ const refused = [];
508
+ const at = opts.occurredAt ?? new Date().toISOString();
509
+ for (const action of deployableEntries(SERVICE, opts.root)) {
510
+ const op = action.operation ?? `${action.subject.type}.update`;
511
+ const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
512
+ if (verb !== 'create' && verb !== 'delete') {
513
+ refused.push({ actionId: action.id, operation: op, reason: unpushableReason(op) ?? `unsupported operation '${op}'` });
514
+ continue;
515
+ }
516
+ const why = unpushableReason(op);
517
+ if (why) {
518
+ refused.push({ actionId: action.id, operation: op, reason: why });
519
+ continue;
520
+ }
521
+ // Anything but a create must address the vendor by the id the vendor issued. A locally-minted
522
+ // subject with none recorded is REFUSED, never guessed.
523
+ let external;
524
+ if (verb !== 'create' && LOCAL_ID.test(bareId(action.subject.id))) {
525
+ const row = projectResources(SERVICE, opts.root).find((r) => r.type === action.subject.type && r.id === action.subject.id);
526
+ const ext = row?._external_id;
527
+ if (typeof ext !== 'string' || !ext) {
528
+ 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}` });
529
+ continue;
530
+ }
531
+ external = ext;
532
+ }
533
+ let externalId;
534
+ try {
535
+ ({ externalId } = await pushFireworksAction(execute, action, opts.accountId, ...(external !== undefined ? [{ externalId: external }] : [])));
536
+ }
537
+ catch (err) {
538
+ // A push that cannot be confirmed faithfully (a vendor response that names no resource, a
539
+ // locally-minted delete address) is REPORTED and left pending — the sweep continues.
540
+ refused.push({ actionId: action.id, operation: op, reason: err instanceof Error ? err.message : String(err) });
541
+ continue;
542
+ }
543
+ // Record the id the vendor issued ON the resource, so a later delete can address it.
544
+ confirmAction({
545
+ service: SERVICE, actionId: action.id, subject: action.subject,
546
+ fields: { ...(action.fields ?? {}), ...(verb === 'create' ? { _external_id: externalId } : {}) },
547
+ occurredAt: at, ...(opts.root !== undefined ? { root: opts.root } : {}),
548
+ });
549
+ confirmed.push(action.id);
550
+ externalIds[action.id] = externalId;
551
+ }
552
+ return { pushed: confirmed.length, confirmed, externalIds, refused };
553
+ }
554
+ // ── FULL bi-directional sync ────────────────────────────────────────────────────────────────
555
+ /**
556
+ * FULL bi-directional sync over the injected client: (1) PUSH every pending local action to real
557
+ * Fireworks and confirm it, then (2) PULL the control-plane collections back and fold them into
558
+ * the event log. Pushing first means the pull observes the twin's own writes as confirmed external
559
+ * state (no double-count). Re-running with no pending writes and identical real state is a no-op.
560
+ */
561
+ export async function fullSyncFireworks(execute, opts) {
562
+ const push = await pushPendingFireworksActions(execute, opts);
563
+ const resources = await pullFireworksState(execute, opts.accountId);
564
+ const pull = fold(resources, opts);
565
+ return { pushed: push.pushed, observed: pull.observed, deltasAppended: pull.appended, collections: PULL_COLLECTIONS.length, refused: push.refused };
566
+ }
567
+ // ── PROTOCOL 2: the pack's half of the real state system ────────────────────────────────────
568
+ /** The one HTTP call every adapter below makes, over the kernel's executor. At a REAL boundary the
569
+ * kernel sets the sealed credential over these headers (executor.ts); at the twin's own wire any
570
+ * credential is one. */
571
+ function fireworksSend(execute) {
572
+ return async (method, path, body) => {
573
+ const res = await execute({
574
+ method, path,
575
+ headers: { accept: 'application/json', 'content-type': 'application/json', authorization: 'Bearer fireworks_twin_bootstrap_token' },
576
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
577
+ });
578
+ let parsed = {};
579
+ try {
580
+ parsed = JSON.parse(res.body || '{}');
581
+ }
582
+ catch {
583
+ parsed = {};
584
+ }
585
+ return { status: res.status, data: parsed };
586
+ };
587
+ }
588
+ /** A `FireworksExecute` over the kernel's executor — the adapter seam protocol 2 fixes. */
589
+ export function fireworksExecuteOver(execute) {
590
+ return fireworksSend(execute);
591
+ }
592
+ /** The refresh adapter: pull the account's control-plane collections. `origin` is unused —
593
+ * Fireworks' surface is single-host, so the executor's own base URL is the address. */
594
+ export async function syncFireworksFromRemote(execute, opts = {}) {
595
+ return syncFireworksFromReal(fireworksExecuteOver(execute), {
596
+ accountId: opts.accountId ?? FIREWORKS_ACCOUNT_ID,
597
+ ...(opts.root !== undefined ? { root: opts.root } : {}),
598
+ ...(opts.occurredAt !== undefined ? { occurredAt: opts.occurredAt } : {}),
599
+ });
600
+ }
601
+ /**
602
+ * The perform adapter — the head's executor for a local write at a REAL boundary. Create/delete
603
+ * replay against the Gateway REST surface through `ctx.resolve` (a locally minted subject is
604
+ * addressed by the vendor id the projection recorded, never by the mint). Inference operations and
605
+ * custom verbs are NOT performed: they are the twin's own record, and the outcome says so.
606
+ */
607
+ export async function performFireworksAction(execute, action, ctx) {
608
+ const op = action.operation ?? `${action.subject.type}.update`;
609
+ const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
610
+ if (verb !== 'create' && verb !== 'delete') {
611
+ return { externalId: action.subject.id, data: { performed: false, reason: `${op} is the twin's own record — ${unpushableReason(op) ?? 'the connector does not replay it'}` } };
612
+ }
613
+ // The account rides ON THE SUBJECT: kernel subject ids are account-namespaced
614
+ // (`{account}/{id}` — the same grammar the create/pull paths write), so the perform path
615
+ // derives the Gateway account from the subject's own namespace. Hardcoding the pack constant
616
+ // here made a perform of any OTHER account's subject address /v1/accounts/my-account/… — the
617
+ // wrong tenant's REST surface. A bare subject id (a connector-verify action written directly)
618
+ // falls back to the pack's default account.
619
+ const accountId = action.subject.id.includes('/') ? action.subject.id.slice(0, action.subject.id.indexOf('/')) : FIREWORKS_ACCOUNT_ID;
620
+ const row = projectResources(SERVICE, ctx.root).find((r) => r.type === action.subject.type && r.id === action.subject.id);
621
+ const recordedExternal = typeof row?._external_id === 'string' ? row._external_id : undefined;
622
+ const bare = bareId(action.subject.id);
623
+ const vendorId = verb === 'create'
624
+ ? bare
625
+ : (!LOCAL_ID.test(bare) ? bare : recordedExternal ?? null);
626
+ if (vendorId === null) {
627
+ throw new Error(`fireworks perform ${op} refused: the subject '${action.subject.id}' was never pushed, so the vendor holds no row to ${verb}`);
628
+ }
629
+ const req = fireworksRequestForAction(action, accountId, ...(vendorId !== action.subject.id ? [{ externalId: vendorId }] : []));
630
+ const res = await fireworksExecuteOver(execute)(req.method, req.path, req.body);
631
+ if (res.status < 200 || res.status >= 300) {
632
+ throw new Error(`fireworks perform ${op} refused: HTTP ${res.status} ${JSON.stringify(res.data).slice(0, 200)}`);
633
+ }
634
+ const data = res.data;
635
+ const name = typeof data?.name === 'string' ? data.name : '';
636
+ const externalId = name.split('/').pop() || vendorId;
637
+ return { externalId, data: (data ?? {}) };
638
+ }
639
+ // The ledger path is re-exported so an operator can find the spend file without importing the
640
+ // budget module separately.
641
+ export { fireworksBudgetPath };
@@ -0,0 +1,11 @@
1
+ /** One catalog row: the id the inference plane accepts. */
2
+ export type FireworksCatalogModel = {
3
+ id: string;
4
+ /** Which inference endpoint families accept it. */
5
+ kind: 'chat' | 'embeddings' | 'rerank';
6
+ };
7
+ export declare const FIREWORKS_MODELS: FireworksCatalogModel[];
8
+ /** The static ids, for the refusal check. */
9
+ export declare const FIREWORKS_MODEL_IDS: Set<string>;
10
+ /** Which endpoint family a static model id belongs to (undefined = not a chat id). */
11
+ export declare function catalogKind(id: string): FireworksCatalogModel['kind'] | undefined;