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/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, options);
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, options);
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, options);
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, options);
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,