@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,14 @@
1
+ export type ConformanceViolation = {
2
+ check: string;
3
+ detail: string;
4
+ };
5
+ export type FireworksConformanceReport = {
6
+ ok: boolean;
7
+ checksRun: number;
8
+ probes: number;
9
+ violations: ConformanceViolation[];
10
+ };
11
+ /** Run the offline conformance checks against a fresh temp root. */
12
+ export declare function checkFireworksConformance(opts?: {
13
+ root?: string;
14
+ }): Promise<FireworksConformanceReport>;
@@ -0,0 +1,514 @@
1
+ // Fireworks twin conformance — an offline check with REAL TEETH: delete a handler branch and it
2
+ // goes RED. The harness does not compare two constants (the false-green ADDING_A_TWIN.md §6
3
+ // documents). Instead it runs THREE passes:
4
+ //
5
+ // 1. PROBES — one real request per CLAIMED endpoint, graded on the OUTCOME a live handler
6
+ // produces: an expected status set PLUS a predicate over the body. A handler that returned
7
+ // `{}`, or whose branch was deleted (so the router falls through to its not-found), fails.
8
+ // 2. BIJECTION — the probe table and the claimed-endpoint snapshot must match two ways, so a
9
+ // claim with no probe and a probe with no claim are both RED.
10
+ // 3. ROUTER_SURFACE — a HAND-AUTHORED census of every method/path pair `routeFireworks`
11
+ // branches on, INCLUDING the generic single-resource branch (the `rest.length === 2`
12
+ // GET/PATCH/DELETE arms serve ALL seven collections, so the census names each one). Every
13
+ // entry must be claimed; and the paths Fireworks serves but this twin does NOT model must
14
+ // answer the vendor-shaped not-found envelope. This closes the served-but-unclaimed
15
+ // direction that probe⇄claim alone is blind to.
16
+ //
17
+ // Fully offline + deterministic (drives the local handler against a temp root) so it runs in CI
18
+ // without an API key. Honest scope: it checks the protocol envelope, NOT model output (a
19
+ // deterministic labeled stub by design).
20
+ import { mkdtempSync, rmSync } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ import { handleFireworksTwinRequest } from "./fireworks-twin.js";
24
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
25
+ const MODEL = 'accounts/fireworks/models/kimi-k2-instruct';
26
+ const CHAT = { model: MODEL, messages: [{ role: 'user', content: 'hi' }] };
27
+ const ACCOUNT = '/v1/accounts/my-account';
28
+ const PROBES = [
29
+ {
30
+ key: 'POST /inference/v1/chat/completions',
31
+ method: 'POST', path: '/inference/v1/chat/completions', body: CHAT, statuses: [200],
32
+ ok: (b) => isObj(b) && b.object === 'chat.completion' && Array.isArray(b.choices)
33
+ && b.choices[0]?.message?.role === 'assistant'
34
+ && typeof b.choices[0]?.message?.content === 'string'
35
+ && isObj(b.usage) && typeof b.usage.prompt_tokens === 'number',
36
+ },
37
+ {
38
+ key: 'POST /inference/v1/completions',
39
+ method: 'POST', path: '/inference/v1/completions', body: { model: MODEL, prompt: 'hi' }, statuses: [200],
40
+ ok: (b) => isObj(b) && b.object === 'text_completion' && Array.isArray(b.choices) && isObj(b.usage) && typeof b.usage.total_tokens === 'number',
41
+ },
42
+ {
43
+ key: 'POST /inference/v1/responses',
44
+ method: 'POST', path: '/inference/v1/responses', body: { model: MODEL, input: 'hi' }, statuses: [200],
45
+ ok: (b) => isObj(b) && b.object === 'response' && b.status === 'completed' && Array.isArray(b.output) && typeof b.id === 'string',
46
+ },
47
+ {
48
+ key: 'GET /inference/v1/responses',
49
+ method: 'GET', path: '/inference/v1/responses', statuses: [200],
50
+ ok: (b) => isObj(b) && b.object === 'list' && Array.isArray(b.data) && typeof b.has_more === 'boolean',
51
+ },
52
+ {
53
+ key: 'GET /inference/v1/responses/{response_id}',
54
+ method: 'GET', path: '/inference/v1/responses/resp_twin_jw3ibm', statuses: [404],
55
+ ok: (b) => isObj(b) && isObj(b.error) && typeof b.error.message === 'string',
56
+ },
57
+ {
58
+ key: 'DELETE /inference/v1/responses/{response_id}',
59
+ method: 'DELETE', path: '/inference/v1/responses/resp_twin_jw3ibm', statuses: [200],
60
+ seed: async (h) => { await h('POST', '/inference/v1/responses', { model: MODEL, input: 'hi' }); },
61
+ ok: (b) => isObj(b) && b.deleted === true && b.id === 'resp_twin_jw3ibm',
62
+ },
63
+ {
64
+ key: 'POST /inference/v1/messages',
65
+ method: 'POST', path: '/inference/v1/messages', body: CHAT, statuses: [200],
66
+ ok: (b) => isObj(b) && b.type === 'message' && b.role === 'assistant' && Array.isArray(b.content)
67
+ && b.content[0]?.type === 'text' && typeof b.stop_reason === 'string' && isObj(b.usage),
68
+ },
69
+ {
70
+ key: 'POST /inference/v1/embeddings',
71
+ method: 'POST', path: '/inference/v1/embeddings', body: { model: 'nomic-ai/nomic-embed-text-v1.5', input: 'hi' }, statuses: [200],
72
+ ok: (b) => isObj(b) && b.object === 'list' && Array.isArray(b.data) && b.data[0]?.object === 'embedding' && Array.isArray(b.data[0]?.embedding) && isObj(b.usage),
73
+ },
74
+ {
75
+ key: 'POST /inference/v1/rerank',
76
+ method: 'POST', path: '/inference/v1/rerank', body: { query: 'q', documents: ['a', 'b'] }, statuses: [200],
77
+ ok: (b) => isObj(b) && b.object === 'list' && Array.isArray(b.data) && b.data.every((d) => typeof d.relevance_score === 'number') && isObj(b.usage),
78
+ },
79
+ {
80
+ key: 'POST /v1/accounts/{account_id}/deployments',
81
+ method: 'POST', path: `${ACCOUNT}/deployments?deploymentId=conf-dep`, body: { baseModel: 'accounts/fireworks/models/llama-v3p1-8b-instruct' }, statuses: [200],
82
+ ok: (b) => isObj(b) && b.name === 'accounts/my-account/deployments/conf-dep' && b.state === 'CREATING' && isObj(b.status) && b.status.code === 'OK',
83
+ },
84
+ {
85
+ key: 'GET /v1/accounts/{account_id}/deployments',
86
+ method: 'GET', path: `${ACCOUNT}/deployments?pageSize=100`, statuses: [200],
87
+ seed: async (h) => { await h('POST', `${ACCOUNT}/deployments?deploymentId=conf-dep`, { baseModel: 'accounts/fireworks/models/llama-v3p1-8b-instruct' }); },
88
+ ok: (b) => isObj(b) && Array.isArray(b.deployments) && b.deployments.some((d) => d.name === 'accounts/my-account/deployments/conf-dep') && typeof b.nextPageToken !== 'undefined' && b.totalSize >= 1,
89
+ },
90
+ {
91
+ key: 'GET /v1/accounts/{account_id}/deployments/{deployment_id}',
92
+ method: 'GET', path: `${ACCOUNT}/deployments/conf-dep`, statuses: [200],
93
+ seed: async (h) => { await h('POST', `${ACCOUNT}/deployments?deploymentId=conf-dep`, { baseModel: 'accounts/fireworks/models/llama-v3p1-8b-instruct' }); },
94
+ ok: (b) => isObj(b) && b.name === 'accounts/my-account/deployments/conf-dep' && typeof b.baseModel === 'string',
95
+ },
96
+ {
97
+ // The account segment is part of the address: the same id under ANOTHER account's path must
98
+ // answer the vendor's own NOT_FOUND (tenancy), never the other account's row.
99
+ key: 'GET /v1/accounts/{account_id}/deployments/{deployment_id} (cross-account 404)',
100
+ method: 'GET', path: '/v1/accounts/other-account/deployments/conf-dep', statuses: [404],
101
+ seed: async (h) => { await h('POST', `${ACCOUNT}/deployments?deploymentId=conf-dep`, { baseModel: 'accounts/fireworks/models/llama-v3p1-8b-instruct' }); },
102
+ ok: (b) => isObj(b) && b.code === 'NOT_FOUND' && typeof b.message === 'string',
103
+ },
104
+ {
105
+ key: 'DELETE /v1/accounts/{account_id}/deployments/{deployment_id}',
106
+ method: 'DELETE', path: `${ACCOUNT}/deployments/conf-dep`, statuses: [200],
107
+ seed: async (h) => { await h('POST', `${ACCOUNT}/deployments?deploymentId=conf-dep`, { baseModel: 'accounts/fireworks/models/llama-v3p1-8b-instruct' }); },
108
+ ok: (b) => isObj(b) && Object.keys(b).length === 0, // the vendor's empty-object delete
109
+ },
110
+ {
111
+ key: 'PATCH /v1/accounts/{account_id}/deployments/{deployment_id}',
112
+ method: 'PATCH', path: `${ACCOUNT}/deployments/conf-dep`, body: { displayName: 'patched' }, statuses: [200],
113
+ seed: async (h) => { await h('POST', `${ACCOUNT}/deployments?deploymentId=conf-dep`, { baseModel: 'accounts/fireworks/models/llama-v3p1-8b-instruct' }); },
114
+ ok: (b) => isObj(b) && b.displayName === 'patched' && b.name === 'accounts/my-account/deployments/conf-dep',
115
+ },
116
+ {
117
+ // the spec's own spelling: PATCH …/{deployment_id}:scale with {replicaCount}, answering `{}`
118
+ key: 'PATCH /v1/accounts/{account_id}/deployments/{deployment_id}:scale',
119
+ method: 'PATCH', path: `${ACCOUNT}/deployments/conf-dep:scale`, body: { replicaCount: 2 }, statuses: [200],
120
+ seed: async (h) => { await h('POST', `${ACCOUNT}/deployments?deploymentId=conf-dep`, { baseModel: 'accounts/fireworks/models/llama-v3p1-8b-instruct' }); },
121
+ ok: (b) => isObj(b) && Object.keys(b).length === 0,
122
+ },
123
+ {
124
+ key: 'POST /v1/accounts/{account_id}/datasets',
125
+ method: 'POST', path: `${ACCOUNT}/datasets`, body: { dataset: { displayName: 'conf ds' }, datasetId: 'conf-ds' }, statuses: [200],
126
+ ok: (b) => isObj(b) && b.name === 'accounts/my-account/datasets/conf-ds' && b.state === 'UPLOADING',
127
+ },
128
+ {
129
+ key: 'GET /v1/accounts/{account_id}/datasets',
130
+ method: 'GET', path: `${ACCOUNT}/datasets?pageSize=100`, statuses: [200],
131
+ seed: async (h) => { await h('POST', `${ACCOUNT}/datasets`, { dataset: { displayName: 'conf ds' }, datasetId: 'conf-ds' }); },
132
+ ok: (b) => isObj(b) && Array.isArray(b.datasets) && b.datasets.some((d) => d.name === 'accounts/my-account/datasets/conf-ds') && typeof b.nextPageToken !== 'undefined',
133
+ },
134
+ {
135
+ key: 'GET /v1/accounts/{account_id}/datasets/{dataset_id}',
136
+ method: 'GET', path: `${ACCOUNT}/datasets/conf-ds`, statuses: [200],
137
+ seed: async (h) => { await h('POST', `${ACCOUNT}/datasets`, { dataset: { displayName: 'conf ds' }, datasetId: 'conf-ds' }); },
138
+ ok: (b) => isObj(b) && b.name === 'accounts/my-account/datasets/conf-ds' && b.state === 'UPLOADING',
139
+ },
140
+ {
141
+ // Gateway_UpdateDataset: a mutable field lands; the response carries the patched value.
142
+ key: 'PATCH /v1/accounts/{account_id}/datasets/{dataset_id}',
143
+ method: 'PATCH', path: `${ACCOUNT}/datasets/conf-ds`, body: { displayName: 'patched-ds' }, statuses: [200],
144
+ seed: async (h) => { await h('POST', `${ACCOUNT}/datasets`, { dataset: { displayName: 'conf ds' }, datasetId: 'conf-ds' }); },
145
+ ok: (b) => isObj(b) && b.displayName === 'patched-ds' && b.name === 'accounts/my-account/datasets/conf-ds',
146
+ },
147
+ {
148
+ key: 'DELETE /v1/accounts/{account_id}/datasets/{dataset_id}',
149
+ method: 'DELETE', path: `${ACCOUNT}/datasets/conf-ds`, statuses: [200],
150
+ seed: async (h) => { await h('POST', `${ACCOUNT}/datasets`, { dataset: { displayName: 'conf ds' }, datasetId: 'conf-ds' }); },
151
+ ok: (b) => isObj(b) && Object.keys(b).length === 0,
152
+ },
153
+ {
154
+ key: 'POST /v1/accounts/{account_id}/batchInferenceJobs',
155
+ method: 'POST', path: `${ACCOUNT}/batchInferenceJobs?batchInferenceJobId=conf-job`, body: { model: MODEL, inputDatasetId: 'conf-ds' }, statuses: [200],
156
+ seed: async (h) => { await h('POST', `${ACCOUNT}/datasets`, { dataset: { displayName: 'conf ds' }, datasetId: 'conf-ds' }); },
157
+ ok: (b) => isObj(b) && b.name === 'accounts/my-account/batchInferenceJobs/conf-job' && b.state === 'JOB_STATE_CREATING',
158
+ },
159
+ {
160
+ key: 'GET /v1/accounts/{account_id}/batchInferenceJobs',
161
+ method: 'GET', path: `${ACCOUNT}/batchInferenceJobs?pageSize=100`, statuses: [200],
162
+ seed: async (h) => {
163
+ await h('POST', `${ACCOUNT}/datasets`, { dataset: { displayName: 'conf ds' }, datasetId: 'conf-ds' });
164
+ await h('POST', `${ACCOUNT}/batchInferenceJobs?batchInferenceJobId=conf-job`, { model: MODEL, inputDatasetId: 'conf-ds' });
165
+ },
166
+ ok: (b) => isObj(b) && Array.isArray(b.batchInferenceJobs) && b.batchInferenceJobs.some((j) => j.name === 'accounts/my-account/batchInferenceJobs/conf-job') && typeof b.nextPageToken !== 'undefined',
167
+ },
168
+ {
169
+ key: 'GET /v1/accounts/{account_id}/batchInferenceJobs/{job_id}',
170
+ method: 'GET', path: `${ACCOUNT}/batchInferenceJobs/conf-job`, statuses: [200],
171
+ seed: async (h) => {
172
+ await h('POST', `${ACCOUNT}/datasets`, { dataset: { displayName: 'conf ds' }, datasetId: 'conf-ds' });
173
+ await h('POST', `${ACCOUNT}/batchInferenceJobs?batchInferenceJobId=conf-job`, { model: MODEL, inputDatasetId: 'conf-ds' });
174
+ },
175
+ ok: (b) => isObj(b) && b.name === 'accounts/my-account/batchInferenceJobs/conf-job' && b.state === 'JOB_STATE_CREATING',
176
+ },
177
+ {
178
+ key: 'DELETE /v1/accounts/{account_id}/batchInferenceJobs/{job_id}',
179
+ method: 'DELETE', path: `${ACCOUNT}/batchInferenceJobs/conf-job`, statuses: [200],
180
+ seed: async (h) => {
181
+ await h('POST', `${ACCOUNT}/datasets`, { dataset: { displayName: 'conf ds' }, datasetId: 'conf-ds' });
182
+ await h('POST', `${ACCOUNT}/batchInferenceJobs?batchInferenceJobId=conf-job`, { model: MODEL, inputDatasetId: 'conf-ds' });
183
+ },
184
+ ok: (b) => isObj(b) && Object.keys(b).length === 0,
185
+ },
186
+ {
187
+ key: 'POST /v1/accounts/{account_id}/batchInferenceJobs/{job_id}:cancel',
188
+ method: 'POST', path: `${ACCOUNT}/batchInferenceJobs/conf-job:cancel`, statuses: [200],
189
+ seed: async (h) => {
190
+ await h('POST', `${ACCOUNT}/datasets`, { dataset: { displayName: 'conf ds' }, datasetId: 'conf-ds' });
191
+ await h('POST', `${ACCOUNT}/batchInferenceJobs?batchInferenceJobId=conf-job`, { model: MODEL, inputDatasetId: 'conf-ds' });
192
+ },
193
+ ok: (b) => isObj(b) && Object.keys(b).length === 0,
194
+ },
195
+ {
196
+ key: 'POST /v1/accounts/{account_id}/supervisedFineTuningJobs',
197
+ method: 'POST', path: `${ACCOUNT}/supervisedFineTuningJobs?supervisedFineTuningJobId=conf-sft`, body: { dataset: 'accounts/my-account/datasets/conf-ds', baseModel: MODEL }, statuses: [200],
198
+ seed: async (h) => { await h('POST', `${ACCOUNT}/datasets`, { dataset: { displayName: 'conf ds' }, datasetId: 'conf-ds' }); },
199
+ ok: (b) => isObj(b) && b.name === 'accounts/my-account/supervisedFineTuningJobs/conf-sft' && b.state === 'JOB_STATE_CREATING',
200
+ },
201
+ {
202
+ key: 'GET /v1/accounts/{account_id}/supervisedFineTuningJobs',
203
+ method: 'GET', path: `${ACCOUNT}/supervisedFineTuningJobs?pageSize=100`, statuses: [200],
204
+ seed: async (h) => {
205
+ await h('POST', `${ACCOUNT}/datasets`, { dataset: { displayName: 'conf ds' }, datasetId: 'conf-ds' });
206
+ await h('POST', `${ACCOUNT}/supervisedFineTuningJobs?supervisedFineTuningJobId=conf-sft`, { dataset: 'accounts/my-account/datasets/conf-ds', baseModel: MODEL });
207
+ },
208
+ ok: (b) => isObj(b) && Array.isArray(b.supervisedFineTuningJobs) && b.supervisedFineTuningJobs.some((j) => j.name === 'accounts/my-account/supervisedFineTuningJobs/conf-sft') && typeof b.nextPageToken !== 'undefined',
209
+ },
210
+ {
211
+ key: 'GET /v1/accounts/{account_id}/supervisedFineTuningJobs/{job_id}',
212
+ method: 'GET', path: `${ACCOUNT}/supervisedFineTuningJobs/conf-sft`, statuses: [200],
213
+ seed: async (h) => {
214
+ await h('POST', `${ACCOUNT}/datasets`, { dataset: { displayName: 'conf ds' }, datasetId: 'conf-ds' });
215
+ await h('POST', `${ACCOUNT}/supervisedFineTuningJobs?supervisedFineTuningJobId=conf-sft`, { dataset: 'accounts/my-account/datasets/conf-ds', baseModel: MODEL });
216
+ },
217
+ ok: (b) => isObj(b) && b.name === 'accounts/my-account/supervisedFineTuningJobs/conf-sft' && b.state === 'JOB_STATE_CREATING',
218
+ },
219
+ {
220
+ key: 'DELETE /v1/accounts/{account_id}/supervisedFineTuningJobs/{job_id}',
221
+ method: 'DELETE', path: `${ACCOUNT}/supervisedFineTuningJobs/conf-sft`, statuses: [200],
222
+ seed: async (h) => {
223
+ await h('POST', `${ACCOUNT}/datasets`, { dataset: { displayName: 'conf ds' }, datasetId: 'conf-ds' });
224
+ await h('POST', `${ACCOUNT}/supervisedFineTuningJobs?supervisedFineTuningJobId=conf-sft`, { dataset: 'accounts/my-account/datasets/conf-ds', baseModel: MODEL });
225
+ },
226
+ ok: (b) => isObj(b) && Object.keys(b).length === 0,
227
+ },
228
+ {
229
+ key: 'POST /v1/accounts/{account_id}/users',
230
+ method: 'POST', path: `${ACCOUNT}/users?userId=conf-user`, body: { role: 'admin' }, statuses: [200],
231
+ ok: (b) => isObj(b) && b.name === 'accounts/my-account/users/conf-user' && b.role === 'admin',
232
+ },
233
+ {
234
+ key: 'GET /v1/accounts/{account_id}/users',
235
+ method: 'GET', path: `${ACCOUNT}/users?pageSize=100`, statuses: [200],
236
+ seed: async (h) => { await h('POST', `${ACCOUNT}/users?userId=conf-user`, { role: 'admin' }); },
237
+ ok: (b) => isObj(b) && Array.isArray(b.users) && b.users.some((u) => u.name === 'accounts/my-account/users/conf-user') && typeof b.nextPageToken !== 'undefined',
238
+ },
239
+ {
240
+ key: 'GET /v1/accounts/{account_id}/users/{user_id}',
241
+ method: 'GET', path: `${ACCOUNT}/users/conf-user`, statuses: [200],
242
+ seed: async (h) => { await h('POST', `${ACCOUNT}/users?userId=conf-user`, { role: 'admin' }); },
243
+ ok: (b) => isObj(b) && b.name === 'accounts/my-account/users/conf-user' && b.role === 'admin',
244
+ },
245
+ {
246
+ key: 'PATCH /v1/accounts/{account_id}/users/{user_id}',
247
+ method: 'PATCH', path: `${ACCOUNT}/users/conf-user`, body: { displayName: 'patched' }, statuses: [200],
248
+ seed: async (h) => { await h('POST', `${ACCOUNT}/users?userId=conf-user`, { role: 'admin' }); },
249
+ ok: (b) => isObj(b) && b.displayName === 'patched' && b.name === 'accounts/my-account/users/conf-user',
250
+ },
251
+ {
252
+ key: 'POST /v1/accounts/{account_id}/users/{user_id}/apiKeys',
253
+ method: 'POST', path: `${ACCOUNT}/users/conf-user/apiKeys`, body: { apiKey: { displayName: 'conf key' } }, statuses: [200],
254
+ seed: async (h) => { await h('POST', `${ACCOUNT}/users?userId=conf-user`, { role: 'user' }); },
255
+ ok: (b) => isObj(b) && typeof b.key === 'string' && String(b.key).startsWith('fw_') && typeof b.keyId === 'string',
256
+ },
257
+ {
258
+ key: 'GET /v1/accounts/{account_id}/users/{user_id}/apiKeys',
259
+ method: 'GET', path: `${ACCOUNT}/users/conf-user/apiKeys`, statuses: [200],
260
+ seed: async (h) => {
261
+ await h('POST', `${ACCOUNT}/users?userId=conf-user`, { role: 'user' });
262
+ await h('POST', `${ACCOUNT}/users/conf-user/apiKeys`, { apiKey: { displayName: 'conf key' } });
263
+ },
264
+ // The list NEVER carries the full key — the custody rule holds on every read path.
265
+ ok: (b) => isObj(b) && Array.isArray(b.apiKeys) && b.apiKeys.length === 1 && b.apiKeys[0]?.key === undefined && typeof b.apiKeys[0]?.prefix === 'string',
266
+ },
267
+ {
268
+ key: 'GET /v1/accounts/{account_id}/users/{user_id}/apiKeys/{key_id}',
269
+ method: 'GET', path: `${ACCOUNT}/users/conf-user/apiKeys/key_twin_1`, statuses: [200],
270
+ seed: async (h) => {
271
+ await h('POST', `${ACCOUNT}/users?userId=conf-user`, { role: 'user' });
272
+ await h('POST', `${ACCOUNT}/users/conf-user/apiKeys`, { apiKey: { displayName: 'conf key' } });
273
+ },
274
+ ok: (b) => isObj(b) && b.key === undefined && typeof b.prefix === 'string',
275
+ },
276
+ {
277
+ key: 'PATCH /v1/accounts/{account_id}/users/{user_id}/apiKeys/{key_id}',
278
+ method: 'PATCH', path: `${ACCOUNT}/users/conf-user/apiKeys/key_twin_1`, body: { displayName: 'renamed' }, statuses: [200],
279
+ seed: async (h) => {
280
+ await h('POST', `${ACCOUNT}/users?userId=conf-user`, { role: 'user' });
281
+ await h('POST', `${ACCOUNT}/users/conf-user/apiKeys`, { apiKey: { displayName: 'conf key' } });
282
+ },
283
+ ok: (b) => isObj(b) && b.displayName === 'renamed' && b.key === undefined,
284
+ },
285
+ {
286
+ key: 'POST /v1/accounts/{account_id}/users/{user_id}/apiKeys/{key_id}:delete',
287
+ method: 'POST', path: `${ACCOUNT}/users/conf-user/apiKeys/key_twin_1:delete`, body: { keyId: 'key_twin_1' }, statuses: [200],
288
+ seed: async (h) => {
289
+ await h('POST', `${ACCOUNT}/users?userId=conf-user`, { role: 'user' });
290
+ await h('POST', `${ACCOUNT}/users/conf-user/apiKeys`, { apiKey: { displayName: 'conf key' } });
291
+ },
292
+ ok: (b) => isObj(b) && Object.keys(b).length === 0,
293
+ },
294
+ {
295
+ // the spec's own collection-level spelling: POST …/apiKeys:delete with {keyId} (Gateway_DeleteApiKey)
296
+ key: 'POST /v1/accounts/{account_id}/users/{user_id}/apiKeys:delete',
297
+ method: 'POST', path: `${ACCOUNT}/users/conf-user/apiKeys:delete`, body: { keyId: 'key_twin_1' }, statuses: [200],
298
+ seed: async (h) => {
299
+ await h('POST', `${ACCOUNT}/users?userId=conf-user`, { role: 'user' });
300
+ await h('POST', `${ACCOUNT}/users/conf-user/apiKeys`, { apiKey: { displayName: 'conf key 2' } });
301
+ },
302
+ ok: (b) => isObj(b) && Object.keys(b).length === 0,
303
+ },
304
+ {
305
+ key: 'POST /v1/accounts/{account_id}/secrets',
306
+ method: 'POST', path: `${ACCOUNT}/secrets`, body: { name: 'conf-secret', keyName: 'KEY', value: 'v' }, statuses: [200],
307
+ ok: (b) => isObj(b) && b.keyName === 'KEY' && b.value === undefined, // the value is never echoed on the read surface
308
+ },
309
+ {
310
+ key: 'GET /v1/accounts/{account_id}/secrets',
311
+ method: 'GET', path: `${ACCOUNT}/secrets?pageSize=100`, statuses: [200],
312
+ seed: async (h) => { await h('POST', `${ACCOUNT}/secrets`, { name: 'conf-secret', keyName: 'KEY', value: 'v' }); },
313
+ ok: (b) => isObj(b) && Array.isArray(b.secrets) && b.secrets.some((s) => s.name === 'accounts/my-account/secrets/conf-secret') && typeof b.nextPageToken !== 'undefined',
314
+ },
315
+ {
316
+ key: 'GET /v1/accounts/{account_id}/secrets/{secret_id}',
317
+ method: 'GET', path: `${ACCOUNT}/secrets/conf-secret`, statuses: [200],
318
+ seed: async (h) => { await h('POST', `${ACCOUNT}/secrets`, { name: 'conf-secret', keyName: 'KEY', value: 'v' }); },
319
+ ok: (b) => isObj(b) && b.name === 'accounts/my-account/secrets/conf-secret' && b.value === undefined,
320
+ },
321
+ {
322
+ // Gateway_UpdateSecret: keyName is the mutable field (value is INPUT_ONLY and never echoes).
323
+ key: 'PATCH /v1/accounts/{account_id}/secrets/{secret_id}',
324
+ method: 'PATCH', path: `${ACCOUNT}/secrets/conf-secret`, body: { keyName: 'KEY2' }, statuses: [200],
325
+ seed: async (h) => { await h('POST', `${ACCOUNT}/secrets`, { name: 'conf-secret', keyName: 'KEY', value: 'v' }); },
326
+ ok: (b) => isObj(b) && b.keyName === 'KEY2' && b.value === undefined,
327
+ },
328
+ {
329
+ key: 'DELETE /v1/accounts/{account_id}/secrets/{secret_id}',
330
+ method: 'DELETE', path: `${ACCOUNT}/secrets/conf-secret`, statuses: [200],
331
+ seed: async (h) => { await h('POST', `${ACCOUNT}/secrets`, { name: 'conf-secret', keyName: 'KEY', value: 'v' }); },
332
+ ok: (b) => isObj(b) && Object.keys(b).length === 0,
333
+ },
334
+ {
335
+ key: 'POST /v1/accounts/{account_id}/models',
336
+ method: 'POST', path: `${ACCOUNT}/models?modelId=conf-model`, body: { displayName: 'conf model' }, statuses: [200],
337
+ ok: (b) => isObj(b) && b.name === 'accounts/my-account/models/conf-model' && b.public === false,
338
+ },
339
+ {
340
+ key: 'GET /v1/accounts/{account_id}/models',
341
+ method: 'GET', path: `${ACCOUNT}/models?pageSize=100`, statuses: [200],
342
+ seed: async (h) => { await h('POST', `${ACCOUNT}/models?modelId=conf-model`, { displayName: 'conf model' }); },
343
+ ok: (b) => isObj(b) && Array.isArray(b.models) && b.models.some((m) => m.name === 'accounts/my-account/models/conf-model') && typeof b.nextPageToken !== 'undefined',
344
+ },
345
+ {
346
+ key: 'GET /v1/accounts/{account_id}/models/{model_id}',
347
+ method: 'GET', path: `${ACCOUNT}/models/conf-model`, statuses: [200],
348
+ seed: async (h) => { await h('POST', `${ACCOUNT}/models?modelId=conf-model`, { displayName: 'conf model' }); },
349
+ ok: (b) => isObj(b) && b.name === 'accounts/my-account/models/conf-model' && b.public === false,
350
+ },
351
+ {
352
+ key: 'PATCH /v1/accounts/{account_id}/models/{model_id}',
353
+ method: 'PATCH', path: `${ACCOUNT}/models/conf-model`, body: { displayName: 'patched' }, statuses: [200],
354
+ seed: async (h) => { await h('POST', `${ACCOUNT}/models?modelId=conf-model`, { displayName: 'conf model' }); },
355
+ ok: (b) => isObj(b) && b.displayName === 'patched' && b.name === 'accounts/my-account/models/conf-model',
356
+ },
357
+ {
358
+ key: 'DELETE /v1/accounts/{account_id}/models/{model_id}',
359
+ method: 'DELETE', path: `${ACCOUNT}/models/conf-model`, statuses: [200],
360
+ seed: async (h) => { await h('POST', `${ACCOUNT}/models?modelId=conf-model`, { displayName: 'conf model' }); },
361
+ ok: (b) => isObj(b) && Object.keys(b).length === 0,
362
+ },
363
+ ];
364
+ /** The literal the router falls through to when NO branch matched. A census entry answering this
365
+ * means its branch is gone. Kept as a LITERAL — importing the handler's string would be a
366
+ * tautology that could not catch the message drifting. */
367
+ const ROUTER_MISS = 'Unknown request URL';
368
+ const ROUTER_SURFACE = [
369
+ { key: 'POST /inference/v1/chat/completions', method: 'POST', path: '/inference/v1/chat/completions', body: CHAT },
370
+ { key: 'POST /inference/v1/completions', method: 'POST', path: '/inference/v1/completions', body: { model: MODEL, prompt: 'hi' } },
371
+ { key: 'POST /inference/v1/responses', method: 'POST', path: '/inference/v1/responses', body: { model: MODEL, input: 'hi' } },
372
+ { key: 'GET /inference/v1/responses', method: 'GET', path: '/inference/v1/responses' },
373
+ { key: 'GET /inference/v1/responses/{response_id}', method: 'GET', path: '/inference/v1/responses/resp_twin_jw3ibm' },
374
+ { key: 'DELETE /inference/v1/responses/{response_id}', method: 'DELETE', path: '/inference/v1/responses/resp_twin_jw3ibm' },
375
+ { key: 'POST /inference/v1/messages', method: 'POST', path: '/inference/v1/messages', body: CHAT },
376
+ { key: 'POST /inference/v1/embeddings', method: 'POST', path: '/inference/v1/embeddings', body: { model: 'nomic-ai/nomic-embed-text-v1.5', input: 'hi' } },
377
+ { key: 'POST /inference/v1/rerank', method: 'POST', path: '/inference/v1/rerank', body: { query: 'q', documents: ['a', 'b'] } },
378
+ { key: 'POST /v1/accounts/{account_id}/deployments', method: 'POST', path: `${ACCOUNT}/deployments?deploymentId=conf-dep`, body: { baseModel: 'accounts/fireworks/models/llama-v3p1-8b-instruct' } },
379
+ { key: 'GET /v1/accounts/{account_id}/deployments', method: 'GET', path: `${ACCOUNT}/deployments` },
380
+ { key: 'GET /v1/accounts/{account_id}/deployments/{deployment_id}', method: 'GET', path: `${ACCOUNT}/deployments/conf-dep` },
381
+ { key: 'GET /v1/accounts/{account_id}/deployments/{deployment_id} (cross-account 404)', method: 'GET', path: '/v1/accounts/other-account/deployments/conf-dep' },
382
+ { key: 'PATCH /v1/accounts/{account_id}/deployments/{deployment_id}', method: 'PATCH', path: `${ACCOUNT}/deployments/conf-dep`, body: { displayName: 'x' } },
383
+ { key: 'DELETE /v1/accounts/{account_id}/deployments/{deployment_id}', method: 'DELETE', path: `${ACCOUNT}/deployments/conf-dep` },
384
+ { key: 'POST /v1/accounts/{account_id}/datasets', method: 'POST', path: `${ACCOUNT}/datasets`, body: { dataset: { displayName: 'x' }, datasetId: 'conf-ds' } },
385
+ { key: 'GET /v1/accounts/{account_id}/datasets', method: 'GET', path: `${ACCOUNT}/datasets` },
386
+ { key: 'GET /v1/accounts/{account_id}/datasets/{dataset_id}', method: 'GET', path: `${ACCOUNT}/datasets/conf-ds` },
387
+ { key: 'PATCH /v1/accounts/{account_id}/datasets/{dataset_id}', method: 'PATCH', path: `${ACCOUNT}/datasets/conf-ds`, body: { displayName: 'x' } },
388
+ { key: 'DELETE /v1/accounts/{account_id}/datasets/{dataset_id}', method: 'DELETE', path: `${ACCOUNT}/datasets/conf-ds` },
389
+ { key: 'POST /v1/accounts/{account_id}/batchInferenceJobs', method: 'POST', path: `${ACCOUNT}/batchInferenceJobs?batchInferenceJobId=conf-job`, body: { model: MODEL, inputDatasetId: 'conf-ds' } },
390
+ { key: 'GET /v1/accounts/{account_id}/batchInferenceJobs', method: 'GET', path: `${ACCOUNT}/batchInferenceJobs` },
391
+ { key: 'GET /v1/accounts/{account_id}/batchInferenceJobs/{job_id}', method: 'GET', path: `${ACCOUNT}/batchInferenceJobs/conf-job` },
392
+ { key: 'DELETE /v1/accounts/{account_id}/batchInferenceJobs/{job_id}', method: 'DELETE', path: `${ACCOUNT}/batchInferenceJobs/conf-job` },
393
+ { key: 'POST /v1/accounts/{account_id}/batchInferenceJobs/{job_id}:cancel', method: 'POST', path: `${ACCOUNT}/batchInferenceJobs/conf-job:cancel` },
394
+ { key: 'POST /v1/accounts/{account_id}/supervisedFineTuningJobs', method: 'POST', path: `${ACCOUNT}/supervisedFineTuningJobs?supervisedFineTuningJobId=conf-sft`, body: { dataset: 'accounts/my-account/datasets/conf-ds', baseModel: MODEL } },
395
+ { key: 'GET /v1/accounts/{account_id}/supervisedFineTuningJobs', method: 'GET', path: `${ACCOUNT}/supervisedFineTuningJobs` },
396
+ { key: 'GET /v1/accounts/{account_id}/supervisedFineTuningJobs/{job_id}', method: 'GET', path: `${ACCOUNT}/supervisedFineTuningJobs/conf-sft` },
397
+ { key: 'DELETE /v1/accounts/{account_id}/supervisedFineTuningJobs/{job_id}', method: 'DELETE', path: `${ACCOUNT}/supervisedFineTuningJobs/conf-sft` },
398
+ { key: 'POST /v1/accounts/{account_id}/users', method: 'POST', path: `${ACCOUNT}/users?userId=conf-user`, body: { role: 'admin' } },
399
+ { key: 'GET /v1/accounts/{account_id}/users', method: 'GET', path: `${ACCOUNT}/users` },
400
+ { key: 'GET /v1/accounts/{account_id}/users/{user_id}', method: 'GET', path: `${ACCOUNT}/users/conf-user` },
401
+ { key: 'PATCH /v1/accounts/{account_id}/users/{user_id}', method: 'PATCH', path: `${ACCOUNT}/users/conf-user`, body: { displayName: 'x' } },
402
+ { key: 'POST /v1/accounts/{account_id}/users/{user_id}/apiKeys', method: 'POST', path: `${ACCOUNT}/users/conf-user/apiKeys`, body: { apiKey: { displayName: 'k' } } },
403
+ { key: 'GET /v1/accounts/{account_id}/users/{user_id}/apiKeys', method: 'GET', path: `${ACCOUNT}/users/conf-user/apiKeys` },
404
+ { key: 'GET /v1/accounts/{account_id}/users/{user_id}/apiKeys/{key_id}', method: 'GET', path: `${ACCOUNT}/users/conf-user/apiKeys/key_twin_1` },
405
+ { key: 'PATCH /v1/accounts/{account_id}/users/{user_id}/apiKeys/{key_id}', method: 'PATCH', path: `${ACCOUNT}/users/conf-user/apiKeys/key_twin_1`, body: { displayName: 'x' } },
406
+ { key: 'POST /v1/accounts/{account_id}/users/{user_id}/apiKeys/{key_id}:delete', method: 'POST', path: `${ACCOUNT}/users/conf-user/apiKeys/key_twin_1:delete`, body: { keyId: 'key_twin_1' } },
407
+ { key: 'POST /v1/accounts/{account_id}/users/{user_id}/apiKeys:delete', method: 'POST', path: `${ACCOUNT}/users/conf-user/apiKeys:delete`, body: { keyId: 'key_twin_2' } },
408
+ { key: 'PATCH /v1/accounts/{account_id}/deployments/{deployment_id}:scale', method: 'PATCH', path: `${ACCOUNT}/deployments/conf-dep:scale`, body: { replicaCount: 2 } },
409
+ { key: 'POST /v1/accounts/{account_id}/secrets', method: 'POST', path: `${ACCOUNT}/secrets`, body: { name: 's', keyName: 'K', value: 'v' } },
410
+ { key: 'GET /v1/accounts/{account_id}/secrets', method: 'GET', path: `${ACCOUNT}/secrets` },
411
+ { key: 'GET /v1/accounts/{account_id}/secrets/{secret_id}', method: 'GET', path: `${ACCOUNT}/secrets/s` },
412
+ { key: 'PATCH /v1/accounts/{account_id}/secrets/{secret_id}', method: 'PATCH', path: `${ACCOUNT}/secrets/s`, body: { keyName: 'K2' } },
413
+ { key: 'DELETE /v1/accounts/{account_id}/secrets/{secret_id}', method: 'DELETE', path: `${ACCOUNT}/secrets/s` },
414
+ { key: 'POST /v1/accounts/{account_id}/models', method: 'POST', path: `${ACCOUNT}/models?modelId=conf-model`, body: { displayName: 'm' } },
415
+ { key: 'GET /v1/accounts/{account_id}/models', method: 'GET', path: `${ACCOUNT}/models` },
416
+ { key: 'GET /v1/accounts/{account_id}/models/{model_id}', method: 'GET', path: `${ACCOUNT}/models/conf-model` },
417
+ { key: 'PATCH /v1/accounts/{account_id}/models/{model_id}', method: 'PATCH', path: `${ACCOUNT}/models/conf-model`, body: { displayName: 'x' } },
418
+ { key: 'DELETE /v1/accounts/{account_id}/models/{model_id}', method: 'DELETE', path: `${ACCOUNT}/models/conf-model` },
419
+ ];
420
+ const ROUTER_SURFACE_KEYS = ROUTER_SURFACE.map((e) => e.key);
421
+ /**
422
+ * Surface the twin must NOT serve: real Fireworks endpoints this twin does not model yet
423
+ * (unmodeled ops fail like the vendor, never a fake success), plus paths neither plane declares.
424
+ */
425
+ const MUST_NOT_SERVE = [
426
+ { method: 'POST', path: '/v1/chat/completions', why: 'the bare OpenAI /v1 prefix is NOT the vendor path — Fireworks serves /inference/v1; a twin answering here would be a lie about the vendor' },
427
+ { method: 'GET', path: '/v1/accounts/my-account/serverlessModels', why: 'the vendor-curated serverless catalog is real surface this twin does not model yet (the spec scopes it under the account: /v1/accounts/{account_id}/serverlessModels)' },
428
+ { method: 'GET', path: '/v1/accounts/my-account/quotas', why: 'quotas are vendor-assigned surface this twin does not model yet' },
429
+ { method: 'POST', path: '/v1/accounts/my-account/datasets/conf-ds:upload', why: 'the dataset-upload extra is real surface this twin does not model yet' },
430
+ ];
431
+ /** Run the offline conformance checks against a fresh temp root. */
432
+ export async function checkFireworksConformance(opts = {}) {
433
+ const violations = [];
434
+ let checksRun = 0;
435
+ const fail = (check, detail) => violations.push({ check, detail });
436
+ // ── 1. PROBES: each in its OWN throwaway root, so a probe's seed can't leak into another. ──
437
+ for (const probe of PROBES) {
438
+ checksRun++;
439
+ // A FRESH root per probe, always. `opts.root` is the PARENT directory, never a shared root:
440
+ // sharing it let `nextId` ratchet across probes, so a probe read the id an earlier probe had
441
+ // minted and `world-fireworks conformance --root DIR` reported false violations.
442
+ const root = mkdtempSync(join(opts.root ?? tmpdir(), 'fireworks-conf-'));
443
+ const h = (method, path, body) => handleFireworksTwinRequest({ method, path, ...(body === undefined ? {} : { body: JSON.stringify(body) }), root });
444
+ try {
445
+ if (probe.seed)
446
+ await probe.seed(h);
447
+ const res = await h(probe.method, probe.path, probe.body);
448
+ if (!probe.statuses.includes(res.status)) {
449
+ fail(`probe:${probe.key}`, `status ${res.status} (expected one of ${probe.statuses.join('/')}) body=${JSON.stringify(res.body).slice(0, 200)}`);
450
+ }
451
+ else if (!probe.ok(res.body, res.status)) {
452
+ fail(`probe:${probe.key}`, `body did not satisfy the live-handler predicate: ${JSON.stringify(res.body).slice(0, 300)}`);
453
+ }
454
+ }
455
+ finally {
456
+ rmSync(root, { recursive: true, force: true });
457
+ }
458
+ }
459
+ // ── 2. BIJECTION: probe table ⇄ router census, both directions. ──
460
+ checksRun++;
461
+ const probeKeys = new Set(PROBES.map((p) => p.key));
462
+ for (const key of ROUTER_SURFACE_KEYS)
463
+ if (!probeKeys.has(key))
464
+ fail('bijection', `router branch "${key}" has no probe`);
465
+ for (const key of probeKeys)
466
+ if (!ROUTER_SURFACE_KEYS.includes(key))
467
+ fail('bijection', `probe "${key}" is not in the hand-authored router census`);
468
+ if (PROBES.length !== new Set(PROBES.map((p) => p.key)).size)
469
+ fail('bijection', 'duplicate probe key');
470
+ if (ROUTER_SURFACE.length !== ROUTER_SURFACE_KEYS.length)
471
+ fail('bijection', 'the router census and its key list disagree');
472
+ // ── 2b. THE CENSUS HAS TEETH: every declared branch must actually be REACHED. A branch whose
473
+ // handler was deleted falls through to the router's own not-found, which is what this
474
+ // catches — independently of that endpoint's own probe.
475
+ for (const entry of ROUTER_SURFACE) {
476
+ checksRun++;
477
+ const root = mkdtempSync(join(opts.root ?? tmpdir(), 'fireworks-conf-'));
478
+ try {
479
+ const res = await handleFireworksTwinRequest({ method: entry.method, path: entry.path, ...(entry.body === undefined ? {} : { body: JSON.stringify(entry.body) }), root });
480
+ const message = String(res.body?.error?.message ?? '');
481
+ // The control plane's google.rpc error carries `message` at the top level.
482
+ const gatewayMessage = String(res.body?.message ?? '');
483
+ if (message.includes(ROUTER_MISS) || gatewayMessage.includes(ROUTER_MISS)) {
484
+ fail('router_surface', `${entry.key} fell through to the router's not-found — the branch is gone (answered ${res.status}: ${message || gatewayMessage})`);
485
+ }
486
+ if (!ROUTER_SURFACE_KEYS.includes(entry.key))
487
+ fail('router_surface', `${entry.key} is exercised but not declared`);
488
+ }
489
+ finally {
490
+ rmSync(root, { recursive: true, force: true });
491
+ }
492
+ }
493
+ // ── 3. The twin must not serve surface it does not model (or the vendor does not have). ──
494
+ {
495
+ const root = opts.root ?? mkdtempSync(join(tmpdir(), 'fireworks-conf-'));
496
+ try {
497
+ for (const entry of MUST_NOT_SERVE) {
498
+ checksRun++;
499
+ const res = await handleFireworksTwinRequest({ method: entry.method, path: entry.path, root });
500
+ if (res.status !== 404)
501
+ fail('must_not_serve', `${entry.method} ${entry.path} answered ${res.status}, not 404 — ${entry.why}`);
502
+ // The vendor 404s carry EITHER plane's envelope: the inference plane's {error:{…}} or the
503
+ // control plane's google.rpc {code, message}.
504
+ else if (!isObj(res.body) || (!isObj(res.body.error) && res.body.code === undefined))
505
+ fail('must_not_serve', `${entry.method} ${entry.path} 404 body is not the vendor error envelope`);
506
+ }
507
+ }
508
+ finally {
509
+ if (root !== opts.root)
510
+ rmSync(root, { recursive: true, force: true });
511
+ }
512
+ }
513
+ return { ok: violations.length === 0, checksRun, probes: PROBES.length, violations };
514
+ }