cloudbroker-sdk 0.8.0 → 0.9.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/README.md CHANGED
@@ -349,6 +349,29 @@ await CloudBrokerCloud.trackExecution("job:fechamento-mensal", () => fecharMes()
349
349
  de um deploy pela versão que de fato rodou.
350
350
  - No shutdown: `await execucoes.flush(); execucoes.stop();`.
351
351
 
352
+ ### Custo por operação (0.9.0)
353
+
354
+ Cada execução ganha um `operationId` (`op_` + UUID v7, gerado localmente). As chamadas de nuvem e de IA feitas durante a execução levam esse id, e o CloudBroker passa a somar o custo de **uma** execução (ex.: S3 + DynamoDB + OpenAI de um `POST /checkout`).
355
+
356
+ - `expressMiddleware` abre uma operação por requisição, e `trackExecution`, uma por execução. Quem já usa um dos dois não precisa mudar nada.
357
+ - Para jobs, filas e operações de negócio:
358
+
359
+ ```js
360
+ import { CloudBroker } from "cloudbroker-sdk";
361
+
362
+ await CloudBroker.operation("process-order", { product: "commerce", team: "checkout" }, async () => {
363
+ await s3.send(new PutObjectCommand({ ... })); // herda o operationId
364
+ await openai.chat.completions.create({ ... }); // idem
365
+ });
366
+ ```
367
+
368
+ - **Aninhamento:** uma operação aberta dentro de outra recebe id próprio e `parentOperationId`. Cada evento conta só para a operação mais interna, então a soma não conta nada duas vezes.
369
+ - **Nome:** use nome de baixa cardinalidade (`"GET /users/:id"`, nunca `"/users/123"`). O nome da operação vira o trecho (`label`).
370
+ - **Escrita:** nenhum evento de início ou fim de operação é enviado. O custo da operação é a soma dos eventos que o SDK já manda.
371
+ - **Fora desta versão:** `tenantId` (o tenant é sempre o da API key), `customerId` e `metadata` (risco de dado pessoal).
372
+ - `CloudBroker.currentOperation()` devolve os ids da operação corrente, para correlacionar com o log da aplicação.
373
+ - A tela que agrega por operação chega numa versão futura do backend (RFC-0026). Até lá, o campo é enviado e ignorado sem erro.
374
+
352
375
  ## Garantias
353
376
 
354
377
  - **Fire-and-forget**: se o CloudBroker estiver fora, sua aplicação não é afetada
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudbroker-sdk",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "CloudBroker SDK — Token Intelligence & Operational Financial Intelligence",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,7 +12,7 @@
12
12
  ".": "./src/index.js"
13
13
  },
14
14
  "scripts": {
15
- "test": "node src/test.js && node src/test-executions.js && node src/test-azure-real.js && node src/test-gemini-real.js"
15
+ "test": "node src/test.js && node src/test-executions.js && node src/test-operations.js && node src/test-azure-real.js && node src/test-gemini-real.js"
16
16
  },
