cloudbroker-sdk 0.7.0 → 0.8.1

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.
@@ -1,6 +1,12 @@
1
1
  name: Publish to npm
2
2
 
3
3
  on:
4
+ # Testar também em PR: até aqui a suíte só rodava no caminho de publish, e
5
+ # publish só dispara em push no main que toque package.json. Ou seja, um PR
6
+ # que quebrasse o SDK não era pego por nada — o erro só apareceria na hora
7
+ # de lançar a versão. Mesmo formato adotado no backend (job `test` rodando
8
+ # em PR e push, com o job de entrega dependendo dele).
9
+ pull_request:
4
10
  push:
5
11
  branches: [main]
6
12
  paths:
@@ -16,31 +22,100 @@ on:
16
22
  workflow_dispatch:
17
23
 
18
24
  jobs:
25
+ test:
26
+ runs-on: ubuntu-latest
27
+ strategy:
28
+ # Não falha as demais versões quando uma quebra — saber que 24 passa e
29
+ # 20 não é a informação útil; abortar em cima da primeira esconde isso.
30
+ fail-fast: false
31
+ matrix:
32
+ # Este pacote roda DENTRO da aplicação de produção do cliente, então o
33
+ # que importa não é "o Node que a gente gosta", é a faixa que o cliente
34
+ # pode estar rodando.
35
+ #
36
+ # 20 é o piso: era a única versão testada até aqui, e sair dela sem
37
+ # aviso quebraria quem ainda está lá. Está EOL desde março/2026, e é
38
+ # exatamente por isso que ele não pode ser a ÚNICA — testar só nele
39
+ # significava nunca exercitar o SDK no Node que os clientes usam hoje.
40
+ node: [20, 22, 24]
41
+ name: test (node ${{ matrix.node }})
42
+
43
+ steps:
44
+ - uses: actions/checkout@v5
45
+
46
+ - name: Setup Node
47
+ uses: actions/setup-node@v5
48
+ with:
49
+ node-version: ${{ matrix.node }}
50
+
51
+ - name: Instalar dependências
52
+ run: npm ci || npm install
53
+
54
+ - name: Rodar testes
55
+ run: npm test
56
+
19
57
  publish:
58
+ needs: test
59
+ # Só publica no caminho de release. Sem esta guarda, o gatilho de
60
+ # pull_request acima tentaria publicar a cada PR.
61
+ if: github.event_name != 'pull_request'
20
62
  runs-on: ubuntu-latest
21
63
  permissions:
22
64
  contents: read
23
65
  id-token: write
24
66
 
25
67
  steps:
26
- - uses: actions/checkout@v4
68
+ - uses: actions/checkout@v5
27
69
 
28
70
  - name: Setup Node
29
- uses: actions/setup-node@v4
71
+ uses: actions/setup-node@v5
30
72
  with:
31
- node-version: 20
73
+ # LTS atual para o artefato publicado. A compatibilidade com as
74
+ # outras versões é garantida pela matriz do job `test` acima, que
75
+ # este job exige via `needs`.
76
+ node-version: 22
32
77
  registry-url: 'https://registry.npmjs.org'
33
78
 
34
- # Gate de teste antes do publish — este pacote roda DENTRO da aplicação
35
- # de produção do cliente; publicar sem rodar a suíte já era um risco
36
- # aberto (nenhum CI de teste existia neste repo).
37
- - name: Instalar dependências de teste
79
+ - name: Instalar dependências
38
80
  run: npm ci || npm install
39
81
 
40
- - name: Rodar testes
41
- run: npm test
82
+ # Trusted Publishing (OIDC): o npm confia neste workflow pelo token de
83
+ # identidade do GitHub (permissions.id-token: write acima) — sem NPM_TOKEN
84
+ # de longa duração. O publish por token falhava com EOTP desde 27/07
85
+ # (token que exige 2FA), e as versões saíam à mão, pulando a suíte.
86
+ # Exige npm >= 11.5.1; o Node 22 traz 10.x, e com npm antigo o publish
87
+ # falha sem dizer por quê. Versão fixa, a mesma do cloudbroker-mcp.
88
+ # Configuração do lado do npm (uma vez, pelo dono do pacote):
89
+ # npm trust github cloudbroker-sdk --file publish.yml \
90
+ # --repo cloudbroker-app/cloudbroker-sdk --allow-stage-publish
91
+ # O gatilho dispara em QUALQUER mudança no package.json, não só em troca
92
+ # de versão — o merge do PR #17 (só o campo `repository`) tentou
93
+ # republicar a 0.7.0 e deixou o main vermelho. Se a versão já está no
94
+ # registro, encerra com aviso. Só pula com confirmação positiva (o npm
95
+ # devolveu exatamente esta versão): 404 ou falha de rede seguem para o
96
+ # publish, que falha alto se houver problema de verdade.
97
+ - name: Versão já publicada?
98
+ id: published
99
+ run: |
100
+ NAME=$(node -p "require('./package.json').name")
101
+ VERSION=$(node -p "require('./package.json').version")
102
+ FOUND=$(npm view "$NAME@$VERSION" version 2>/dev/null || true)
103
+ if [ "$FOUND" = "$VERSION" ]; then
104
+ echo "::notice::$NAME@$VERSION já está no npm — nada a publicar (troque a versão para lançar)."
105
+ echo "skip=true" >> "$GITHUB_OUTPUT"
106
+ else
107
+ echo "skip=false" >> "$GITHUB_OUTPUT"
108
+ fi
109
+
110
+ - name: Atualizar npm (OIDC exige >= 11.5.1; npm stage vem no 11.19)
111
+ if: steps.published.outputs.skip != 'true'
112
+ run: npm install -g npm@11.19.1 && npm --version
42
113
 
