cloudbroker-sdk 0.4.0 → 0.6.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/.github/workflows/publish.yml +18 -0
- package/README.md +83 -5
- package/docs/COST_PER_FEATURE.md +296 -0
- package/example-checkout-app/.env.example +13 -0
- package/example-checkout-app/README.md +225 -0
- package/example-checkout-app/package.json +28 -0
- package/example-checkout-app/server.js +209 -0
- package/example-checkout-app/test/e2e.js +97 -0
- package/package.json +13 -4
- package/src/client.js +17 -6
- package/src/cloud-wrapper.js +531 -5
- package/src/pricing-cloud.js +165 -10
- package/src/test-azure-real.js +169 -0
- package/src/test.js +316 -0
- package/src/wrapper.js +24 -4
package/src/test.js
CHANGED
|
@@ -173,5 +173,321 @@ assert.strictEqual(ctxEvent.product, "Billing", `product do contexto esperad
|
|
|
173
173
|
assert.strictEqual(ctxEvent.team, "Plataforma", `team do contexto esperado, veio ${ctxEvent.team}`);
|
|
174
174
|
console.log("✅ expressMiddleware: label/product/team do contexto aplicados sem cb* explícito");
|
|
175
175
|
|
|
176
|
+
// 4) CloudBrokerAI.wrap — label de código nas chamadas de IA (mesma
|
|
177
|
+
// convenção do wrapAWS: explícito na chamada > contexto cbStore > nenhum),
|
|
178
|
+
// pra agregar custo de IA junto com custo de nuvem na visão "Custo por Código".
|
|
179
|
+
const capturedAi = [];
|
|
180
|
+
globalThis.fetch = async (url, opts) => {
|
|
181
|
+
if (String(url).includes("/api/ai/events")) {
|
|
182
|
+
capturedAi.push(JSON.parse(opts.body));
|
|
183
|
+
}
|
|
184
|
+
return { ok: true, status: 200, json: async () => ({}) };
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
let sawOpenAiOptions;
|
|
188
|
+
const mockOpenAI2 = {
|
|
189
|
+
chat: {
|
|
190
|
+
completions: {
|
|
191
|
+
create: async (params, options) => {
|
|
192
|
+
sawOpenAiOptions = options;
|
|
193
|
+
return {
|
|
194
|
+
model: params.model,
|
|
195
|
+
choices: [{ message: { role: "assistant", content: "ok" } }],
|
|
196
|
+
usage: { prompt_tokens: 10, completion_tokens: 5 },
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
const wrappedAi = CloudBrokerAI.wrap(mockOpenAI2, {
|
|
203
|
+
apiKey: "cb-test-key", product: "FinOps Advisor", team: "platform", debug: true,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
await wrappedAi.chat.completions.create(
|
|
207
|
+
{ model: "gpt-4o", messages: [{ role: "user", content: "x" }] },
|
|
208
|
+
{ cbLabel: "finops-advisor:intent-classify" }
|
|
209
|
+
);
|
|
210
|
+
assert.strictEqual(sawOpenAiOptions, undefined, "cbLabel não pode vazar pro SDK do provider");
|
|
211
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
212
|
+
assert.strictEqual(capturedAi.length, 1, `esperado 1 evento de IA, veio ${capturedAi.length}`);
|
|
213
|
+
assert.strictEqual(capturedAi[0].events[0].label, "finops-advisor:intent-classify");
|
|
214
|
+
console.log("✅ CloudBrokerAI.wrap: label explícito (cbLabel) capturado, sem vazar pro provider");
|
|
215
|
+
|
|
216
|
+
// label via contexto (cbStore, mesmo ALS de CloudBrokerCloud.expressMiddleware)
|
|
217
|
+
const mw2 = CloudBrokerCloud.expressMiddleware({ label: "finops-advisor:root-cause-agent" });
|
|
218
|
+
await new Promise((resolve) => {
|
|
219
|
+
mw2({ method: "POST", path: "/api/chat/response" }, {}, async () => {
|
|
220
|
+
await wrappedAi.chat.completions.create({ model: "gpt-4o", messages: [{ role: "user", content: "y" }] });
|
|
221
|
+
resolve();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
225
|
+
assert.strictEqual(capturedAi[1].events[0].label, "finops-advisor:root-cause-agent");
|
|
226
|
+
console.log("✅ CloudBrokerAI.wrap: label via contexto cbStore (sem cbLabel explícito)");
|
|
227
|
+
|
|
228
|
+
// ─── AZURE (wrapAzure — Blob Storage + Cosmos DB) ──────────────────────────────
|
|
229
|
+
console.log("\n☁️ Testando wrapAzure (Blob + Cosmos)...");
|
|
230
|
+
|
|
231
|
+
// 5) Tabela de preços Azure
|
|
232
|
+
const blobW = estimateCloudCost({ provider: "azure", service: "blob", operation: "uploadData", requests: 1 });
|
|
233
|
+
assert.strictEqual(blobW.billingUnit, "blob-write");
|
|
234
|
+
assert.ok(Math.abs(blobW.costUSD - 0.055 / 10_000) < 1e-12, `blob write veio ${blobW.costUSD}`);
|
|
235
|
+
|
|
236
|
+
const blobR = estimateCloudCost({ provider: "azure", service: "blob", operation: "download", requests: 1, bytesOut: 1024 * 1024 * 1024 });
|
|
237
|
+
assert.strictEqual(blobR.billingUnit, "blob-read");
|
|
238
|
+
assert.ok(Math.abs(blobR.costUSD - (0.0044 / 10_000 + 0.087)) < 1e-9, `blob read+transfer veio ${blobR.costUSD}`);
|
|
239
|
+
|
|
240
|
+
const blobDel = estimateCloudCost({ provider: "azure", service: "blob", operation: "delete", requests: 1 });
|
|
241
|
+
assert.strictEqual(blobDel.billingUnit, "blob-free");
|
|
242
|
+
assert.strictEqual(blobDel.costUSD, 0);
|
|
243
|
+
|
|
244
|
+
const blobList = estimateCloudCost({ provider: "azure", service: "blob", operation: "listBlobsFlat", requests: 1 });
|
|
245
|
+
assert.strictEqual(blobList.billingUnit, "blob-list");
|
|
246
|
+
|
|
247
|
+
const cosmosReal = estimateCloudCost({ provider: "azure", service: "cosmosdb", operation: "items.create", requestCharge: 7.43 });
|
|
248
|
+
assert.strictEqual(cosmosReal.billingUnit, "cosmos-ru");
|
|
249
|
+
assert.ok(Math.abs(cosmosReal.costUSD - 7.43 * 0.25 / 1_000_000) < 1e-15, `cosmos RU real veio ${cosmosReal.costUSD}`);
|
|
250
|
+
|
|
251
|
+
const cosmosEst = estimateCloudCost({ provider: "azure", service: "cosmosdb", operation: "items.create" });
|
|
252
|
+
assert.strictEqual(cosmosEst.billingUnit, "cosmos-ru-est");
|
|
253
|
+
assert.ok(Math.abs(cosmosEst.costUSD - 5 * 0.25 / 1_000_000) < 1e-15, `cosmos RU fallback veio ${cosmosEst.costUSD}`);
|
|
254
|
+
console.log("✅ estimateCloudCost azure: blob write/read+transfer/delete-free/list, cosmos RU real/fallback");
|
|
255
|
+
|
|
256
|
+
// 5b) Região sem tabela de preço NÃO pode ser precificada como eastus2 — um app
|
|
257
|
+
// em brazilsouth receberia o custo de outra região apresentado como o dele.
|
|
258
|
+
const brs = estimateCloudCost({ provider: "azure", service: "blob", operation: "uploadData", region: "brazilsouth" });
|
|
259
|
+
assert.strictEqual(brs.costUSD, null, `região sem preço deve dar custo null, veio ${brs.costUSD}`);
|
|
260
|
+
assert.strictEqual(brs.billingUnit, "blob-region-unpriced");
|
|
261
|
+
console.log("✅ região Azure sem tabela: custo null + unidade explícita (não finge eastus2)");
|
|
262
|
+
|
|
263
|
+
// 6) Blob — cadeia de fábricas, cbLabel não vaza, bytes medidos do argumento certo
|
|
264
|
+
captured.length = 0;
|
|
265
|
+
globalThis.fetch = async (url, opts) => {
|
|
266
|
+
if (String(url).includes("/api/cloud/events")) captured.push(JSON.parse(opts.body));
|
|
267
|
+
return { ok: true, status: 200, json: async () => ({}) };
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
let sawBlobOptions, sawUploadArgs;
|
|
271
|
+
class BlockBlobClient {
|
|
272
|
+
async uploadData(data, options) { sawBlobOptions = options; return { etag: "0x1" }; }
|
|
273
|
+
async download() { return { contentLength: 4096 }; }
|
|
274
|
+
// getProperties devolve o TAMANHO DO BLOB, mas não transfere o blob.
|
|
275
|
+
async getProperties() { return { contentLength: 5 * 1024 * 1024 * 1024 }; }
|
|
276
|
+
async uploadBlockBlob(...args) { sawUploadArgs = args; return { etag: "0x2" }; }
|
|
277
|
+
async setMetadata(meta, options) { return { meta, options }; }
|
|
278
|
+
}
|
|
279
|
+
class ContainerClient {
|
|
280
|
+
getBlockBlobClient(_name) { return new BlockBlobClient(); }
|
|
281
|
+
listBlobsFlat() {
|
|
282
|
+
let i = 0;
|
|
283
|
+
return { async next() { return i++ < 2 ? { done: false, value: { name: `b${i}` } } : { done: true }; },
|
|
284
|
+
[Symbol.asyncIterator]() { return this; } };
|
|
285
|
+
}
|
|
286
|
+
async uploadBlockBlob(...args) { sawUploadArgs = args; return { etag: "0x2" }; }
|
|
287
|
+
}
|
|
288
|
+
class BlobServiceClient {
|
|
289
|
+
getContainerClient(_name) { return new ContainerClient(); }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const blobSvc = CloudBrokerCloud.wrapAzure(new BlobServiceClient(), {
|
|
293
|
+
apiKey: "cb-test-key", product: "checkout", team: "mobile", debug: true,
|
|
294
|
+
});
|
|
295
|
+
const blockBlob = blobSvc.getContainerClient("docs").getBlockBlobClient("nf.pdf");
|
|
296
|
+
const upRes = await blockBlob.uploadData(Buffer.from("hello azure"), { cbLabel: "upload-nf-azure" });
|
|
297
|
+
assert.strictEqual(upRes.etag, "0x1", "resultado original deve ser preservado");
|
|
298
|
+
// O argumento NUNCA é removido: setMetadata() sem argumento apaga os metadados
|
|
299
|
+
// do blob. O options segue existindo, só sem as chaves cb*.
|
|
300
|
+
assert.deepStrictEqual(sawBlobOptions, {}, "options deve chegar sem cb*, mas deve chegar");
|
|
301
|
+
|
|
302
|
+
const dlRes = await blockBlob.download();
|
|
303
|
+
assert.strictEqual(dlRes.contentLength, 4096);
|
|
304
|
+
|
|
305
|
+
await new Promise(r => setTimeout(r, 50));
|
|
306
|
+
const azEvents = captured.flatMap(c => c.events);
|
|
307
|
+
assert.strictEqual(azEvents.length, 2, `esperado 2 eventos (upload+download), veio ${azEvents.length}`);
|
|
308
|
+
const [evUp, evDl] = azEvents;
|
|
309
|
+
assert.strictEqual(evUp.provider, "azure");
|
|
310
|
+
assert.strictEqual(evUp.service, "blob");
|
|
311
|
+
assert.strictEqual(evUp.operation, "uploadData");
|
|
312
|
+
assert.strictEqual(evUp.label, "upload-nf-azure");
|
|
313
|
+
assert.strictEqual(evUp.product, "checkout");
|
|
314
|
+
assert.strictEqual(evUp.bytesIn, Buffer.byteLength("hello azure"));
|
|
315
|
+
assert.strictEqual(evUp.billingUnit, "blob-write");
|
|
316
|
+
assert.strictEqual(evDl.operation, "download");
|
|
317
|
+
assert.strictEqual(evDl.bytesOut, 4096);
|
|
318
|
+
assert.strictEqual(evDl.billingUnit, "blob-read");
|
|
319
|
+
console.log("✅ wrapAzure blob: cadeia de fábricas, cb* sem vazar, options preservado, bytes in/out");
|
|
320
|
+
|
|
321
|
+
// 6b) getProperties NÃO pode cobrar egress do tamanho do blob (custo inflado)
|
|
322
|
+
captured.length = 0;
|
|
323
|
+
await blockBlob.getProperties();
|
|
324
|
+
await new Promise(r => setTimeout(r, 50));
|
|
325
|
+
const evProps = captured.flatMap(c => c.events)[0];
|
|
326
|
+
assert.strictEqual(evProps.operation, "getProperties");
|
|
327
|
+
assert.strictEqual(evProps.bytesOut, 0, `getProperties não transfere o blob; bytesOut veio ${evProps.bytesOut}`);
|
|
328
|
+
assert.ok(evProps.costUSD < 1e-6, `custo de metadado deve ser desprezível, veio ${evProps.costUSD}`);
|
|
329
|
+
console.log("✅ getProperties: sem egress do tamanho do blob (custo de metadado, não de 5 GB)");
|
|
330
|
+
|
|
331
|
+
// 6c) bytesIn vem do argumento de CORPO declarado, não da primeira string
|
|
332
|
+
// (em uploadBlockBlob a primeira string é o NOME do blob)
|
|
333
|
+
captured.length = 0;
|
|
334
|
+
await blobSvc.getContainerClient("docs").uploadBlockBlob("relatorio-financeiro-2026-07.pdf", Buffer.alloc(10 * 1024 * 1024), 10 * 1024 * 1024);
|
|
335
|
+
await new Promise(r => setTimeout(r, 50));
|
|
336
|
+
const evBlockUp = captured.flatMap(c => c.events)[0];
|
|
337
|
+
assert.strictEqual(evBlockUp.bytesIn, 10 * 1024 * 1024, `bytesIn deve ser o corpo (10MB), veio ${evBlockUp.bytesIn}`);
|
|
338
|
+
console.log("✅ bytesIn: medido do argumento de corpo, não do nome do blob");
|
|
339
|
+
|
|
340
|
+
// 6d) list é LAZY — nenhum HTTP na criação do iterator. Evento só quando a
|
|
341
|
+
// página é de fato buscada (senão emitia custo de operação que nunca ocorreu).
|
|
342
|
+
captured.length = 0;
|
|
343
|
+
const neverUsed = blobSvc.getContainerClient("docs").listBlobsFlat();
|
|
344
|
+
await new Promise(r => setTimeout(r, 50));
|
|
345
|
+
assert.strictEqual(captured.length, 0, "iterator criado e não consumido não pode gerar custo");
|
|
346
|
+
for await (const _b of neverUsed) { /* consome */ }
|
|
347
|
+
await new Promise(r => setTimeout(r, 50));
|
|
348
|
+
const listEvents = captured.flatMap(c => c.events);
|
|
349
|
+
assert.ok(listEvents.length >= 1, "consumir o iterator deve gerar evento de list");
|
|
350
|
+
assert.strictEqual(listEvents[0].billingUnit, "blob-list");
|
|
351
|
+
console.log("✅ list: sem custo fabricado — evento só quando a página é buscada");
|
|
352
|
+
|
|
353
|
+
// 7) Cosmos — requestCharge REAL vira custo; iterator de query rastreado
|
|
354
|
+
captured.length = 0;
|
|
355
|
+
let sawCreateArgs;
|
|
356
|
+
class Item {
|
|
357
|
+
async read() { return { requestCharge: 1.0, resource: { id: "x" } }; }
|
|
358
|
+
}
|
|
359
|
+
class Container {
|
|
360
|
+
constructor() {
|
|
361
|
+
this.items = {
|
|
362
|
+
create: async (...args) => { sawCreateArgs = args; return { requestCharge: 7.43, resource: { id: "y" } }; },
|
|
363
|
+
query: (_q) => ({ fetchAll: async () => ({ requestCharge: 3.2, resources: [] }) }),
|
|
364
|
+
bulk: async (_ops) => ([{ requestCharge: 5 }, { requestCharge: 6 }]),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
item(_id, _pk) { return new Item(); }
|
|
368
|
+
// control-plane: lê a definição do CONTAINER, não de um item
|
|
369
|
+
async read() { return { requestCharge: 2.0, resource: { id: "orders" } }; }
|
|
370
|
+
}
|
|
371
|
+
class Database {
|
|
372
|
+
container(_id) { return new Container(); }
|
|
373
|
+
async read() { return { requestCharge: 1.5, resource: { id: "db" } }; }
|
|
374
|
+
}
|
|
375
|
+
class CosmosClient { database(_id) { return new Database(); } }
|
|
376
|
+
|
|
377
|
+
const cosmos = CloudBrokerCloud.wrapAzure(new CosmosClient(), {
|
|
378
|
+
apiKey: "cb-test-key", product: "checkout", team: "mobile",
|
|
379
|
+
});
|
|
380
|
+
const cont = cosmos.database("db").container("orders");
|
|
381
|
+
const createRes = await cont.items.create({ id: "y" }, { cbLabel: "criar-pedido" });
|
|
382
|
+
assert.strictEqual(createRes.resource.id, "y");
|
|
383
|
+
await cont.item("x", "pk").read();
|
|
384
|
+
await cont.items.query("SELECT * FROM c").fetchAll();
|
|
385
|
+
|
|
386
|
+
await new Promise(r => setTimeout(r, 50));
|
|
387
|
+
const cosmosEvents = captured.flatMap(c => c.events);
|
|
388
|
+
assert.strictEqual(cosmosEvents.length, 3, `esperado 3 eventos cosmos, veio ${cosmosEvents.length}`);
|
|
389
|
+
const [evCreate, evRead, evQuery] = cosmosEvents;
|
|
390
|
+
assert.strictEqual(evCreate.service, "cosmosdb");
|
|
391
|
+
assert.strictEqual(evCreate.operation, "items.create");
|
|
392
|
+
assert.strictEqual(evCreate.label, "criar-pedido");
|
|
393
|
+
assert.strictEqual(evCreate.billingUnit, "cosmos-ru");
|
|
394
|
+
assert.ok(Math.abs(evCreate.costUSD - 7.43 * 0.25 / 1_000_000) < 1e-15, `custo RU real veio ${evCreate.costUSD}`);
|
|
395
|
+
assert.strictEqual(evRead.operation, "item.read");
|
|
396
|
+
assert.strictEqual(evQuery.operation, "items.query.fetchAll");
|
|
397
|
+
assert.ok(Math.abs(evQuery.costUSD - 3.2 * 0.25 / 1_000_000) < 1e-15, `custo do query veio ${evQuery.costUSD}`);
|
|
398
|
+
console.log("✅ wrapAzure cosmos: requestCharge real → custo, item.read, query iterator");
|
|
399
|
+
|
|
400
|
+
// 7b) O DOCUMENTO do cliente nunca é inspecionado nem alterado — um campo
|
|
401
|
+
// chamado cbLabel no corpo tem que ser gravado como veio.
|
|
402
|
+
captured.length = 0;
|
|
403
|
+
const docComCampoColidindo = { id: "z", cbLabel: "campo-de-negocio-do-cliente", valor: 10 };
|
|
404
|
+
await cont.items.create(docComCampoColidindo);
|
|
405
|
+
assert.deepStrictEqual(
|
|
406
|
+
sawCreateArgs[0],
|
|
407
|
+
{ id: "z", cbLabel: "campo-de-negocio-do-cliente", valor: 10 },
|
|
408
|
+
"documento do cliente não pode perder campo por colisão de nome",
|
|
409
|
+
);
|
|
410
|
+
await new Promise(r => setTimeout(r, 50));
|
|
411
|
+
assert.strictEqual(captured.flatMap(c => c.events)[0].label, null, "campo do corpo não vira label");
|
|
412
|
+
console.log("✅ documento do cliente intacto: cb* no corpo não é lido nem removido");
|
|
413
|
+
|
|
414
|
+
// 7c) Container.read()/Database.read() são control-plane — não podem sair
|
|
415
|
+
// rotulados como item.read (operação errada na tela de custo por trecho).
|
|
416
|
+
captured.length = 0;
|
|
417
|
+
await cont.read();
|
|
418
|
+
await cosmos.database("db").read();
|
|
419
|
+
await new Promise(r => setTimeout(r, 50));
|
|
420
|
+
const cpEvents = captured.flatMap(c => c.events);
|
|
421
|
+
assert.strictEqual(cpEvents[0].operation, "container.read", `veio ${cpEvents[0].operation}`);
|
|
422
|
+
assert.strictEqual(cpEvents[1].operation, "database.read", `veio ${cpEvents[1].operation}`);
|
|
423
|
+
console.log("✅ cosmos: container.read/database.read não são confundidos com item.read");
|
|
424
|
+
|
|
425
|
+
// 7d) bulk soma o requestCharge de cada operação (senão 1000 escritas = 1 leitura)
|
|
426
|
+
captured.length = 0;
|
|
427
|
+
await cont.items.bulk([{ operationType: "Create" }, { operationType: "Create" }]);
|
|
428
|
+
await new Promise(r => setTimeout(r, 50));
|
|
429
|
+
const evBulk = captured.flatMap(c => c.events)[0];
|
|
430
|
+
assert.strictEqual(evBulk.operation, "items.bulk");
|
|
431
|
+
assert.strictEqual(evBulk.billingUnit, "cosmos-ru", "bulk deve usar RU medida, não estimativa");
|
|
432
|
+
assert.ok(Math.abs(evBulk.costUSD - 11 * 0.25 / 1_000_000) < 1e-15, `bulk deve somar 5+6 RU, veio ${evBulk.costUSD}`);
|
|
433
|
+
console.log("✅ cosmos bulk: RUs somadas por operação");
|
|
434
|
+
|
|
435
|
+
// 8) Robustez — a instrumentação NUNCA pode quebrar a aplicação do cliente.
|
|
436
|
+
// Este é o requisito mais importante: o SDK roda dentro da produção dele.
|
|
437
|
+
captured.length = 0;
|
|
438
|
+
class BlobQuebrado {
|
|
439
|
+
async uploadData() {
|
|
440
|
+
// Resposta com propriedade lazy que lança ao ser lida (padrão em SDKs
|
|
441
|
+
// de streaming). A operação JÁ ACONTECEU no Azure quando isso é lido.
|
|
442
|
+
return { etag: "ok", get contentLength() { throw new Error("propriedade lazy do SDK lançou"); } };
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
class ContainerQuebrado { getBlockBlobClient() { return new BlobQuebrado(); } }
|
|
446
|
+
class BlobServiceClient2 { getContainerClient() { return new ContainerQuebrado(); } }
|
|
447
|
+
Object.defineProperty(BlobServiceClient2, "name", { value: "BlobServiceClient" });
|
|
448
|
+
Object.defineProperty(ContainerQuebrado, "name", { value: "ContainerClient" });
|
|
449
|
+
Object.defineProperty(BlobQuebrado, "name", { value: "BlockBlobClient" });
|
|
450
|
+
|
|
451
|
+
const svcQuebrado = CloudBrokerCloud.wrapAzure(new BlobServiceClient2(), { apiKey: "cb-test-key" });
|
|
452
|
+
const r8 = await svcQuebrado.getContainerClient("c").getBlockBlobClient("b").uploadData(Buffer.from("x"));
|
|
453
|
+
assert.strictEqual(r8.etag, "ok", "erro na instrumentação não pode virar erro da aplicação do cliente");
|
|
454
|
+
console.log("✅ robustez: exceção na leitura do resultado não vaza pro app do cliente");
|
|
455
|
+
|
|
456
|
+
// options com getter que lança, ANTES da chamada real
|
|
457
|
+
class BlobGetterRuim {
|
|
458
|
+
async uploadData(_d, _o) { return { etag: "ok2" }; }
|
|
459
|
+
}
|
|
460
|
+
Object.defineProperty(BlobGetterRuim, "name", { value: "BlockBlobClient" });
|
|
461
|
+
const bad = CloudBrokerCloud.wrapAzure(new BlobGetterRuim(), { apiKey: "cb-test-key" });
|
|
462
|
+
const optsRuim = Object.defineProperty({}, "cbLabel", { get() { throw new Error("getter ruim"); }, enumerable: true });
|
|
463
|
+
const r8b = await bad.uploadData(Buffer.from("x"), optsRuim);
|
|
464
|
+
assert.strictEqual(r8b.etag, "ok2", "erro ao extrair label não pode impedir a operação real");
|
|
465
|
+
console.log("✅ robustez: falha na extração de label não impede a chamada Azure");
|
|
466
|
+
|
|
467
|
+
// 8b) Client congelado pelo cliente: a atribuição in-place lançaria (strict
|
|
468
|
+
// mode). Instrumentação nunca pode derrubar a app — avisa e devolve original.
|
|
469
|
+
class BlobCongelado { async uploadData() { return { etag: "frozen-ok" }; } }
|
|
470
|
+
Object.defineProperty(BlobCongelado, "name", { value: "BlockBlobClient" });
|
|
471
|
+
const congelado = Object.freeze(new BlobCongelado());
|
|
472
|
+
const warnsFrozen = [];
|
|
473
|
+
const warnFrozenOrig = console.warn;
|
|
474
|
+
console.warn = (...a) => warnsFrozen.push(a.join(" "));
|
|
475
|
+
const wrappedFrozen = CloudBrokerCloud.wrapAzure(congelado, { apiKey: "cb-test-key" });
|
|
476
|
+
console.warn = warnFrozenOrig;
|
|
477
|
+
const rFrozen = await wrappedFrozen.uploadData(Buffer.from("x"));
|
|
478
|
+
assert.strictEqual(rFrozen.etag, "frozen-ok", "client congelado deve seguir funcionando");
|
|
479
|
+
assert.ok(warnsFrozen.some(w => w.includes("congelado")), "deve avisar que não vai rastrear");
|
|
480
|
+
console.log("✅ robustez: client congelado não quebra a aplicação (avisa e não rastreia)");
|
|
481
|
+
|
|
482
|
+
// 9) Client não reconhecido → retorna original intocado (e avisa alto)
|
|
483
|
+
class MinhaClasseQualquer { async foo() { return 42; } }
|
|
484
|
+
const warns = [];
|
|
485
|
+
const origWarn = console.warn;
|
|
486
|
+
console.warn = (...a) => warns.push(a.join(" "));
|
|
487
|
+
const untouched = CloudBrokerCloud.wrapAzure(new MinhaClasseQualquer(), { apiKey: "cb-test-key" });
|
|
488
|
+
console.warn = origWarn;
|
|
489
|
+
assert.strictEqual(await untouched.foo(), 42);
|
|
490
|
+
assert.ok(warns.some(w => w.includes("não reconhecido")), "deve avisar mesmo sem debug:true");
|
|
491
|
+
console.log("✅ wrapAzure: client não reconhecido passa intocado, com aviso incondicional");
|
|
176
492
|
globalThis.fetch = realFetch;
|
|
177
493
|
console.log("\n🎉 Todos os testes passaram.");
|
package/src/wrapper.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { CloudBrokerClient } from "./client.js";
|
|
7
7
|
import { PRICING } from "./pricing.js";
|
|
8
|
+
import { cbStore } from "./context.js";
|
|
8
9
|
|
|
9
10
|
export class CloudBrokerAI {
|
|
10
11
|
/**
|
|
@@ -17,6 +18,13 @@ export class CloudBrokerAI {
|
|
|
17
18
|
* @param {string} options.team — nome do squad (ex: "mobile")
|
|
18
19
|
* @param {string} options.endpoint — endpoint CloudBroker (opcional)
|
|
19
20
|
* @param {boolean} options.debug — logs de debug (opcional)
|
|
21
|
+
*
|
|
22
|
+
* Label opcional do trecho de código, pra agregar custo de IA junto com
|
|
23
|
+
* custo de nuvem na mesma visão "Custo por Código" (mesma convenção de
|
|
24
|
+
* CloudBrokerCloud.wrapAWS):
|
|
25
|
+
* await client.chat.completions.create(params, { cbLabel: "checkout:resumo-pedido" });
|
|
26
|
+
* await client.messages.create(params, { cbLabel: "checkout:resumo-pedido" });
|
|
27
|
+
* Ou via CloudBrokerCloud.expressMiddleware() (contexto automático por requisição).
|
|
20
28
|
*/
|
|
21
29
|
static wrap(client, options = {}) {
|
|
22
30
|
const { apiKey, product, team, endpoint, debug = false } = options;
|
|
@@ -77,6 +85,11 @@ export class CloudBrokerAI {
|
|
|
77
85
|
|
|
78
86
|
client.chat.completions.create = async (params, options) => {
|
|
79
87
|
const start = Date.now();
|
|
88
|
+
// Mesma precedência de cloud-wrapper.js: explícito na chamada > contexto
|
|
89
|
+
// cbStore (expressMiddleware) > nenhum. Nunca vaza cbLabel pro SDK do provider.
|
|
90
|
+
const cbLabel = options?.cbLabel || cbStore.getStore()?.label || null;
|
|
91
|
+
const { cbLabel: _l, ...providerOptions } = options || {};
|
|
92
|
+
const fwdOptions = Object.keys(providerOptions).length ? providerOptions : undefined;
|
|
80
93
|
|
|
81
94
|
// ── Streaming: pede usage no chunk final e instrumenta o iterador ────────
|
|
82
95
|
if (params?.stream) {
|
|
@@ -84,7 +97,7 @@ export class CloudBrokerAI {
|
|
|
84
97
|
...params,
|
|
85
98
|
stream_options: { include_usage: true, ...(params.stream_options || {}) },
|
|
86
99
|
};
|
|
87
|
-
const stream = await originalCreate(streamParams,
|
|
100
|
+
const stream = await originalCreate(streamParams, fwdOptions);
|
|
88
101
|
|
|
89
102
|
let model = params.model || "unknown";
|
|
90
103
|
let inputTokens = 0, outputTokens = 0;
|
|
@@ -104,6 +117,7 @@ export class CloudBrokerAI {
|
|
|
104
117
|
model,
|
|
105
118
|
product: product || null,
|
|
106
119
|
team: team || null,
|
|
120
|
+
label: cbLabel,
|
|
107
121
|
inputTokens,
|
|
108
122
|
outputTokens,
|
|
109
123
|
totalTokens: inputTokens + outputTokens,
|
|
@@ -121,7 +135,7 @@ export class CloudBrokerAI {
|
|
|
121
135
|
}
|
|
122
136
|
|
|
123
137
|
// ── Não-streaming ───────────────────────────────────────────────────────
|
|
124
|
-
const res = await originalCreate(params,
|
|
138
|
+
const res = await originalCreate(params, fwdOptions);
|
|
125
139
|
const ms = Date.now() - start;
|
|
126
140
|
|
|
127
141
|
// Fire-and-forget
|
|
@@ -137,6 +151,7 @@ export class CloudBrokerAI {
|
|
|
137
151
|
model,
|
|
138
152
|
product: product || null,
|
|
139
153
|
team: team || null,
|
|
154
|
+
label: cbLabel,
|
|
140
155
|
inputTokens,
|
|
141
156
|
outputTokens,
|
|
142
157
|
totalTokens: inputTokens + outputTokens,
|
|
@@ -159,10 +174,13 @@ export class CloudBrokerAI {
|
|
|
159
174
|
|
|
160
175
|
client.messages.create = async (params, options) => {
|
|
161
176
|
const start = Date.now();
|
|
177
|
+
const cbLabel = options?.cbLabel || cbStore.getStore()?.label || null;
|
|
178
|
+
const { cbLabel: _l, ...providerOptions } = options || {};
|
|
179
|
+
const fwdOptions = Object.keys(providerOptions).length ? providerOptions : undefined;
|
|
162
180
|
|
|
163
181
|
// ── Streaming: acumula usage dos eventos message_start / message_delta ───
|
|
164
182
|
if (params?.stream) {
|
|
165
|
-
const stream = await originalCreate(params,
|
|
183
|
+
const stream = await originalCreate(params, fwdOptions);
|
|
166
184
|
|
|
167
185
|
let model = params.model || "unknown";
|
|
168
186
|
let inputTokens = 0, outputTokens = 0;
|
|
@@ -185,6 +203,7 @@ export class CloudBrokerAI {
|
|
|
185
203
|
model,
|
|
186
204
|
product: product || null,
|
|
187
205
|
team: team || null,
|
|
206
|
+
label: cbLabel,
|
|
188
207
|
inputTokens,
|
|
189
208
|
outputTokens,
|
|
190
209
|
totalTokens: inputTokens + outputTokens,
|
|
@@ -202,7 +221,7 @@ export class CloudBrokerAI {
|
|
|
202
221
|
}
|
|
203
222
|
|
|
204
223
|
// ── Não-streaming ───────────────────────────────────────────────────────
|
|
205
|
-
const res = await originalCreate(params,
|
|
224
|
+
const res = await originalCreate(params, fwdOptions);
|
|
206
225
|
const ms = Date.now() - start;
|
|
207
226
|
|
|
208
227
|
try {
|
|
@@ -217,6 +236,7 @@ export class CloudBrokerAI {
|
|
|
217
236
|
model,
|
|
218
237
|
product: product || null,
|
|
219
238
|
team: team || null,
|
|
239
|
+
label: cbLabel,
|
|
220
240
|
inputTokens,
|
|
221
241
|
outputTokens,
|
|
222
242
|
totalTokens: inputTokens + outputTokens,
|