17
17
  "keywords": [
18
18
  "cloudbroker",
package/src/client.js CHANGED
@@ -48,7 +48,7 @@ export class CloudBrokerClient {
48
48
  try {
49
49
  const payload = {
50
50
  ...event,
51
- sdkVersion: "0.8.0",
51
+ sdkVersion: "0.9.0",
52
52
  sentAt: new Date().toISOString(),
53
53
  };
54
54
  if (this.#cloudQueue.length < MAX_QUEUE_SIZE) {
@@ -104,7 +104,7 @@ export class CloudBrokerClient {
104
104
  try {
105
105
  const payload = {
106
106
  ...event,
107
- sdkVersion: "0.8.0",
107
+ sdkVersion: "0.9.0",
108
108
  sentAt: new Date().toISOString(),
109
109
  };
110
110
 
@@ -35,7 +35,7 @@ import {
35
35
  AZURE_COSMOS_ITEMS_OPS,
36
36
  AZURE_COSMOS_ITEMS_ITERATOR_OPS,
37
37
  } from "./pricing-cloud.js";
38
- import { cbStore } from "./context.js";
38
+ import { cbStore, childOperationIds, operationFields } from "./context.js";
39
39
  import { resolveVersion } from "./executions.js";
40
40
 
41
41
  // Nomes de construtor do @azure/* → tipo do nó na cadeia de sub-clients.
@@ -177,6 +177,7 @@ export class CloudBrokerCloud {
177
177
  const cbLabel = sendOptions?.cbLabel || ctx.label || null;
178
178
  const evProduct = sendOptions?.cbProduct || ctx.product || product || null;
179
179
  const evTeam = sendOptions?.cbTeam || ctx.team || team || null;
180
+ const op = operationFields();
180
181
  const bytesIn = CloudBrokerCloud.#inputBytes(command?.input);
181
182
 
182
183
  // Passa adiante apenas as options nativas — nunca vaza campos cb* pro AWS SDK.
@@ -204,6 +205,7 @@ export class CloudBrokerCloud {
204
205
  latencyMs: Date.now() - start,
205
206
  region: clientRegion,
206
207
  version,
208
+ ...op,
207
209
  });
208
210
  } catch (err) {
209
211
  if (debug) console.warn("[CloudBroker] trackCloud error:", err.message);
@@ -243,7 +245,9 @@ export class CloudBrokerCloud {
243
245
 
244
246
  return (req, res, next) => {
245
247
  const safe = (fn) => { try { return fn(req); } catch { return undefined; } };
246
- const ctx = { label: safe(resolveLabel), product: safe(resolveProduct), team: safe(resolveTeam) };
248
+ // Uma operação por requisição (RFC-0026): os eventos de nuvem e IA feitos
249
+ // durante a requisição levam o mesmo operationId.
250
+ const ctx = { label: safe(resolveLabel), product: safe(resolveProduct), team: safe(resolveTeam), ...childOperationIds() };
247
251
  try {
248
252
  if (executions && typeof executions.count === "function") executions.count(ctx);
249
253
  } catch { /* contador nunca derruba a requisição */ }
@@ -264,7 +268,8 @@ export class CloudBrokerCloud {
264
268
  * @param {{product?: string, team?: string, executions?: import("./executions.js").ExecutionCounter}} [options]
265
269
  */
266
270
  static trackExecution(label, fn, options = {}) {
267
- const ctx = { label, product: options.product, team: options.team };
271
+ // Uma operação por execução (RFC-0026); dentro de outra operação, vira filha.
272
+ const ctx = { label, product: options.product, team: options.team, ...childOperationIds() };
268
273
  try {
269
274
  if (options.executions && typeof options.executions.count === "function") options.executions.count(ctx);
270
275
  } catch { /* contador nunca derruba a execução */ }
@@ -383,6 +388,7 @@ export class CloudBrokerCloud {
383
388
  latencyMs,
384
389
  region: env.region,
385
390
  version: env.version,
391
+ ...operationFields(),
386
392
  });
387
393
  } catch (err) {
388
394
  if (env.debug) console.warn("[CloudBroker] trackCloud error:", err.message);
package/src/context.js CHANGED
@@ -3,8 +3,14 @@
3
3
  * Permite atribuir um label de negócio (módulo / rota) a todas as chamadas de
4
4
  * nuvem disparadas dentro de uma requisição, sem precisar passar cbLabel manual
5
5
  * em cada `.send()`. Server-side apenas (Node).
6
+ *
7
+ * Desde a 0.9.0 (RFC-0026, custo por operação) o mesmo contexto carrega o
8
+ * `operationId` da execução corrente — não existe um segundo mecanismo de
9
+ * contexto. Os wrappers anexam esse id aos eventos, e o custo de UMA execução
10
+ * passa a ser um agrupamento na leitura (nenhuma escrita a mais).
6
11
  */
7
12
  import { AsyncLocalStorage } from "node:async_hooks";
13
+ import { randomBytes } from "node:crypto";
8
14
 
9
15
  export const cbStore = new AsyncLocalStorage();
10
16
 
@@ -12,3 +18,53 @@ export const cbStore = new AsyncLocalStorage();
12
18
  export function currentLabel() {
13
19
  return cbStore.getStore()?.label || null;
14
20
  }
21
+
22
+ /** Formato aceito para operationId: "op_" + UUID v7 em minúsculas. */
23
+ export const OPERATION_ID_RE = /^op_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
24
+
25
+ /**
26
+ * Gera um operationId local ("op_" + UUID v7), sem ida ao backend.
27
+ * UUID v7 começa pelo timestamp em ms, então ids ordenam por tempo — útil para
28
+ * a leitura por janela no armazenamento analítico. Nunca contém dado do cliente.
29
+ */
30
+ export function newOperationId(now = Date.now()) {
31
+ const b = randomBytes(16);
32
+ const ms = BigInt(Math.max(0, Math.floor(now)));
33
+ for (let i = 0; i < 6; i++) b[i] = Number((ms >> BigInt(8 * (5 - i))) & 0xffn);
34
+ b[6] = (b[6] & 0x0f) | 0x70; // versão 7
35
+ b[8] = (b[8] & 0x3f) | 0x80; // variante RFC 4122
36
+ const h = b.toString("hex");
37
+ return `op_${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
38
+ }
39
+
40
+ /**
41
+ * Ids de uma operação nova aberta DENTRO do contexto atual: o pai é a
42
+ * operação corrente, se houver. Nunca lança — sem id, a execução segue sem
43
+ * correlação, igual a antes da 0.9.0.
44
+ */
45
+ export function childOperationIds() {
46
+ try {
47
+ const parent = cbStore.getStore()?.operationId;
48
+ return { operationId: newOperationId(), ...(parent ? { parentOperationId: parent } : {}) };
49
+ } catch {
50
+ return {};
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Campos de operação para anexar a um evento. Vazio fora de uma operação, para
56
+ * que o evento saia idêntico ao formato anterior (compatibilidade V1).
57
+ * Chamar no INÍCIO da chamada instrumentada (como o label), não na emissão:
58
+ * o fim de um stream pode rodar fora da operação que o abriu.
59
+ */
60
+ export function operationFields() {
61
+ try {
62
+ const s = cbStore.getStore();
63
+ if (!s?.operationId) return {};
64
+ return s.parentOperationId
65
+ ? { operationId: s.operationId, parentOperationId: s.parentOperationId }
66
+ : { operationId: s.operationId };
67
+ } catch {
68
+ return {};
69
+ }
70
+ }
package/src/index.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * const openai = CloudBrokerAI.wrap(new OpenAI(), { apiKey: "cb-key-xxx", product: "checkout", team: "mobile" });
8
8
  */
9
9
 
10
+ export { CloudBroker } from "./operation.js";
10
11
  export { CloudBrokerAI } from "./wrapper.js";
11
12
  export { CloudBrokerCloud } from "./cloud-wrapper.js";
12
13
  export { CloudBrokerClient } from "./client.js";
@@ -0,0 +1,83 @@
1
+ /**
2
+ * CloudBroker.operation — custo por operação (0.9.0, RFC-0026).
3
+ *
4
+ * Abre uma operação lógica (job, fila, operação de negócio) no MESMO contexto
5
+ * AsyncLocalStorage do expressMiddleware/trackExecution: as chamadas de
6
+ * nuvem e IA feitas dentro do callback levam o mesmo `operationId`. Não há
7
+ * evento de início/fim de operação — o custo da operação é a soma, na leitura,
8
+ * dos eventos que já são enviados (nenhuma escrita a mais por execução).
9
+ *
10
+ * Uso:
11
+ * import { CloudBroker } from "cloudbroker-sdk";
12
+ * await CloudBroker.operation("process-order", { product: "commerce", team: "checkout" }, async () => {
13
+ * await s3.send(...); // wrapAWS herda o operationId
14
+ * await openai.chat.completions.create(...); // CloudBrokerAI.wrap também
15
+ * });
16
+ *
17
+ * Aninhada: a operação interna ganha id próprio e `parentOperationId` = o da
18
+ * externa; cada evento pertence só à operação mais interna (soma plana sem
19
+ * contagem dupla).
20
+ *
21
+ * De propósito FORA desta versão: tenantId (o tenant é sempre o da API key, no
22
+ * servidor), customerId e metadata (risco de dado pessoal — RFC-0026 §3).
23
+ */
24
+ import { cbStore, childOperationIds, operationFields } from "./context.js";
25
+
26
+ const OPERATION_TYPES = new Set(["http", "business", "job", "queue", "graphql", "database", "ai", "custom"]);
27
+
28
+ /** Texto curto e sem caractere de controle; qualquer outra coisa vira null. */
29
+ function text(v, max = 128) {
30
+ if (typeof v !== "string") return null;
31
+ const t = v.replace(/[\u0000-\u001F\u007F]/g, "").trim();
32
+ return t ? t.slice(0, max) : null;
33
+ }
34
+
35
+ export class CloudBroker {
36
+ /**
37
+ * @template T
38
+ * @param {string} name — nome da operação, de BAIXA cardinalidade
39
+ * ("process-order", "GET /users/:id"), nunca com id ou dado do usuário.
40
+ * Vira o label (trecho) das chamadas feitas dentro dela.
41
+ * @param {{label?: string, product?: string, team?: string, operationType?: string,
42
+ * executions?: import("./executions.js").ExecutionCounter}} [options]
43
+ * product/team ausentes herdam da operação externa.
44
+ * @param {() => T} callback — síncrono ou assíncrono
45
+ * @returns {T} o que o callback devolver (inclusive a Promise); o erro do
46
+ * callback propaga intacto. A instrumentação em si nunca lança.
47
+ */
48
+ static operation(name, options, callback) {
49
+ if (typeof options === "function" && callback === undefined) { callback = options; options = {}; }
50
+ if (typeof callback !== "function") {
51
+ throw new TypeError("[CloudBroker] operation(name, options, callback): callback deve ser uma função.");
52
+ }
53
+
54
+ let ctx = null;
55
+ try {
56
+ const parent = cbStore.getStore() || {};
57
+ const o = options && typeof options === "object" ? options : {};
58
+ const opName = text(name);
59
+ ctx = {
60
+ label: text(o.label) || opName || parent.label || null,
61
+ product: text(o.product) ?? parent.product ?? null,
62
+ team: text(o.team) ?? parent.team ?? null,
63
+ operationName: opName,
64
+ operationType: OPERATION_TYPES.has(o.operationType) ? o.operationType : "business",
65
+ ...childOperationIds(),
66
+ };
67
+ try {
68
+ if (o.executions && typeof o.executions.count === "function") o.executions.count(ctx);
69
+ } catch { /* contador nunca derruba a operação */ }
70
+ } catch {
71
+ ctx = null;
72
+ }
73
+ return ctx ? cbStore.run(ctx, callback) : callback();
74
+ }
75
+
76
+ /**
77
+ * Ids da operação corrente ({operationId, parentOperationId?}) ou `{}` fora
78
+ * de uma operação — ex.: para correlacionar com o log da própria aplicação.
79
+ */
80
+ static currentOperation() {
81
+ return operationFields();
82
+ }
83
+ }
package/src/pricing.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Tabela de preços por provider/modelo
3
3
  * Preços em USD por token
4
- * Atualizado: Mai/2026
4
+ * Atualizado: Mai/2026 (Anthropic conferida em Out/2026)
5
5
  */
6
6
  export const PRICING = {
7
7
  openai: {
@@ -16,10 +16,13 @@ export const PRICING = {
16
16
  "text-embedding-3-large": { input: 0.00000013, output: 0 },
17
17
  },
18
18
  anthropic: {
19
- "claude-opus-4-6": { input: 0.000015, output: 0.000075 },
19
+ // Conferidos em 2026-10-12 contra a tabela oficial da Anthropic. Até essa
20
+ // data o Haiku 4.5 estava com o preço do Haiku 3 (4x abaixo do real) e o
21
+ // Opus 4.6 com o do Opus 4/4.1 (3x acima).
22
+ "claude-opus-4-6": { input: 0.000005, output: 0.000025 },
20
23
  "claude-sonnet-4-6": { input: 0.000003, output: 0.000015 },
21
24
  "claude-sonnet-4-5": { input: 0.000003, output: 0.000015 },
22
- "claude-haiku-4-5-20251001":{ input: 0.00000025, output: 0.00000125 },
25
+ "claude-haiku-4-5-20251001":{ input: 0.000001, output: 0.000005 },
23
26
  "claude-3-opus-20240229": { input: 0.000015, output: 0.000075 },
24
27
  "claude-3-sonnet-20240229": { input: 0.000003, output: 0.000015 },
25
28
  "claude-3-haiku-20240307": { input: 0.00000025, output: 0.00000125 },
@@ -188,3 +191,83 @@ export function priceGemini(model, usage = {}) {
188
191
  : null,
189
192
  };
190
193
  }
194
+
195
+ // ── Anthropic ────────────────────────────────────────────────────────────────
196
+
197
+ /**
198
+ * Prompt caching da Anthropic: gravação no cache custa um múltiplo do preço de
199
+ * ENTRADA (1,25x com validade de 5 min, 2x com 1 h) e leitura, 0,1x. Os tokens
200
+ * de cache NÃO aparecem em `usage.input_tokens` — vêm em campos próprios.
201
+ * Calcular só sobre `input_tokens` subconta o custo assim que o cache é ligado.
202
+ */
203
+ export const ANTHROPIC_CACHE_MULTIPLIERS = Object.freeze({ write5m: 1.25, write1h: 2, read: 0.1 });
204
+
205
+ // Snapshot datado da Anthropic: `claude-haiku-4-5-20251001`.
206
+ const ANTHROPIC_VERSION_SUFFIX = /-\d{8}$/;
207
+
208
+ const nonNeg = (v) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : 0);
209
+
210
+ /**
211
+ * Custo de uma chamada da Messages API da Anthropic a partir do `usage`.
212
+ *
213
+ * Modelo fora da tabela sai com `costUSD: null` + `unpriced: true` — nunca o
214
+ * preço de outro modelo (antes caía no do Sonnet 4.6, o que superfaturava um
215
+ * Haiku em 3x e subfaturava um Opus). Snapshot com sufixo de data não listado
216
+ * usa o preço do modelo-base, declarado em `approxPricing`.
217
+ *
218
+ * `inputTokens` é o total de entrada processada (sem cache + gravado + lido),
219
+ * mesma semântica do Gemini (`promptTokenCount` já inclui o cache). A parte de
220
+ * cache vai também separada em `cacheWriteTokens`/`cacheReadTokens`.
221
+ */
222
+ export function priceAnthropic(model, usage = {}) {
223
+ const u = usage || {};
224
+ const plain = nonNeg(u.input_tokens);
225
+ const output = nonNeg(u.output_tokens);
226
+ const read = nonNeg(u.cache_read_input_tokens);
227
+ const br = u.cache_creation;
228
+ let write5m, write1h;
229
+ if (br && typeof br === "object" && ("ephemeral_5m_input_tokens" in br || "ephemeral_1h_input_tokens" in br)) {
230
+ write5m = nonNeg(br.ephemeral_5m_input_tokens);
231
+ write1h = nonNeg(br.ephemeral_1h_input_tokens);
232
+ } else {
233
+ write5m = nonNeg(u.cache_creation_input_tokens);
234
+ write1h = 0;
235
+ }
236
+ const inputTokens = plain + write5m + write1h + read;
237
+ const base = {
238
+ inputTokens,
239
+ outputTokens: output,
240
+ totalTokens: inputTokens + output,
241
+ cacheWriteTokens: write5m + write1h,
242
+ cacheReadTokens: read,
243
+ };
244
+
245
+ const table = PRICING.anthropic;
246
+ const name = typeof model === "string" ? model : "";
247
+ let id = null, approx = false;
248
+ if (name && Object.hasOwn(table, name)) {
249
+ id = name;
250
+ } else {
251
+ const b = name.replace(ANTHROPIC_VERSION_SUFFIX, "");
252
+ if (b !== name && Object.hasOwn(table, b)) { id = b; approx = true; }
253
+ }
254
+ if (!id) {
255
+ return {
256
+ ...base, costUSD: null, pricedAs: null, approxPricing: false, unpriced: true,
257
+ reason: `Modelo "${name.slice(0, 60) || "desconhecido"}" não está na tabela de preço da Anthropic — tokens registrados, custo não estimado.`,
258
+ };
259
+ }
260
+ const r = table[id];
261
+ const m = ANTHROPIC_CACHE_MULTIPLIERS;
262
+ const costUSD = plain * r.input
263
+ + (write5m * m.write5m + write1h * m.write1h + read * m.read) * r.input
264
+ + output * r.output;
265
+ return {
266
+ ...base,
267
+ costUSD: parseFloat(costUSD.toFixed(8)),
268
+ pricedAs: id,
269
+ approxPricing: approx,
270
+ unpriced: false,
271
+ reason: approx ? `Preço do modelo base "${id}" aplicado a "${name.slice(0, 60)}" — snapshot não listado na tabela.` : null,
272
+ };
273
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Custo por operação (0.9.0, RFC-0026). Execute: node src/test-operations.js
3
+ * Sem rede: fetch é substituído por um falso que registra o que seria enviado.
4
+ */
5
+ import assert from "node:assert";
6
+ import { readFileSync } from "node:fs";
7
+ import { CloudBroker, CloudBrokerCloud, CloudBrokerAI } from "./index.js";
8
+ import { cbStore, newOperationId, OPERATION_ID_RE, operationFields } from "./context.js";
9
+
10
+ let pass = 0;
11
+ const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log("✅", msg); };
12
+ const tick = () => new Promise((r) => setTimeout(r, 20));
13
+
14
+ const cloudSent = [];
15
+ const aiSent = [];
16
+ globalThis.fetch = async (url, opts) => {
17
+ const body = JSON.parse(opts.body);
18
+ if (String(url).includes("/api/cloud/events")) cloudSent.push(...body.events);
19
+ if (String(url).includes("/api/ai/events")) aiSent.push(...body.events);
20
+ return { ok: true, status: 200, json: async () => ({}) };
21
+ };
22
+
23
+ class S3Client {
24
+ constructor(cfg) { this.config = cfg; }
25
+ async send() { return { ContentLength: 10, $metadata: {} }; }
26
+ }
27
+ class PutObjectCommand { constructor(input) { this.input = input; } }
28
+ class OpenAI {
29
+ constructor() {
30
+ this.chat = { completions: { create: async (p) => ({ model: p.model, usage: { prompt_tokens: 100, completion_tokens: 50 } }) } };
31
+ }
32
+ }
33
+
34
+ const s3 = CloudBrokerCloud.wrapAWS(new S3Client({ region: "us-east-2" }), { apiKey: "cb-test", product: "loja" });
35
+ const openai = CloudBrokerAI.wrap(new OpenAI(), { apiKey: "cb-test", product: "loja" });
36
+ const put = () => s3.send(new PutObjectCommand({ Bucket: "b", Key: "k", Body: "x" }));
37
+ const ask = () => openai.chat.completions.create({ model: "gpt-4o-mini", messages: [] });
38
+
39
+ // ─── id ──────────────────────────────────────────────────────────────────────
40
+ {
41
+ const a = newOperationId(Date.parse("2026-10-17T12:00:00Z"));
42
+ const b = newOperationId(Date.parse("2026-10-17T12:00:01Z"));
43
+ ok(OPERATION_ID_RE.test(a) && OPERATION_ID_RE.test(b), "operationId = op_ + UUID v7 (versão 7, variante RFC 4122)");
44
+ ok(a < b, "ids ordenam por tempo (UUID v7 começa pelo timestamp)");
45
+ const many = new Set(Array.from({ length: 2000 }, () => newOperationId()));
46
+ ok(many.size === 2000, "2000 ids no mesmo ms não colidem");
47
+ }
48
+
49
+ // ─── critério de saída da Fase 1: S3 + IA no mesmo operationId ───────────────
50
+ {
51
+ cloudSent.length = 0; aiSent.length = 0;
52
+ let inside;
53
+ const r = await CloudBroker.operation("process-order", { team: "checkout" }, async () => {
54
+ inside = CloudBroker.currentOperation().operationId;
55
+ await put();
56
+ await ask();
57
+ return 42;
58
+ });
59
+ await tick();
60
+ ok(r === 42, "devolve o que o callback devolver");
61
+ ok(OPERATION_ID_RE.test(inside), "currentOperation() expõe o id dentro da operação");
62
+ ok(cloudSent.length === 1 && aiSent.length === 1, "1 evento de nuvem + 1 de IA (nenhum evento de ciclo de vida)");
63
+ ok(cloudSent[0].operationId === inside && aiSent[0].operationId === inside, "S3 e IA saem com o MESMO operationId");
64
+ ok(cloudSent[0].label === "process-order" && aiSent[0].label === "process-order", "nome da operação vira o label (trecho)");
65
+ ok(cloudSent[0].team === "checkout" && cloudSent[0].product === "loja", "team da operação; product estático do wrapAWS preservado");
66
+ ok(!("parentOperationId" in cloudSent[0]), "operação raiz não tem pai");
67
+ }
68
+
69
+ // ─── fora de operação: evento idêntico ao V1 ─────────────────────────────────
70
+ {
71
+ cloudSent.length = 0; aiSent.length = 0;
72
+ await put(); await ask(); await tick();
73
+ ok(!("operationId" in cloudSent[0]) && !("operationId" in aiSent[0]), "sem operação ativa o evento não ganha campo novo (V1)");
74
+ ok(Object.keys(operationFields()).length === 0 && Object.keys(CloudBroker.currentOperation()).length === 0, "fora de operação: {}");
75
+ }
76
+
77
+ // ─── aninhada: id próprio, pai, sem contagem dupla ───────────────────────────
78
+ {
79
+ cloudSent.length = 0;
80
+ let outerId, innerId;
81
+ await CloudBroker.operation("checkout", { product: "commerce" }, async () => {
82
+ outerId = CloudBroker.currentOperation().operationId;
83
+ await put();
84
+ await CloudBroker.operation("calculate-shipping", {}, async () => {
85
+ innerId = CloudBroker.currentOperation().operationId;
86
+ await put();
87
+ await put();
88
+ });
89
+ ok(CloudBroker.currentOperation().operationId === outerId, "ao sair da filha, volta à operação externa");
90
+ });
91
+ await tick();
92
+ const outer = cloudSent.filter((e) => e.operationId === outerId);
93
+ const inner = cloudSent.filter((e) => e.operationId === innerId);
94
+ ok(innerId !== outerId, "filha recebe operationId próprio");
95
+ ok(outer.length === 1 && inner.length === 2, "cada evento pertence só à operação mais interna (soma plana sem contagem dupla)");
96
+ ok(inner.every((e) => e.parentOperationId === outerId), "eventos da filha levam parentOperationId = externa");
97
+ ok(inner[0].product === "commerce" && inner[0].label === "calculate-shipping", "filha herda product da externa; label é o próprio nome");
98
+ }
99
+
100
+ // ─── middleware e trackExecution criam operação ──────────────────────────────
101
+ {
102
+ const mw = CloudBrokerCloud.expressMiddleware({ label: "POST /orders" });
103
+ const ids = [];
104
+ for (let i = 0; i < 2; i++) {
105
+ await new Promise((r) => mw({ method: "POST", path: "/orders" }, {}, () => { ids.push(cbStore.getStore().operationId); r(); }));
106
+ }
107
+ ok(ids.every((id) => OPERATION_ID_RE.test(id)) && ids[0] !== ids[1], "expressMiddleware: uma operação nova por requisição");
108
+
109
+ let jobId, jobParent;
110
+ await CloudBroker.operation("batch", {}, async () => {
111
+ const outer = CloudBroker.currentOperation().operationId;
112
+ await CloudBrokerCloud.trackExecution("job:item", async () => {
113
+ jobId = cbStore.getStore().operationId; jobParent = cbStore.getStore().parentOperationId;
114
+ });
115
+ ok(jobParent === outer && jobId !== outer, "trackExecution dentro de operação vira filha");
116
+ });
117
+ ok(OPERATION_ID_RE.test(jobId), "trackExecution gera operationId");
118
+ }
119
+
120
+ // ─── síncrono, erro, uso indevido ────────────────────────────────────────────
121
+ {
122
+ ok(CloudBroker.operation("sync", {}, () => 7) === 7, "callback síncrono devolve valor direto (sem Promise)");
123
+ ok(CloudBroker.operation("sem-options", () => "ok") === "ok", "options é opcional");
124
+ class MeuErro extends Error {}
125
+ let e1; try { CloudBroker.operation("x", {}, () => { throw new MeuErro("sync"); }); } catch (e) { e1 = e; }
126
+ ok(e1 instanceof MeuErro && e1.message === "sync", "erro síncrono do callback propaga com o mesmo tipo");
127
+ let e2; try { await CloudBroker.operation("x", {}, async () => { throw new MeuErro("async"); }); } catch (e) { e2 = e; }
128
+ ok(e2 instanceof MeuErro && e2.message === "async", "rejeição do callback propaga com o mesmo tipo");
129
+ let e3; try { CloudBroker.operation("x", {}); } catch (e) { e3 = e; }
130
+ ok(e3 instanceof TypeError, "sem callback: TypeError claro (erro de uso, não de instrumentação)");
131
+ ok(CloudBroker.operation({ evil: 1 }, null, () => "segue") === "segue", "nome/options inválidos não derrubam a execução");
132
+ const contadorQuebrado = { count() { throw new Error("x"); } };
133
+ ok(CloudBroker.operation("x", { executions: contadorQuebrado }, () => 1) === 1, "contador que lança não derruba a operação");
134
+ }
135
+
136
+ // ─── fronteira de privacidade (RFC-0026 §3) ──────────────────────────────────
137
+ {
138
+ cloudSent.length = 0;
139
+ await CloudBroker.operation("x\n\u0007forjado", { tenantId: "outro-tenant", customerId: "a@b.com", metadata: { cpf: "1" } }, async () => { await put(); });
140
+ await tick();
141
+ const ev = cloudSent[0];
142
+ ok(!("tenantId" in ev) && !("customerId" in ev) && !("metadata" in ev), "tenantId/customerId/metadata NÃO saem do SDK nesta fase");
143
+ ok(ev.label === "xforjado", "nome da operação sem caractere de controle (não forja linha de log)");
144
+ }
145
+
146
+ // ─── Azure e Anthropic também propagam ───────────────────────────────────────
147
+ {
148
+ class BlockBlobClient { async uploadData() { return { etag: "1" }; } }
149
+ class ContainerClient { getBlockBlobClient() { return new BlockBlobClient(); } }
150
+ class BlobServiceClient { getContainerClient() { return new ContainerClient(); } }
151
+ class Anthropic {
152
+ constructor() { this.messages = { create: async (p) => ({ model: p.model, usage: { input_tokens: 10, output_tokens: 5 } }) }; }
153
+ }
154
+ const blob = CloudBrokerCloud.wrapAzure(new BlobServiceClient(), { apiKey: "cb-test" });
155
+ const claude = CloudBrokerAI.wrap(new Anthropic(), { apiKey: "cb-test" });
156
+ cloudSent.length = 0; aiSent.length = 0;
157
+ let id;
158
+ await CloudBroker.operation("azure-op", {}, async () => {
159
+ id = CloudBroker.currentOperation().operationId;
160
+ await blob.getContainerClient("c").getBlockBlobClient("b").uploadData(Buffer.from("x"));
161
+ await claude.messages.create({ model: "claude-haiku-4-5", max_tokens: 10, messages: [] });
162
+ });
163
+ await tick();
164
+ ok(cloudSent[0]?.provider === "azure" && cloudSent[0].operationId === id, "wrapAzure propaga o operationId");
165
+ ok(aiSent[0]?.provider === "anthropic" && aiSent[0].operationId === id, "wrap Anthropic propaga o operationId");
166
+ }
167
+
168
+ // ─── versão anunciada = package.json ─────────────────────────────────────────
169
+ {
170
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
171
+ const client = readFileSync(new URL("./client.js", import.meta.url), "utf8");
172
+ ok(pkg.version === "0.9.0" && [...client.matchAll(/sdkVersion:\s*"([^"]+)"/g)].every((m) => m[1] === pkg.version), "sdkVersion 0.9.0 nos eventos");
173
+ }
174
+
175
+ console.log(`\n${pass} asserções ok — custo por operação`);
package/src/test.js CHANGED
@@ -489,5 +489,75 @@ console.warn = origWarn;
489
489
  assert.strictEqual(await untouched.foo(), 42);
490
490
  assert.ok(warns.some(w => w.includes("não reconhecido")), "deve avisar mesmo sem debug:true");
491
491
  console.log("✅ wrapAzure: client não reconhecido passa intocado, com aviso incondicional");
492
+ // 10) Anthropic: preço conferido (2026-10-12), tokens de cache e modelo
493
+ // desconhecido sem preço fabricado.
494
+ {
495
+ const { priceAnthropic, PRICING: P } = await import("./pricing.js");
496
+ assert.strictEqual(P.anthropic["claude-haiku-4-5-20251001"].input, 0.000001, "Haiku 4.5 entrada = US$ 1/M (não o preço do Haiku 3)");
497
+ assert.strictEqual(P.anthropic["claude-haiku-4-5-20251001"].output, 0.000005, "Haiku 4.5 saída = US$ 5/M");
498
+ assert.strictEqual(P.anthropic["claude-opus-4-6"].input, 0.000005, "Opus 4.6 entrada = US$ 5/M");
499
+ assert.strictEqual(P.anthropic["claude-opus-4-6"].output, 0.000025, "Opus 4.6 saída = US$ 25/M");
500
+
501
+ const h = priceAnthropic("claude-haiku-4-5-20251001", { input_tokens: 1_000_000, output_tokens: 1_000_000 });
502
+ assert.ok(Math.abs(h.costUSD - 6) < 1e-9, `1M+1M no Haiku 4.5 = US$ 6, veio ${h.costUSD}`);
503
+
504
+ const c = priceAnthropic("claude-haiku-4-5-20251001", {
505
+ input_tokens: 1000, output_tokens: 100, cache_creation_input_tokens: 10000, cache_read_input_tokens: 20000,
506
+ });
507
+ const esperado = 1000 * 1e-6 + 100 * 5e-6 + 10000 * 1e-6 * 1.25 + 20000 * 1e-6 * 0.1;
508
+ assert.ok(Math.abs(c.costUSD - esperado) < 1e-9, `gravação 1,25x e leitura 0,1x, esperado ${esperado}, veio ${c.costUSD}`);
509
+ assert.strictEqual(c.inputTokens, 31000, "inputTokens = sem cache + gravado + lido");
510
+ assert.strictEqual(c.cacheWriteTokens, 10000);
511
+ assert.strictEqual(c.cacheReadTokens, 20000);
512
+
513
+ const h1 = priceAnthropic("claude-haiku-4-5-20251001", {
514
+ cache_creation_input_tokens: 1000, cache_creation: { ephemeral_5m_input_tokens: 0, ephemeral_1h_input_tokens: 1000 },
515
+ });
516
+ assert.ok(Math.abs(h1.costUSD - 1000 * 1e-6 * 2) < 1e-12, "gravação com validade de 1 h custa 2x");
517
+
518
+ const d = priceAnthropic("claude-modelo-inventado", { input_tokens: 10, output_tokens: 10 });
519
+ assert.strictEqual(d.costUSD, null, "modelo desconhecido não recebe o preço de outro modelo");
520
+ assert.strictEqual(d.unpriced, true);
521
+ assert.strictEqual(d.totalTokens, 20, "tokens registrados mesmo sem preço");
522
+
523
+ const a = priceAnthropic("claude-sonnet-4-6-20991231", { input_tokens: 1000 });
524
+ assert.ok(a.approxPricing === true && a.pricedAs === "claude-sonnet-4-6", "snapshot datado não listado usa o modelo-base, declarado");
525
+
526
+ for (const nome of ["constructor", "__proto__", "toString", "hasOwnProperty"]) {
527
+ const v = priceAnthropic(nome, { input_tokens: 10 });
528
+ assert.strictEqual(v.costUSD, null, `nome venenoso "${nome}" não vira tabela de preço`);
529
+ }
530
+ const lixo = priceAnthropic("claude-haiku-4-5-20251001", { input_tokens: -5, output_tokens: "x", cache_read_input_tokens: NaN });
531
+ assert.strictEqual(lixo.costUSD, 0, "valor negativo ou não numérico conta como 0, nunca NaN");
532
+ assert.strictEqual(priceAnthropic(undefined, undefined).costUSD, null, "sem modelo e sem usage não lança");
533
+ console.log("✅ priceAnthropic: preço conferido, cache 1,25x/2x/0,1x, desconhecido = null");
534
+
535
+ // wrap: o evento enviado leva o custo com cache e o sinal de sem preço
536
+ const capt = [];
537
+ globalThis.fetch = async (url, opts) => {
538
+ if (String(url).includes("/api/ai/events")) capt.push(...JSON.parse(opts.body).events);
539
+ return { ok: true, status: 200, json: async () => ({}) };
540
+ };
541
+ const mockAnthropic = {
542
+ messages: {
543
+ create: async (params) => ({
544
+ model: params.model, content: [],
545
+ usage: { input_tokens: 1000, output_tokens: 100, cache_creation_input_tokens: 10000, cache_read_input_tokens: 20000 },
546
+ }),
547
+ },
548
+ };
549
+ const wa = CloudBrokerAI.wrap(mockAnthropic, { apiKey: "cb-test-key", product: "FinOps Advisor" });
550
+ await wa.messages.create({ model: "claude-haiku-4-5-20251001", max_tokens: 10, messages: [] }, { cbLabel: "finops-advisor:agent" });
551
+ await wa.messages.create({ model: "claude-modelo-inventado", max_tokens: 10, messages: [] });
552
+ await new Promise((r) => setTimeout(r, 50));
553
+ assert.strictEqual(capt.length, 2, `esperado 2 eventos de IA, veio ${capt.length}`);
554
+ assert.ok(Math.abs(capt[0].costUSD - esperado) < 1e-9, `evento leva o custo com cache, veio ${capt[0].costUSD}`);
555
+ assert.strictEqual(capt[0].cacheReadTokens, 20000);
556
+ assert.strictEqual(capt[0].label, "finops-advisor:agent");
557
+ assert.strictEqual(capt[1].costUSD, null, "evento de modelo desconhecido sai com costUSD null");
558
+ assert.strictEqual(capt[1].unpriced, true);
559
+ console.log("✅ CloudBrokerAI.wrap (Anthropic): custo com cache no evento, desconhecido = null");
560
+ }
561
+
492
562
  globalThis.fetch = realFetch;
493
563
  console.log("\n🎉 Todos os testes passaram.");
package/src/wrapper.js CHANGED
@@ -4,8 +4,36 @@
4
4
  */
5
5
 
6
6
  import { CloudBrokerClient } from "./client.js";
7
- import { PRICING, priceGemini } from "./pricing.js";
8
- import { cbStore } from "./context.js";
7
+ import { PRICING, priceGemini, priceAnthropic } from "./pricing.js";
8
+ import { cbStore, operationFields } from "./context.js";
9
+
10
+ /**
11
+ * Evento de uma chamada da Anthropic. O preço vem de `priceAnthropic`: inclui
12
+ * os tokens de cache e devolve `costUSD: null` + `unpriced` para modelo fora
13
+ * da tabela (nunca o preço de outro modelo).
14
+ */
15
+ function anthropicEvent(model, usage, { product, team, label, start, latencyMs, streaming }) {
16
+ const p = priceAnthropic(model, usage || {});
17
+ const ev = {
18
+ provider: "anthropic",
19
+ model,
20
+ product: product || null,
21
+ team: team || null,
22
+ label,
23
+ inputTokens: p.inputTokens,
24
+ outputTokens: p.outputTokens,
25
+ totalTokens: p.totalTokens,
26
+ costUSD: p.costUSD,
27
+ cacheWriteTokens: p.cacheWriteTokens,
28
+ cacheReadTokens: p.cacheReadTokens,
29
+ unpriced: p.unpriced,
30
+ pricingNote: p.reason,
31
+ latencyMs: latencyMs ?? (Date.now() - start),
32
+ endpoint: "messages",
33
+ };
34
+ if (streaming) ev.streaming = true;
35
+ return ev;
36
+ }
9
37
 
10
38
  export class CloudBrokerAI {
11
39
  /**
@@ -94,6 +122,7 @@ export class CloudBrokerAI {
94
122
  // Mesma precedência de cloud-wrapper.js: explícito na chamada > contexto
95
123
  // cbStore (expressMiddleware) > nenhum. Nunca vaza cbLabel pro SDK do provider.
96
124
  const cbLabel = options?.cbLabel || cbStore.getStore()?.label || null;
125
+ const op = operationFields(); // capturado no início, como o label (RFC-0026)
97
126
  const { cbLabel: _l, ...providerOptions } = options || {};
98
127
  const fwdOptions = Object.keys(providerOptions).length ? providerOptions : undefined;
99
128
 
@@ -131,6 +160,7 @@ export class CloudBrokerAI {
131
160
  latencyMs: Date.now() - start,
132
161
  endpoint: "chat.completions",
133
162
  streaming: true,
163
+ ...op,
134
164
  });
135
165
  } catch (err) {
136
166
  if (debug) console.warn("[CloudBroker] track error:", err.message);
@@ -164,6 +194,7 @@ export class CloudBrokerAI {
164
194
  costUSD: parseFloat(costUSD.toFixed(6)),
165
195
  latencyMs: ms,
166
196
  endpoint: "chat.completions",
197
+ ...op,
167
198
  });
168
199
  } catch (err) {
169
200
  if (debug) console.warn("[CloudBroker] track error:", err.message);
@@ -181,6 +212,7 @@ export class CloudBrokerAI {
181
212
  client.messages.create = async (params, options) => {
182
213
  const start = Date.now();
183
214
  const cbLabel = options?.cbLabel || cbStore.getStore()?.label || null;
215
+ const op = operationFields(); // capturado no início, como o label (RFC-0026)
184
216
  const { cbLabel: _l, ...providerOptions } = options || {};
185
217
  const fwdOptions = Object.keys(providerOptions).length ? providerOptions : undefined;
186
218
 
@@ -189,35 +221,20 @@ export class CloudBrokerAI {
189
221
  const stream = await originalCreate(params, fwdOptions);
190
222
 
191
223
  let model = params.model || "unknown";
192
- let inputTokens = 0, outputTokens = 0;
224
+ // usage de entrada (incl. tokens de cache) vem no message_start;
225
+ // message_delta traz o total acumulado de output_tokens.
226
+ let usage = {};
193
227
  const onChunk = (event) => {
194
228
  if (event?.type === "message_start" && event.message) {
195
- model = event.message.model || model;
196
- inputTokens = event.message.usage?.input_tokens || inputTokens;
197
- outputTokens = event.message.usage?.output_tokens || outputTokens;
229
+ model = event.message.model || model;
230
+ if (event.message.usage) usage = { ...event.message.usage };
198
231
  } else if (event?.type === "message_delta" && event.usage) {
199
- // message_delta traz o total acumulado de output_tokens
200
- outputTokens = event.usage.output_tokens || outputTokens;
232
+ if (event.usage.output_tokens != null) usage.output_tokens = event.usage.output_tokens;
201
233
  }
202
234
  };
203
235
  const onDone = () => {
204
236
  try {
205
- const pricing = PRICING.anthropic[model] || PRICING.anthropic["claude-sonnet-4-6"];
206
- const costUSD = (inputTokens * pricing.input) + (outputTokens * pricing.output);
207
- cb.track({
208
- provider: "anthropic",
209
- model,
210
- product: product || null,
211
- team: team || null,
212
- label: cbLabel,
213
- inputTokens,
214
- outputTokens,
215
- totalTokens: inputTokens + outputTokens,
216
- costUSD: parseFloat(costUSD.toFixed(6)),
217
- latencyMs: Date.now() - start,
218
- endpoint: "messages",
219
- streaming: true,
220
- });
237
+ cb.track({ ...anthropicEvent(model, usage, { product, team, label: cbLabel, start, streaming: true }), ...op });
221
238
  } catch (err) {
222
239
  if (debug) console.warn("[CloudBroker] track error:", err.message);
223
240
  }
@@ -231,25 +248,8 @@ export class CloudBrokerAI {
231
248
  const ms = Date.now() - start;
232
249
 
233
250
  try {
234
- const model = res.model || params.model || "unknown";
235
- const inputTokens = res.usage?.input_tokens || 0;
236
- const outputTokens = res.usage?.output_tokens || 0;
237
- const pricing = PRICING.anthropic[model] || PRICING.anthropic["claude-sonnet-4-6"];
238
- const costUSD = (inputTokens * pricing.input) + (outputTokens * pricing.output);
239
-
240
- cb.track({
241
- provider: "anthropic",
242
- model,
243
- product: product || null,
244
- team: team || null,
245
- label: cbLabel,
246
- inputTokens,
247
- outputTokens,
248
- totalTokens: inputTokens + outputTokens,
249
- costUSD: parseFloat(costUSD.toFixed(6)),
250
- latencyMs: ms,
251
- endpoint: "messages",
252
- });
251
+ const model = res.model || params.model || "unknown";
252
+ cb.track({ ...anthropicEvent(model, res.usage, { product, team, label: cbLabel, start, latencyMs: ms }), ...op });
253
253
  } catch (err) {
254
254
  if (debug) console.warn("[CloudBroker] track error:", err.message);
255
255
  }
@@ -328,14 +328,15 @@ export class CloudBrokerAI {
328
328
  models.generateContent = async (params, options) => {
329
329
  const start = Date.now();
330
330
  const { label, fwd } = splitLabel(params, options);
331
+ const op = operationFields();
331
332
 
332
333
  const res = await originalGenerate(fwd);
333
334
 
334
335
  try {
335
336
  const model = res?.modelVersion || params?.model || "unknown";
336
- cb.track(buildEvent(model, res?.usageMetadata, {
337
+ cb.track({ ...buildEvent(model, res?.usageMetadata, {
337
338
  label, start, streaming: false, endpoint: "models.generateContent",
338
- }));
339
+ }), ...op });
339
340
  } catch (err) {
340
341
  if (debug) console.warn("[CloudBroker] track error:", err.message);
341
342
  }
@@ -349,6 +350,7 @@ export class CloudBrokerAI {
349
350
  models.generateContentStream = async (params, options) => {
350
351
  const start = Date.now();
351
352
  const { label, fwd } = splitLabel(params, options);
353
+ const op = operationFields();
352
354
 
353
355
  const stream = await originalStream(fwd);
354
356
 
@@ -362,9 +364,9 @@ export class CloudBrokerAI {
362
364
  };
363
365
  const onDone = () => {
364
366
  try {
365
- cb.track(buildEvent(model, usage, {
367
+ cb.track({ ...buildEvent(model, usage, {
366
368
  label, start, streaming: true, endpoint: "models.generateContentStream",
367
- }));
369
+ }), ...op });
368
370
  } catch (err) {
369
371
  if (debug) console.warn("[CloudBroker] track error:", err.message);
370
372
  }