43
- - name: Publish to npm
44
- run: npm publish
45
- env:
46
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
114
+ # Staged publishing: o CI só deixa a versão em STAGING (fora do ar); quem
115
+ # publica de fato é uma pessoa, com 2FA:
116
+ # npm stage list cloudbroker-sdk
117
+ # npm stage approve <id-do-staging>
118
+ # Sem NODE_AUTH_TOKEN: a autenticação vem do OIDC.
119
+ - name: Stage no npm (aprovação manual com 2FA)
120
+ if: steps.published.outputs.skip != 'true'
121
+ run: npm stage publish
package/README.md CHANGED
@@ -309,6 +309,46 @@ app.use(CloudBrokerCloud.expressMiddleware({
309
309
  Prioridade de cada campo: explícito no `.send()` (`cbLabel`/`cbProduct`/`cbTeam`) >
310
310
  contexto do middleware > valor estático do `wrapAWS` > `null`.
311
311
 
312
+ ### Custo por execução e versão do código (0.8.0)
313
+
314
+ Cada evento do `wrapAWS`/`wrapAzure` é uma **chamada de API**, não uma
315
+ **execução** do trecho. Se o custo de um trecho sobe, só com chamadas não dá
316
+ para saber se o código passou a chamar mais ou se veio mais tráfego. O
317
+ `ExecutionCounter` conta as execuções e fecha essa conta: **custo por
318
+ execução** e **chamadas por execução**.
319
+
320
+ ```js
321
+ import { CloudBrokerCloud, ExecutionCounter } from "cloudbroker-sdk";
322
+
323
+ const execucoes = new ExecutionCounter({ apiKey: process.env.CLOUDBROKER_API_KEY });
324
+
325
+ app.use(CloudBrokerCloud.expressMiddleware({
326
+ product: "checkout",
327
+ label: (req) => req.path.split("/").slice(0, 3).join("/"), // prefixo da rota ("/api/pedidos"), sem ids
328
+ executions: execucoes, // 1 execução por requisição
329
+ }));
330
+
331
+ // Fora do Express (jobs, filas, workers):
332
+ await CloudBrokerCloud.trackExecution("job:fechamento-mensal", () => fecharMes(), {
333
+ product: "financeiro", executions: execucoes,
334
+ });
335
+ ```
336
+
337
+ - A contagem fica em memória, agregada por trecho, app, versão e hora, e sai
338
+ em lote a cada 60 s (`flushIntervalMs`). Nada de rede no caminho da
339
+ requisição; o timer não segura o processo.
340
+ - **Use rótulo de baixa cardinalidade.** Um rótulo com id (`/api/pedidos/123`)
341
+ cria uma combinação por pedido; acima de 200 combinações pendentes o
342
+ excedente é descartado e somado em `execucoes.dropped`. O rótulo padrão do
343
+ middleware é o caminho cru — com `executions`, passe sempre um `label`.
344
+ (Middleware de `app.use` roda antes do roteamento: `req.route` ainda não
345
+ existe nele.)
346
+ - **Versão do código:** defina `CLOUDBROKER_VERSION` (ex.: o `GIT_SHA` do
347
+ build) ou passe `version` ao `ExecutionCounter` e ao `wrapAWS`/`wrapAzure`.
348
+ Ela vai nas execuções e nas chamadas, para comparar o trecho antes e depois
349
+ de um deploy pela versão que de fato rodou.
350
+ - No shutdown: `await execucoes.flush(); execucoes.stop();`.
351
+
312
352
  ## Garantias
313
353
 
314
354
  - **Fire-and-forget**: se o CloudBroker estiver fora, sua aplicação não é afetada
package/package.json CHANGED
@@ -1,14 +1,18 @@
1
1
  {
2
2
  "name": "cloudbroker-sdk",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "CloudBroker SDK — Token Intelligence & Operational Financial Intelligence",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/cloudbroker-app/cloudbroker-sdk.git"
8
+ },
5
9
  "main": "src/index.js",
6
10
  "type": "module",
7
11
  "exports": {
8
12
  ".": "./src/index.js"
9
13
  },
10
14
  "scripts": {
11
- "test": "node src/test.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-azure-real.js && node src/test-gemini-real.js"
12
16
  },
13
17
  "keywords": [
14
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.7.0",
51
+ sdkVersion: "0.8.1",
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.7.0",
107
+ sdkVersion: "0.8.1",
108
108
  sentAt: new Date().toISOString(),
109
109
  };
110
110
 
@@ -36,6 +36,7 @@ import {
36
36
  AZURE_COSMOS_ITEMS_ITERATOR_OPS,
37
37
  } from "./pricing-cloud.js";
38
38
  import { cbStore } from "./context.js";
39
+ import { resolveVersion } from "./executions.js";
39
40
 
40
41
  // Nomes de construtor do @azure/* → tipo do nó na cadeia de sub-clients.
41
42
  const AZURE_BLOB_KIND = {
@@ -149,10 +150,12 @@ export class CloudBrokerCloud {
149
150
  * @param {string} [options.team] — nome do squad (ex: "mobile")
150
151
  * @param {string} [options.region="us-east-2"]
151
152
  * @param {string} [options.endpoint] — endpoint de eventos de nuvem (opcional)
153
+ * @param {string} [options.version] — versão do código (padrão: env CLOUDBROKER_VERSION, ex.: o GIT_SHA do build)
152
154
  * @param {boolean} [options.debug=false]
153
155
  */
154
156
  static wrapAWS(client, options = {}) {
155
157
  const { apiKey, product, team, region = "us-east-2", endpoint, debug = false } = options;
158
+ const version = resolveVersion(options.version);
156
159
  if (!apiKey) throw new Error("[CloudBroker] apiKey é obrigatório.");
157
160
 
158
161
  if (!client || typeof client.send !== "function") {
@@ -200,6 +203,7 @@ export class CloudBrokerCloud {
200
203
  billingUnit,
201
204
  latencyMs: Date.now() - start,
202
205
  region: clientRegion,
206
+ version,
203
207
  });
204
208
  } catch (err) {
205
209
  if (debug) console.warn("[CloudBroker] trackCloud error:", err.message);
@@ -221,6 +225,9 @@ export class CloudBrokerCloud {
221
225
  * @param {string|((req)=>string)} [options.label] — trecho (default: "MÉTODO /caminho")
222
226
  * @param {string|((req)=>string)} [options.product] — App (sobrescreve o product do wrapAWS)
223
227
  * @param {string|((req)=>string)} [options.team] — Grupo (sobrescreve o team do wrapAWS)
228
+ * @param {import("./executions.js").ExecutionCounter} [options.executions] — se informado,
229
+ * conta UMA execução do trecho por requisição (custo por execução, Passo 2).
230
+ * Use rótulo de baixa cardinalidade (módulo/rota com padrão), nunca o caminho cru com ids.
224
231
  * @returns {(req,res,next)=>void}
225
232
  */
226
233
  static expressMiddleware(options = {}) {
@@ -232,12 +239,38 @@ export class CloudBrokerCloud {
232
239
  const resolveProduct = mk(options.product, () => undefined);
233
240
  const resolveTeam = mk(options.team, () => undefined);
234
241
 
242
+ const executions = options.executions;
243
+
235
244
  return (req, res, next) => {
236
245
  const safe = (fn) => { try { return fn(req); } catch { return undefined; } };
237
- cbStore.run({ label: safe(resolveLabel), product: safe(resolveProduct), team: safe(resolveTeam) }, () => next());
246
+ const ctx = { label: safe(resolveLabel), product: safe(resolveProduct), team: safe(resolveTeam) };
247
+ try {
248
+ if (executions && typeof executions.count === "function") executions.count(ctx);
249
+ } catch { /* contador nunca derruba a requisição */ }
250
+ cbStore.run(ctx, () => next());
238
251
  };
239
252
  }
240
253
 
254
+ /**
255
+ * Roda `fn` como UMA execução do trecho `label`, para código fora do
256
+ * Express (jobs, filas, workers): as chamadas de nuvem/IA feitas dentro de
257
+ * `fn` herdam o trecho (mesmo contexto do expressMiddleware) e, com
258
+ * `options.executions`, a execução é contada. Devolve o que `fn` devolver
259
+ * (inclusive a Promise) e propaga o erro de `fn` — a instrumentação em si
260
+ * nunca lança.
261
+ *
262
+ * @param {string} label — trecho (baixa cardinalidade, ex.: "job:fechamento")
263
+ * @param {Function} fn
264
+ * @param {{product?: string, team?: string, executions?: import("./executions.js").ExecutionCounter}} [options]
265
+ */
266
+ static trackExecution(label, fn, options = {}) {
267
+ const ctx = { label, product: options.product, team: options.team };
268
+ try {
269
+ if (options.executions && typeof options.executions.count === "function") options.executions.count(ctx);
270
+ } catch { /* contador nunca derruba a execução */ }
271
+ return cbStore.run(ctx, fn);
272
+ }
273
+
241
274
  /**
242
275
  * Envolve um client do Azure SDK (Blob Storage / Cosmos DB) e rastreia cada
243
276
  * chamada de data-plane como evento de custo — mesmo contrato do wrapAWS.
@@ -272,6 +305,7 @@ export class CloudBrokerCloud {
272
305
  */
273
306
  static wrapAzure(client, options = {}) {
274
307
  const { apiKey, product, team, region = "eastus2", endpoint, debug = false } = options;
308
+ const version = resolveVersion(options.version);
275
309
  if (!apiKey) throw new Error("[CloudBroker] apiKey é obrigatório.");
276
310
 
277
311
  const name = client?.constructor?.name || "";
@@ -295,7 +329,7 @@ export class CloudBrokerCloud {
295
329
  }
296
330
 
297
331
  const cb = new CloudBrokerClient({ apiKey, cloudEndpoint: endpoint, debug });
298
- const env = { cb, product, team, region, debug };
332
+ const env = { cb, product, team, region, debug, version };
299
333
  return blobKind
300
334
  ? CloudBrokerCloud.#wrapBlobNode(client, env)
301
335
  : CloudBrokerCloud.#wrapCosmosNode(client, env, cosmosKind);
@@ -348,6 +382,7 @@ export class CloudBrokerCloud {
348
382
  billingUnit,
349
383
  latencyMs,
350
384
  region: env.region,
385
+ version: env.version,
351
386
  });
352
387
  } catch (err) {
353
388
  if (env.debug) console.warn("[CloudBroker] trackCloud error:", err.message);
@@ -0,0 +1,152 @@
1
+ /**
2
+ * ExecutionCounter — contador de EXECUÇÕES de trecho de código (0.8.0).
3
+ *
4
+ * Por que existe: o wrapAWS/wrapAzure grava um evento por CHAMADA de API, não
5
+ * por execução do trecho. Sem o número de execuções, "o código passou a chamar
6
+ * mais" e "veio mais tráfego" são indistinguíveis — o custo por chamada é
7
+ * quase constante por construção. Este contador é o denominador que separa os
8
+ * dois (custo por execução, chamadas por execução).
9
+ *
10
+ * Como: contagem EM MEMÓRIA, agregada por (trecho, app, versão, hora UTC), e
11
+ * enviada em lote a cada `flushIntervalMs` para POST /api/cloud/executions.
12
+ * Uma requisição = uma soma num Map; nada de rede no caminho da aplicação.
13
+ *
14
+ * Garantias (iguais às do resto do SDK):
15
+ * • nunca lança para a aplicação do cliente;
16
+ * • timer com `.unref()` — não segura o processo (Lambda, cron, CLI);
17
+ * • memória limitada: no máximo `maxKeys` combinações pendentes; o excedente
18
+ * é descartado e contado em `dropped` (rótulo de alta cardinalidade, como
19
+ * uma rota com id no caminho, não pode crescer sem limite);
20
+ * • contagem com mais de 7 dias sem conseguir enviar é descartada (o backend
21
+ * recusa, e reenviar para sempre seria inútil).
22
+ *
23
+ * Uso:
24
+ * const execucoes = new ExecutionCounter({ apiKey: "cb-key-xxx" }); // version ← CLOUDBROKER_VERSION
25
+ * app.use(CloudBrokerCloud.expressMiddleware({ label: ..., executions: execucoes }));
26
+ * await CloudBrokerCloud.trackExecution("job:fechamento", () => fecharMes(), { executions: execucoes });
27
+ */
28
+
29
+ const DEFAULT_EXECUTIONS_ENDPOINT = "https://api.cloudbroker.app.br/api/cloud/executions";
30
+ const HOUR_MS = 3_600_000;
31
+ const MAX_AGE_MS = 7 * 24 * HOUR_MS;
32
+
33
+ /** Versão do código do cliente: explícita > CLOUDBROKER_VERSION > null. */
34
+ export function resolveVersion(explicit) {
35
+ const v = typeof explicit === "string" && explicit.trim() ? explicit
36
+ : (typeof process !== "undefined" ? process.env?.CLOUDBROKER_VERSION : undefined);
37
+ return typeof v === "string" && v.trim() ? v.trim().slice(0, 64) : null;
38
+ }
39
+
40
+ function hourStartIso(ms) {
41
+ return new Date(Math.floor(ms / HOUR_MS) * HOUR_MS).toISOString();
42
+ }
43
+
44
+ export class ExecutionCounter {
45
+ #apiKey;
46
+ #endpoint;
47
+ #version;
48
+ #debug;
49
+ #maxKeys;
50
+ #now;
51
+ #pending = new Map();
52
+ #flushing = false;
53
+ #timer = null;
54
+ dropped = 0;
55
+
56
+ /**
57
+ * @param {object} options
58
+ * @param {string} options.apiKey — API key CloudBroker do tipo SDK
59
+ * @param {string} [options.version] — versão do código (padrão: CLOUDBROKER_VERSION)
60
+ * @param {string} [options.endpoint]
61
+ * @param {number} [options.flushIntervalMs=60000]
62
+ * @param {number} [options.maxKeys=200] — o backend recusa lote com mais de 200
63
+ * @param {boolean} [options.debug=false]
64
+ */
65
+ constructor({ apiKey, version, endpoint = DEFAULT_EXECUTIONS_ENDPOINT, flushIntervalMs = 60_000, maxKeys = 200, debug = false, now } = {}) {
66
+ if (!apiKey) throw new Error("[CloudBroker] apiKey é obrigatório.");
67
+ this.#apiKey = apiKey;
68
+ this.#endpoint = endpoint;
69
+ this.#version = resolveVersion(version);
70
+ this.#debug = debug;
71
+ this.#maxKeys = Math.max(1, Math.min(200, Math.floor(maxKeys) || 200));
72
+ this.#now = typeof now === "function" ? now : Date.now;
73
+ if (flushIntervalMs > 0) {
74
+ this.#timer = setInterval(() => { this.flush(); }, flushIntervalMs);
75
+ if (typeof this.#timer?.unref === "function") this.#timer.unref();
76
+ }
77
+ }
78
+
79
+ /** Versão que este contador anexa às execuções. */
80
+ get version() { return this.#version; }
81
+
82
+ /** Combinações pendentes de envio (para diagnóstico/teste). */
83
+ get pendingKeys() { return this.#pending.size; }
84
+
85
+ /**
86
+ * Soma uma execução do trecho. Nunca lança.
87
+ * @param {{label?: string, product?: string}} ctx
88
+ */
89
+ count(ctx) {
90
+ try {
91
+ const label = ctx?.label, product = ctx?.product;
92
+ if (typeof label !== "string" || !label.trim()) return;
93
+ const hour = hourStartIso(this.#now());
94
+ const prod = typeof product === "string" && product.trim() ? product : null;
95
+ const key = `${hour}\u0000${label}\u0000${prod || ""}`;
96
+ const cur = this.#pending.get(key);
97
+ if (cur) { cur.count++; return; }
98
+ if (this.#pending.size >= this.#maxKeys) { this.dropped++; return; }
99
+ this.#pending.set(key, { label, product: prod, version: this.#version, hour, count: 1 });
100
+ } catch (err) {
101
+ if (this.#debug) console.warn("[CloudBroker] executions.count error:", err.message);
102
+ }
103
+ }
104
+
105
+ /** Envia o pendente. Nunca lança; em falha transitória, a contagem volta para a fila. */
106
+ async flush() {
107
+ if (this.#flushing || this.#pending.size === 0) return;
108
+ this.#flushing = true;
109
+ const batch = [...this.#pending.values()];
110
+ this.#pending = new Map();
111
+ let devolver = false;
112
+ try {
113
+ const res = await fetch(this.#endpoint, {
114
+ method: "POST",
115
+ headers: { "Content-Type": "application/json", "x-cloudbroker-key": this.#apiKey },
116
+ body: JSON.stringify({ executions: batch }),
117
+ signal: AbortSignal.timeout(5000),
118
+ });
119
+ if (!res.ok) {
120
+ // 4xx (exceto 429) é recusa definitiva — reenviar o mesmo lote não muda nada.
121
+ devolver = res.status === 429 || res.status >= 500;
122
+ if (this.#debug) console.warn(`[CloudBroker] executions flush failed: ${res.status}${devolver ? " (vai tentar de novo)" : " (descartado)"}`);
123
+ } else if (this.#debug) {
124
+ console.log(`[CloudBroker] flushed ${batch.length} execution buckets`);
125
+ }
126
+ } catch (err) {
127
+ devolver = true;
128
+ if (this.#debug) console.warn("[CloudBroker] executions flush error:", err.message);
129
+ } finally {
130
+ if (devolver) this.#requeue(batch);
131
+ this.#flushing = false;
132
+ }
133
+ }
134
+
135
+ #requeue(batch) {
136
+ const limite = this.#now() - MAX_AGE_MS;
137
+ for (const it of batch) {
138
+ if (Date.parse(it.hour) < limite) { this.dropped += it.count; continue; }
139
+ const key = `${it.hour}\u0000${it.label}\u0000${it.product || ""}`;
140
+ const cur = this.#pending.get(key);
141
+ if (cur) cur.count += it.count;
142
+ else if (this.#pending.size < this.#maxKeys) this.#pending.set(key, it);
143
+ else this.dropped += it.count;
144
+ }
145
+ }
146
+
147
+ /** Para o timer (ex.: no shutdown, depois de um `await flush()`). */
148
+ stop() {
149
+ if (this.#timer) clearInterval(this.#timer);
150
+ this.#timer = null;
151
+ }
152
+ }
package/src/index.js CHANGED
@@ -10,4 +10,5 @@
10
10
  export { CloudBrokerAI } from "./wrapper.js";
11
11
  export { CloudBrokerCloud } from "./cloud-wrapper.js";
12
12
  export { CloudBrokerClient } from "./client.js";
13
+ export { ExecutionCounter } from "./executions.js";
13
14
  export { estimateCloudCost, CLOUD_PRICING } from "./pricing-cloud.js";
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,190 @@
1
+ /**
2
+ * Contador de execuções (0.8.0). Execute: node src/test-executions.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 { ExecutionCounter, resolveVersion } from "./executions.js";
8
+ import { CloudBrokerCloud } from "./cloud-wrapper.js";
9
+ import { cbStore } from "./context.js";
10
+
11
+ let pass = 0;
12
+ const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log("✅", msg); };
13
+
14
+ const realFetch = globalThis.fetch;
15
+ const sent = [];
16
+ let respond = () => ({ ok: true, status: 200 });
17
+ globalThis.fetch = async (url, opts) => {
18
+ sent.push({ url, headers: opts.headers, body: JSON.parse(opts.body) });
19
+ const r = respond();
20
+ if (r instanceof Error) throw r;
21
+ return r;
22
+ };
23
+
24
+ const T0 = Date.parse("2026-10-12T15:42:10.000Z");
25
+ let clock = T0;
26
+ const mk = (o = {}) => new ExecutionCounter({ apiKey: "cb-test", flushIntervalMs: 0, now: () => clock, ...o });
27
+
28
+ // ─── versão ──────────────────────────────────────────────────────────────────
29
+ delete process.env.CLOUDBROKER_VERSION;
30
+ ok(resolveVersion() === null, "sem versão e sem env → null (nunca inventa versão)");
31
+ process.env.CLOUDBROKER_VERSION = " abc1234 ";
32
+ ok(resolveVersion() === "abc1234", "versão vem de CLOUDBROKER_VERSION, sem espaços");
33
+ ok(resolveVersion("v2") === "v2", "versão explícita vence o env");
34
+ ok(resolveVersion("x".repeat(100)).length === 64, "versão com teto de 64");
35
+
36
+ // ─── agregação ───────────────────────────────────────────────────────────────
37
+ {
38
+ const c = mk();
39
+ c.count({ label: "checkout", product: "Loja" });
40
+ c.count({ label: "checkout", product: "Loja" });
41
+ c.count({ label: "checkout", product: "Loja" });
42
+ c.count({ label: "checkout", product: "Outro" });
43
+ ok(c.pendingKeys === 2, "mesmo trecho+app+hora vira 1 combinação; app diferente, outra");
44
+ c.count({ label: "" }); c.count({}); c.count(null); c.count({ label: 42 });
45
+ ok(c.pendingKeys === 2, "sem rótulo não conta e não lança");
46
+ await c.flush();
47
+ const body = sent.at(-1).body.executions;
48
+ const loja = body.find((e) => e.product === "Loja");
49
+ ok(loja.count === 3 && loja.hour === "2026-10-12T15:00:00.000Z" && loja.version === "abc1234", "lote enviado: contagem somada, início da hora em UTC, versão anexada");
50
+ ok(sent.at(-1).url.endsWith("/api/cloud/executions") && sent.at(-1).headers["x-cloudbroker-key"] === "cb-test", "endpoint e cabeçalho da key");
51
+ ok(c.pendingKeys === 0, "enviado com sucesso → fila vazia");
52
+
53
+ clock = T0 + 3_600_000;
54
+ c.count({ label: "checkout", product: "Loja" });
55
+ clock = T0;
56
+ c.count({ label: "checkout", product: "Loja" });
57
+ ok(c.pendingKeys === 2, "horas diferentes são combinações diferentes");
58
+ }
59
+
60
+ // ─── teto de memória ─────────────────────────────────────────────────────────
61
+ {
62
+ const c = mk({ maxKeys: 3 });
63
+ for (let i = 0; i < 5; i++) c.count({ label: `/api/apps/${i}` });
64
+ c.count({ label: "/api/apps/0" });
65
+ ok(c.pendingKeys === 3 && c.dropped === 2, "acima do teto de combinações descarta e conta em dropped (rótulo com id não cresce sem limite)");
66
+ const big = mk({ maxKeys: 5000 });
67
+ for (let i = 0; i < 250; i++) big.count({ label: `t${i}` });
68
+ ok(big.pendingKeys === 200 && big.dropped === 50, "maxKeys efetivo nunca passa de 200");
69
+ }
70
+
71
+ // ─── falha de envio ──────────────────────────────────────────────────────────
72
+ {
73
+ const c = mk();
74
+ c.count({ label: "a" }); c.count({ label: "a" });
75
+ respond = () => new Error("ECONNRESET");
76
+ await c.flush();
77
+ ok(c.pendingKeys === 1, "erro de rede → a contagem volta para a fila");
78
+ c.count({ label: "a" });
79
+ respond = () => ({ ok: false, status: 503 });
80
+ await c.flush();
81
+ respond = () => ({ ok: true, status: 200 });
82
+ await c.flush();
83
+ ok(sent.at(-1).body.executions[0].count === 3, "503 também volta para a fila, e a contagem não se perde nem duplica");
84
+
85
+ c.count({ label: "b" });
86
+ respond = () => ({ ok: false, status: 400 });
87
+ await c.flush();
88
+ ok(c.pendingKeys === 0, "4xx é recusa definitiva → descartado, sem reenviar para sempre");
89
+ respond = () => ({ ok: false, status: 429 });
90
+ c.count({ label: "b" });
91
+ await c.flush();
92
+ ok(c.pendingKeys === 1, "429 volta para a fila");
93
+ respond = () => ({ ok: true, status: 200 });
94
+
95
+ const velho = mk();
96
+ velho.count({ label: "v" });
97
+ respond = () => new Error("offline");
98
+ clock = T0 + 8 * 24 * 3_600_000; // o flush falha 8 dias depois
99
+ await velho.flush();
100
+ clock = T0;
101
+ respond = () => ({ ok: true, status: 200 });
102
+ ok(velho.pendingKeys === 0 && velho.dropped === 1, "contagem com mais de 7 dias não volta para a fila (o backend recusaria)");
103
+ }
104
+
105
+ // ─── nunca lança, e não segura o processo ────────────────────────────────────
106
+ {
107
+ // Timer padrão (60s) SEM stop(): um processo filho precisa terminar sozinho.
108
+ // Sem o .unref(), o timer seguraria o event loop e o filho não sairia.
109
+ const { spawnSync } = await import("node:child_process");
110
+ const filho = spawnSync(process.execPath, ["--input-type=module", "-e",
111
+ `import { ExecutionCounter } from ${JSON.stringify(new URL("./executions.js", import.meta.url).href)};
112
+ const c = new ExecutionCounter({ apiKey: "cb-test" }); c.count({ label: "x" });`], { timeout: 10_000 });
113
+ ok(filho.status === 0 && !filho.error, "o timer do contador não segura o processo (Lambda, cron, CLI terminam sozinhos)");
114
+
115
+ const c = mk();
116
+ respond = () => { throw new TypeError("fetch quebrado"); };
117
+ c.count({ label: "x" });
118
+ await c.flush();
119
+ respond = () => ({ ok: true, status: 200 });
120
+ ok(true, "fetch que lança não escapa do flush");
121
+ c.stop();
122
+ assert.throws(() => new ExecutionCounter({}), /apiKey/);
123
+ ok(true, "sem apiKey lança na construção (erro de configuração, não de execução)");
124
+ }
125
+
126
+ // ─── expressMiddleware ───────────────────────────────────────────────────────
127
+ {
128
+ const c = mk();
129
+ const mw = CloudBrokerCloud.expressMiddleware({ label: (req) => `mod:${req.path}`, product: "Loja", executions: c });
130
+ let vistoNoContexto;
131
+ await new Promise((r) => mw({ method: "GET", path: "/x" }, {}, () => { vistoNoContexto = cbStore.getStore(); r(); }));
132
+ await new Promise((r) => mw({ method: "GET", path: "/x" }, {}, r));
133
+ await c.flush();
134
+ const ex = sent.at(-1).body.executions;
135
+ ok(ex.length === 1 && ex[0].label === "mod:/x" && ex[0].product === "Loja" && ex[0].count === 2, "uma execução por requisição, com o mesmo trecho/app do contexto");
136
+ ok(vistoNoContexto?.label === "mod:/x", "o contexto continua chegando ao handler");
137
+
138
+ const quebrado = { count() { throw new Error("boom"); } };
139
+ const mw2 = CloudBrokerCloud.expressMiddleware({ label: "a", executions: quebrado });
140
+ let passou = false;
141
+ mw2({ method: "GET", path: "/" }, {}, () => { passou = true; });
142
+ ok(passou, "contador que lança não derruba a requisição (next é chamado)");
143
+ const mw3 = CloudBrokerCloud.expressMiddleware({ label: "a" });
144
+ let chamou = false;
145
+ mw3({ method: "GET", path: "/" }, {}, () => { chamou = true; });
146
+ ok(chamou, "sem `executions`, o middleware se comporta como antes");
147
+ }
148
+
149
+ // ─── trackExecution ──────────────────────────────────────────────────────────
150
+ {
151
+ const c = mk();
152
+ const r1 = CloudBrokerCloud.trackExecution("job:fechamento", () => cbStore.getStore().label, { executions: c, product: "Financeiro" });
153
+ ok(r1 === "job:fechamento", "fn roda dentro do contexto do trecho e o retorno é devolvido");
154
+ const r2 = await CloudBrokerCloud.trackExecution("job:fechamento", async () => 7, { executions: c, product: "Financeiro" });
155
+ ok(r2 === 7, "fn assíncrona: devolve a Promise");
156
+ let erro;
157
+ try { await CloudBrokerCloud.trackExecution("job:fechamento", async () => { throw new Error("falha do job"); }, { executions: c, product: "Financeiro" }); } catch (e) { erro = e; }
158
+ ok(erro?.message === "falha do job", "erro de fn propaga intacto (a instrumentação não o engole)");
159
+ await c.flush();
160
+ ok(sent.at(-1).body.executions[0].count === 3, "cada chamada conta uma execução (inclusive a que falhou)");
161
+ ok(CloudBrokerCloud.trackExecution("x", () => 1, { executions: { count() { throw new Error("x"); } } }) === 1, "contador que lança não derruba a execução");
162
+ ok(CloudBrokerCloud.trackExecution("x", () => 2) === 2, "sem contador funciona só como contexto");
163
+ }
164
+
165
+ // ─── versão nos eventos de chamada ───────────────────────────────────────────
166
+ {
167
+ process.env.CLOUDBROKER_VERSION = "abc1234";
168
+ class S3Client { constructor() { this.config = { region: async () => "us-east-2" }; } async send() { return {}; } }
169
+ class GetObjectCommand { constructor(input) { this.input = input; } }
170
+ const s3 = CloudBrokerCloud.wrapAWS(new S3Client(), { apiKey: "cb-test" });
171
+ await s3.send(new GetObjectCommand({ Bucket: "b", Key: "k" }));
172
+ await new Promise((r) => setTimeout(r, 30));
173
+ const ev = sent.filter((s) => s.url.endsWith("/api/cloud/events")).at(-1)?.body.events[0];
174
+ ok(ev?.version === "abc1234", `evento de chamada leva a versão do código (veio ${ev?.version})`);
175
+ const s3b = CloudBrokerCloud.wrapAWS(new S3Client(), { apiKey: "cb-test", version: "v9" });
176
+ await s3b.send(new GetObjectCommand({ Bucket: "b", Key: "k" }));
177
+ await new Promise((r) => setTimeout(r, 30));
178
+ ok(sent.filter((s) => s.url.endsWith("/api/cloud/events")).at(-1)?.body.events[0].version === "v9", "versão explícita do wrapAWS vence o env");
179
+ }
180
+
181
+ // ─── versão do pacote ────────────────────────────────────────────────────────
182
+ {
183
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
184
+ const client = readFileSync(new URL("./client.js", import.meta.url), "utf8");
185
+ const declaradas = [...client.matchAll(/sdkVersion:\s*"([^"]+)"/g)].map((m) => m[1]);
186
+ ok(declaradas.length > 0 && declaradas.every((v) => v === pkg.version), `sdkVersion dos eventos (${declaradas.join(", ")}) = versão do package.json (${pkg.version})`);
187
+ }
188
+
189
+ globalThis.fetch = realFetch;
190
+ console.log(`\n🎉 ${pass} asserções passaram.`);
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,9 +4,37 @@
4
4
  */
5
5
 
6
6
  import { CloudBrokerClient } from "./client.js";
7
- import { PRICING, priceGemini } from "./pricing.js";
7
+ import { PRICING, priceGemini, priceAnthropic } from "./pricing.js";
8
8
  import { cbStore } from "./context.js";
9
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
+ }
37
+
10
38
  export class CloudBrokerAI {
11
39
  /**
12
40
  * Envolve um cliente OpenAI ou Anthropic
@@ -189,35 +217,20 @@ export class CloudBrokerAI {
189
217
  const stream = await originalCreate(params, fwdOptions);
190
218
 
191
219
  let model = params.model || "unknown";
192
- let inputTokens = 0, outputTokens = 0;
220
+ // usage de entrada (incl. tokens de cache) vem no message_start;
221
+ // message_delta traz o total acumulado de output_tokens.
222
+ let usage = {};
193
223
  const onChunk = (event) => {
194
224
  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;
225
+ model = event.message.model || model;
226
+ if (event.message.usage) usage = { ...event.message.usage };
198
227
  } 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;
228
+ if (event.usage.output_tokens != null) usage.output_tokens = event.usage.output_tokens;
201
229
  }
202
230
  };
203
231
  const onDone = () => {
204
232
  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
- });
233
+ cb.track(anthropicEvent(model, usage, { product, team, label: cbLabel, start, streaming: true }));
221
234
  } catch (err) {
222
235
  if (debug) console.warn("[CloudBroker] track error:", err.message);
223
236
  }
@@ -231,25 +244,8 @@ export class CloudBrokerAI {
231
244
  const ms = Date.now() - start;
232
245
 
233
246
  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
- });
247
+ const model = res.model || params.model || "unknown";
248
+ cb.track(anthropicEvent(model, res.usage, { product, team, label: cbLabel, start, latencyMs: ms }));
253
249
  } catch (err) {
254
250
  if (debug) console.warn("[CloudBroker] track error:", err.message);
255
251
  }