cloudbroker-sdk 0.6.0 → 0.8.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.
@@ -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
@@ -46,6 +46,62 @@ const res = await anthropic.messages.create({
46
46
  });
47
47
  ```
48
48
 
49
+ ## Uso com Gemini
50
+
51
+ ```js
52
+ import { GoogleGenAI } from "@google/genai";
53
+ import { CloudBrokerAI } from "cloudbroker-sdk";
54
+
55
+ const gemini = CloudBrokerAI.wrap(new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY }), {
56
+ apiKey: "cb-key-xxxx",
57
+ product: "checkout",
58
+ team: "mobile",
59
+ });
60
+
61
+ const res = await gemini.models.generateContent({
62
+ model: "gemini-2.5-flash",
63
+ contents: "Olá!",
64
+ });
65
+ ```
66
+
67
+ O `cbLabel` pode ir no próprio params (a API do Gemini é de argumento único) ou
68
+ no segundo argumento, como nos outros wrappers. Nos dois casos ele é removido
69
+ antes de a chamada seguir pro Google:
70
+
71
+ ```js
72
+ await gemini.models.generateContent({
73
+ model: "gemini-2.5-flash", contents: "…", cbLabel: "checkout:resumo-pedido",
74
+ });
75
+ ```
76
+
77
+ ### Por que o custo do Gemini não é `input × preço + output × preço`
78
+
79
+ A conta do Gemini tem três particularidades. Ignorar qualquer uma delas erra o
80
+ valor de forma relevante, então o SDK trata as três:
81
+
82
+ | Particularidade | O que acontece se ignorar |
83
+ |---|---|
84
+ | `promptTokenCount` **já inclui** os tokens servidos de cache, que custam ~10x menos | superfatura a parcela cacheada (1,65x a mais num prompt 80% cacheado) |
85
+ | `thoughtsTokenCount` é separado de `candidatesTokenCount` e é cobrado como output | subfatura — 33x a menos num caso com 2000 thinking tokens |
86
+ | 2.5 Pro dobra de preço acima de 200k tokens de prompt; áudio na entrada custa mais que texto | subfatura prompts longos e entrada de áudio |
87
+
88
+ ### Limitações declaradas
89
+
90
+ - **O custo é estimado por tabela de preço local**, não lido de uma fatura. O
91
+ Gemini não expõe API de uso ou custo por chave — só o dashboard do AI Studio
92
+ e o console do Cloud Billing. Os preços são os do paid tier publicados em
93
+ `ai.google.dev/gemini-api/docs/pricing`, conferidos em 2026-08-02, e **não
94
+ foram calibrados contra fatura real**.
95
+ - **Free tier sai com custo > 0.** A tabela é a do paid tier; quem está no free
96
+ tier vê o valor que *pagaria*, não o que paga.
97
+ - **Modelo fora da tabela não recebe preço de outro modelo.** O evento vai com
98
+ `costUSD: null` e `unpriced: true`, preservando os tokens. Variante com sufixo
99
+ (`-preview-…`) é precificada pelo modelo base e marcada `approxPricing`.
100
+ - **Armazenamento de contexto em cache não é cobrado aqui.** O Google cobra por
101
+ hora de cache ativo (US$ 1,00/h na maioria dos modelos); isso não aparece no
102
+ `usageMetadata` de uma chamada e não é modelado.
103
+ - **Batch API não é modelada** — o Google cobra metade nesse modo.
104
+
49
105
  ## Streaming
50
106
 
51
107
  Chamadas com `stream: true` também são rastreadas — o SDK observa os chunks
@@ -253,6 +309,46 @@ app.use(CloudBrokerCloud.expressMiddleware({
253
309
  Prioridade de cada campo: explícito no `.send()` (`cbLabel`/`cbProduct`/`cbTeam`) >
254
310
  contexto do middleware > valor estático do `wrapAWS` > `null`.
255
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
+
256
352
  ## Garantias
257
353
 
258
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.6.0",
3
+ "version": "0.8.0",
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"
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",
@@ -20,6 +24,7 @@
20
24
  "license": "MIT",
21
25
  "devDependencies": {
22
26
  "@azure/cosmos": "^4.10.0",
23
- "@azure/storage-blob": "^12.33.0"
27
+ "@azure/storage-blob": "^12.33.0",
28
+ "@google/genai": "^2.15.0"
24
29
  }
25
30
  }
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.6.0",
51
+ sdkVersion: "0.8.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.6.0",
107
+ sdkVersion: "0.8.0",
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
@@ -24,9 +24,167 @@ export const PRICING = {
24
24
  "claude-3-sonnet-20240229": { input: 0.000003, output: 0.000015 },
25
25
  "claude-3-haiku-20240307": { input: 0.00000025, output: 0.00000125 },
26
26
  },
27
+ /**
28
+ * Gemini — preços do paid tier em ai.google.dev/gemini-api/docs/pricing,
29
+ * conferidos em 2026-08-02. A página publica por 1M de tokens; aqui, como no
30
+ * resto da tabela, o valor é por token.
31
+ *
32
+ * A conta do Gemini tem três particularidades que um par `{input, output}`
33
+ * plano não expressa — ignorar qualquer uma delas erra o custo de verdade:
34
+ *
35
+ * - `cached` — prompt servido de cache custa ~10x menos. Atenção:
36
+ * `promptTokenCount` JÁ INCLUI os tokens cacheados, então o
37
+ * input a preço cheio é `prompt - cached`. Cobrar o prompt
38
+ * inteiro a preço cheio superfatura em 10x essa parcela.
39
+ * - `audioInput` — áudio na entrada custa mais que texto/imagem/vídeo nos
40
+ * Flash e Flash-Lite. A separação sai de
41
+ * `promptTokensDetails[].modality === "AUDIO"`.
42
+ * - `tierOver` — o 2.5 Pro dobra acima de 200k tokens de prompt.
43
+ *
44
+ * Os modelos 1.5/2.0 que existiam aqui foram removidos: a página oficial não
45
+ * os lista mais, e manter preço não verificável é fabricar custo. Eles caem
46
+ * agora no caminho de modelo desconhecido (`costUSD: null`), que é honesto.
47
+ *
48
+ * Thinking tokens não aparecem nesta tabela porque não têm preço próprio —
49
+ * a página diz "Output price (including thinking tokens)", ou seja, são
50
+ * cobrados como output. Quem soma é `priceGemini()`.
51
+ */
27
52
  gemini: {
28
- "gemini-1.5-pro": { input: 0.0000035, output: 0.0000105 },
29
- "gemini-1.5-flash": { input: 0.00000035, output: 0.00000105 },
30
- "gemini-2.0-flash": { input: 0.0000001, output: 0.0000004 },
53
+ "gemini-3.6-flash": { input: 0.0000015, output: 0.0000075, cached: 0.00000015 },
54
+ "gemini-3.5-flash": { input: 0.0000015, output: 0.000009, cached: 0.00000015 },
55
+ "gemini-3.5-flash-lite": { input: 0.0000003, output: 0.0000025, cached: 0.00000003 },
56
+ "gemini-3.1-flash-lite": {
57
+ input: 0.00000025, output: 0.0000015, cached: 0.000000025,
58
+ audioInput: 0.0000005, audioCached: 0.00000005,
59
+ },
60
+ "gemini-2.5-pro": {
61
+ input: 0.00000125, output: 0.00001, cached: 0.000000125,
62
+ tierOver: {
63
+ promptTokens: 200000,
64
+ input: 0.0000025, output: 0.000015, cached: 0.00000025,
65
+ },
66
+ },
67
+ "gemini-2.5-flash": {
68
+ input: 0.0000003, output: 0.0000025, cached: 0.00000003,
69
+ audioInput: 0.000001, audioCached: 0.0000001,
70
+ },
71
+ "gemini-2.5-flash-lite": {
72
+ input: 0.0000001, output: 0.0000004, cached: 0.00000001,
73
+ audioInput: 0.0000003, audioCached: 0.00000003,
74
+ },
31
75
  },
32
76
  };
77
+
78
+ /**
79
+ * Resolve a entrada de preço de um modelo Gemini.
80
+ *
81
+ * A API devolve `modelVersion` que muitas vezes carrega sufixo (`-preview-…`,
82
+ * data). Exigir igualdade exata deixaria quase toda resposta real sem preço;
83
+ * casar por prefixo sem avisar seria afirmar que preview custa o mesmo que GA,
84
+ * o que não é garantido. Então: exato é exato, prefixo é marcado `approx`, e o
85
+ * que não casa não recebe preço de outro modelo.
86
+ *
87
+ * @returns {{ id: string, rates: object, approx: boolean } | null}
88
+ */
89
+ function resolveGeminiModel(model) {
90
+ if (!model) return null;
91
+ const table = PRICING.gemini;
92
+ if (table[model]) return { id: model, rates: table[model], approx: false };
93
+
94
+ // Prefixo mais longo primeiro: "gemini-3.5-flash-lite" tem que ganhar de
95
+ // "gemini-3.5-flash" para "gemini-3.5-flash-lite-preview-01".
96
+ const match = Object.keys(table)
97
+ .filter(id => model.startsWith(id))
98
+ .sort((a, b) => b.length - a.length)[0];
99
+
100
+ return match ? { id: match, rates: table[match], approx: true } : null;
101
+ }
102
+
103
+ /**
104
+ * Calcula o custo de uma chamada Gemini a partir do `usageMetadata` da resposta.
105
+ *
106
+ * Nomes de campo conferidos nos tipos do @google/genai@2.15.0 — a doc pública
107
+ * mostra um formato snake_case (`total_input_tokens`) que é de outra superfície
108
+ * da API; o `generateContent` devolve camelCase.
109
+ *
110
+ * @param {string} model — `modelVersion` da resposta, ou o pedido na chamada
111
+ * @param {object} usage — `response.usageMetadata`
112
+ * @returns {{
113
+ * costUSD: number|null, inputTokens: number, outputTokens: number,
114
+ * totalTokens: number, cachedTokens: number, thoughtsTokens: number,
115
+ * audioInputTokens: number, pricedAs: string|null, approxPricing: boolean,
116
+ * unpriced: boolean, reason: string|null
117
+ * }}
118
+ */
119
+ export function priceGemini(model, usage = {}) {
120
+ const prompt = usage.promptTokenCount || 0;
121
+ const cached = usage.cachedContentTokenCount || 0;
122
+ const output = usage.candidatesTokenCount || 0;
123
+ const thoughts = usage.thoughtsTokenCount || 0;
124
+ const toolUse = usage.toolUsePromptTokenCount || 0;
125
+
126
+ // `prompt` já inclui `cached` — o restante é que sai a preço cheio.
127
+ // Nunca deixar negativo: se a API mudar a semântica, subfaturar em silêncio
128
+ // é pior do que somar 0.
129
+ const fullPriceInput = Math.max(0, prompt - cached);
130
+
131
+ // Áudio na entrada tem preço próprio em parte dos modelos. O detalhamento
132
+ // por modalidade se refere ao prompt inteiro (cacheado incluso), então o
133
+ // recorte de áudio a preço cheio é proporcional ao que não veio de cache.
134
+ const audioDetail = (usage.promptTokensDetails || [])
135
+ .find(d => d?.modality === "AUDIO")?.tokenCount || 0;
136
+ const audioFullPrice = prompt > 0
137
+ ? Math.round(audioDetail * (fullPriceInput / prompt))
138
+ : 0;
139
+ const audioCached = Math.max(0, audioDetail - audioFullPrice);
140
+
141
+ const base = {
142
+ inputTokens: prompt + toolUse,
143
+ outputTokens: output + thoughts,
144
+ totalTokens: usage.totalTokenCount || (prompt + output + thoughts + toolUse),
145
+ cachedTokens: cached,
146
+ thoughtsTokens: thoughts,
147
+ audioInputTokens: audioDetail,
148
+ };
149
+
150
+ const resolved = resolveGeminiModel(model);
151
+ if (!resolved) {
152
+ return {
153
+ ...base,
154
+ costUSD: null,
155
+ pricedAs: null,
156
+ approxPricing: false,
157
+ unpriced: true,
158
+ reason: `Modelo "${model || "desconhecido"}" não está na tabela de preço do Gemini — tokens registrados, custo não estimado.`,
159
+ };
160
+ }
161
+
162
+ // O 2.5 Pro troca de faixa pelo tamanho do prompt.
163
+ let rates = resolved.rates;
164
+ if (rates.tierOver && prompt > rates.tierOver.promptTokens) {
165
+ rates = { ...rates, ...rates.tierOver };
166
+ }
167
+
168
+ const textFullPrice = Math.max(0, fullPriceInput - audioFullPrice);
169
+ const textCached = Math.max(0, cached - audioCached);
170
+
171
+ const costUSD =
172
+ textFullPrice * rates.input
173
+ + audioFullPrice * (rates.audioInput ?? rates.input)
174
+ + textCached * (rates.cached ?? rates.input)
175
+ + audioCached * (rates.audioCached ?? rates.cached ?? rates.input)
176
+ + toolUse * rates.input
177
+ // Thinking tokens são cobrados como output.
178
+ + (output + thoughts) * rates.output;
179
+
180
+ return {
181
+ ...base,
182
+ costUSD: parseFloat(costUSD.toFixed(8)),
183
+ pricedAs: resolved.id,
184
+ approxPricing: resolved.approx,
185
+ unpriced: false,
186
+ reason: resolved.approx
187
+ ? `Preço do modelo base "${resolved.id}" aplicado a "${model}" — variante não listada na tabela oficial.`
188
+ : null,
189
+ };
190
+ }
@@ -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.`);
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Teste do wrapGemini contra o cliente REAL do @google/genai — não contra uma
3
+ * fake escrita à mão.
4
+ *
5
+ * Por que existe: a mesma razão do test-azure-real.js. Uma fake desenhada pra
6
+ * casar com a implementação não reprova suposição errada sobre o SDK de
7
+ * verdade. E aqui havia uma suposição especialmente perigosa: a doc pública do
8
+ * Gemini mostra o usage em snake_case (`total_input_tokens`), que pertence a
9
+ * outra superfície da API — o `generateContent` devolve camelCase
10
+ * (`promptTokenCount`). Ter errado isso daria token zero em silêncio, sem
11
+ * nenhum erro. Este arquivo trava os nomes contra os tipos do pacote instalado.
12
+ *
13
+ * Não faz chamada de rede: a chave é sintética e o método original é
14
+ * substituído por um stub antes do wrap ser exercitado.
15
+ *
16
+ * Execute: node src/test-gemini-real.js
17
+ */
18
+ import assert from "node:assert";
19
+ import { readFileSync } from "node:fs";
20
+ import { createRequire } from "node:module";
21
+ import { GoogleGenAI } from "@google/genai";
22
+ import { CloudBrokerAI } from "./wrapper.js";
23
+ import { priceGemini, PRICING } from "./pricing.js";
24
+
25
+ const require = createRequire(import.meta.url);
26
+
27
+ // O pacote não expõe "./package.json" em `exports`, então o caminho dos tipos
28
+ // é derivado do entry point resolvido (.../dist/node/index.cjs → .../dist).
29
+ function typingsPath() {
30
+ const entry = require.resolve("@google/genai");
31
+ return entry.slice(0, entry.indexOf("/dist/") + "/dist/".length) + "genai.d.ts";
32
+ }
33
+
34
+ let ok = 0, fail = 0;
35
+ const t = (name, fn) => {
36
+ try { fn(); console.log(`✅ ${name}`); ok++; }
37
+ catch (e) { console.error(`❌ ${name}\n ${e.message}`); fail++; }
38
+ };
39
+ const ta = async (name, fn) => {
40
+ try { await fn(); console.log(`✅ ${name}`); ok++; }
41
+ catch (e) { console.error(`❌ ${name}\n ${e.message}`); fail++; }
42
+ };
43
+
44
+ console.log("🧪 wrapGemini contra o SDK real do Google (sem rede)\n");
45
+
46
+ // ── O contrato de nomes que a implementação assume ───────────────────────────
47
+ t("usageMetadata do @google/genai é camelCase, como a implementação assume", () => {
48
+ const dts = readFileSync(typingsPath(), "utf8");
49
+ const decl = dts.slice(dts.indexOf("declare class GenerateContentResponseUsageMetadata"));
50
+ const body = decl.slice(0, decl.indexOf("\n}"));
51
+ for (const field of [
52
+ "promptTokenCount", "candidatesTokenCount", "thoughtsTokenCount",
53
+ "cachedContentTokenCount", "toolUsePromptTokenCount", "totalTokenCount",
54
+ "promptTokensDetails",
55
+ ]) {
56
+ assert.ok(body.includes(`${field}?:`), `campo ${field} sumiu do SDK — a extração de token quebra em silêncio`);
57
+ }
58
+ });
59
+
60
+ t("promptTokenCount continua incluindo os tokens cacheados", () => {
61
+ // A fórmula subtrai `cached` de `prompt`. Se o SDK mudar essa semântica, a
62
+ // subtração passa a subfaturar — e nada mais no código avisaria.
63
+ const dts = readFileSync(typingsPath(), "utf8");
64
+ const i = dts.indexOf("promptTokenCount?:");
65
+ const doc = dts.slice(Math.max(0, i - 400), i);
66
+ assert.ok(
67
+ /also includes the number of tokens in the cached content/i.test(doc),
68
+ "a doc do campo mudou — revisar `fullPriceInput = prompt - cached` em pricing.js",
69
+ );
70
+ });
71
+
72
+ // ── Detecção do cliente real ─────────────────────────────────────────────────
73
+ const raw = new GoogleGenAI({ apiKey: "cb-test-synthetic" });
74
+
75
+ t("cliente real tem a forma que a detecção procura", () => {
76
+ assert.strictEqual(raw.constructor.name, "GoogleGenAI");
77
+ assert.strictEqual(typeof raw.models.generateContent, "function");
78
+ assert.strictEqual(typeof raw.models.generateContentStream, "function");
79
+ });
80
+
81
+ t("não colide com a detecção de OpenAI nem de Anthropic", () => {
82
+ assert.ok(!raw.chat?.completions, "cairia no ramo da OpenAI");
83
+ assert.ok(!raw.messages?.create, "cairia no ramo da Anthropic");
84
+ });
85
+
86
+ // ── Instrumentação ───────────────────────────────────────────────────────────
87
+ // Stub do método original: nenhuma rede, e devolve um usageMetadata realista.
88
+ const tracked = [];
89
+ function makeClient(usageMetadata, { modelVersion = "gemini-2.5-flash" } = {}) {
90
+ const c = new GoogleGenAI({ apiKey: "cb-test-synthetic" });
91
+ let seenParams = null;
92
+ c.models.generateContent = async (params) => {
93
+ seenParams = params;
94
+ return { modelVersion, usageMetadata, candidates: [] };
95
+ };
96
+ c.models.generateContentStream = async (params) => {
97
+ seenParams = params;
98
+ return (async function* () {
99
+ yield { modelVersion, candidates: [] };
100
+ yield { modelVersion, usageMetadata, candidates: [] };
101
+ })();
102
+ };
103
+ const wrapped = CloudBrokerAI.wrap(c, { apiKey: "cb-test-key", product: "checkout" });
104
+ // Intercepta o envio pra inspecionar o evento sem rede.
105
+ wrapped.trackDeploy = wrapped.trackDeploy; // preserva
106
+ return { wrapped, getParams: () => seenParams };
107
+ }
108
+
109
+ // O client.track empurra pra fila e tenta flush em background; aqui trocamos o
110
+ // CloudBrokerClient por captura direta, via monkey-patch do fetch global.
111
+ const realFetch = globalThis.fetch;
112
+ globalThis.fetch = async (url, init) => {
113
+ try {
114
+ const body = JSON.parse(init?.body || "{}");
115
+ for (const ev of (body.events || [])) tracked.push(ev);
116
+ } catch { /* ignora */ }
117
+ return { ok: true, status: 200, json: async () => ({ ok: true }) };
118
+ };
119
+
120
+ await ta("generateContent registra tokens reais e não altera a resposta", async () => {
121
+ tracked.length = 0;
122
+ const { wrapped } = makeClient({
123
+ promptTokenCount: 1000, candidatesTokenCount: 500, totalTokenCount: 1500,
124
+ });
125
+ const res = await wrapped.models.generateContent({ model: "gemini-2.5-flash", contents: "oi" });
126
+ assert.strictEqual(res.modelVersion, "gemini-2.5-flash", "resposta original tem que passar intacta");
127
+ await new Promise(r => setTimeout(r, 30));
128
+ const ev = tracked.at(-1);
129
+ assert.ok(ev, "nenhum evento registrado");
130
+ assert.strictEqual(ev.provider, "gemini");
131
+ assert.strictEqual(ev.inputTokens, 1000);
132
+ assert.strictEqual(ev.outputTokens, 500);
133
+ assert.strictEqual(ev.costUSD, priceGemini("gemini-2.5-flash", { promptTokenCount: 1000, candidatesTokenCount: 500 }).costUSD);
134
+ });
135
+
136
+ await ta("thinking tokens entram no output (senão o custo sai muito menor)", async () => {
137
+ tracked.length = 0;
138
+ const { wrapped } = makeClient({
139
+ promptTokenCount: 100, candidatesTokenCount: 50, thoughtsTokenCount: 2000,
140
+ });
141
+ await wrapped.models.generateContent({ model: "gemini-2.5-flash", contents: "oi" });
142
+ await new Promise(r => setTimeout(r, 30));
143
+ const ev = tracked.at(-1);
144
+ assert.strictEqual(ev.outputTokens, 2050, "thoughts precisa somar ao output");
145
+ assert.strictEqual(ev.thoughtsTokens, 2000);
146
+ const ingenuo = 100 * PRICING.gemini["gemini-2.5-flash"].input + 50 * PRICING.gemini["gemini-2.5-flash"].output;
147
+ assert.ok(ev.costUSD > ingenuo * 10, "ignorar thoughts subfaturaria em mais de 10x");
148
+ });
149
+
150
+ await ta("tokens cacheados saem ao preço de cache, não ao preço cheio", async () => {
151
+ tracked.length = 0;
152
+ const { wrapped } = makeClient({
153
+ promptTokenCount: 1000, cachedContentTokenCount: 800, candidatesTokenCount: 100,
154
+ });
155
+ await wrapped.models.generateContent({ model: "gemini-2.5-flash", contents: "oi" });
156
+ await new Promise(r => setTimeout(r, 30));
157
+ const ev = tracked.at(-1);
158
+ const r = PRICING.gemini["gemini-2.5-flash"];
159
+ assert.strictEqual(ev.cachedTokens, 800);
160
+ assert.ok(Math.abs(ev.costUSD - (200 * r.input + 800 * r.cached + 100 * r.output)) < 1e-9);
161
+ const cheio = 1000 * r.input + 100 * r.output;
162
+ assert.ok(ev.costUSD < cheio, "cobrar o prompt inteiro a preço cheio superfaturaria");
163
+ });
164
+
165
+ await ta("modelo fora da tabela registra tokens sem inventar preço", async () => {
166
+ tracked.length = 0;
167
+ const { wrapped } = makeClient(
168
+ { promptTokenCount: 1000, candidatesTokenCount: 500 },
169
+ { modelVersion: "gemini-9.9-ultra" },
170
+ );
171
+ await wrapped.models.generateContent({ model: "gemini-9.9-ultra", contents: "oi" });
172
+ await new Promise(r => setTimeout(r, 30));
173
+ const ev = tracked.at(-1);
174
+ assert.strictEqual(ev.costUSD, null, "custo tem que ser null, nunca o preço de outro modelo");
175
+ assert.strictEqual(ev.unpriced, true);
176
+ assert.strictEqual(ev.inputTokens, 1000, "os tokens continuam sendo registrados");
177
+ assert.ok(ev.pricingNote?.includes("gemini-9.9-ultra"));
178
+ });
179
+
180
+ await ta("resposta sem usageMetadata não vira custo zero silencioso", async () => {
181
+ tracked.length = 0;
182
+ const { wrapped } = makeClient(undefined);
183
+ await wrapped.models.generateContent({ model: "gemini-2.5-flash", contents: "oi" });
184
+ await new Promise(r => setTimeout(r, 30));
185
+ const ev = tracked.at(-1);
186
+ assert.strictEqual(ev.totalTokens, 0);
187
+ assert.strictEqual(ev.costUSD, 0, "sem tokens, zero é o valor correto — o caso perigoso é o modelo desconhecido");
188
+ });
189
+
190
+ await ta("cbLabel é aceito no params e NÃO vaza pra API do provider", async () => {
191
+ tracked.length = 0;
192
+ const { wrapped, getParams } = makeClient({ promptTokenCount: 10, candidatesTokenCount: 5 });
193
+ await wrapped.models.generateContent({ model: "gemini-2.5-flash", contents: "oi", cbLabel: "checkout:resumo" });
194
+ await new Promise(r => setTimeout(r, 30));
195
+ assert.ok(!("cbLabel" in getParams()), "cbLabel vazou pro SDK do Google");
196
+ assert.strictEqual(tracked.at(-1).label, "checkout:resumo");
197
+ });
198
+
199
+ await ta("cbLabel também funciona no segundo argumento (convenção dos outros wrappers)", async () => {
200
+ tracked.length = 0;
201
+ const { wrapped } = makeClient({ promptTokenCount: 10, candidatesTokenCount: 5 });
202
+ await wrapped.models.generateContent({ model: "gemini-2.5-flash", contents: "oi" }, { cbLabel: "billing:fatura" });
203
+ await new Promise(r => setTimeout(r, 30));
204
+ assert.strictEqual(tracked.at(-1).label, "billing:fatura");
205
+ });
206
+
207
+ await ta("streaming acumula o usage do chunk final e entrega todos os chunks", async () => {
208
+ tracked.length = 0;
209
+ const { wrapped } = makeClient({
210
+ promptTokenCount: 300, candidatesTokenCount: 200, thoughtsTokenCount: 100,
211
+ });
212
+ const stream = await wrapped.models.generateContentStream({ model: "gemini-2.5-flash", contents: "oi" });
213
+ let chunks = 0;
214
+ for await (const _ of stream) chunks++;
215
+ await new Promise(r => setTimeout(r, 30));
216
+ const ev = tracked.at(-1);
217
+ assert.strictEqual(chunks, 2, "o consumidor tem que receber os chunks originais");
218
+ assert.strictEqual(ev.streaming, true);
219
+ assert.strictEqual(ev.inputTokens, 300);
220
+ assert.strictEqual(ev.outputTokens, 300, "200 candidates + 100 thoughts");
221
+ });
222
+
223
+ await ta("erro do provider passa intacto e não é engolido pela instrumentação", async () => {
224
+ const c = new GoogleGenAI({ apiKey: "cb-test-synthetic" });
225
+ c.models.generateContent = async () => { throw new Error("429 quota exceeded"); };
226
+ const wrapped = CloudBrokerAI.wrap(c, { apiKey: "cb-test-key" });
227
+ await assert.rejects(
228
+ () => wrapped.models.generateContent({ model: "gemini-2.5-flash", contents: "oi" }),
229
+ /429 quota exceeded/,
230
+ );
231
+ });
232
+
233
+ globalThis.fetch = realFetch;
234
+
235
+ // ── Preço: casos que o wrapper não exercita ──────────────────────────────────
236
+ const near = (a, b) => Math.abs(a - b) < 1e-9;
237
+
238
+ t("2.5 Pro troca de faixa acima de 200k tokens de prompt", () => {
239
+ const base = priceGemini("gemini-2.5-pro", { promptTokenCount: 1000, candidatesTokenCount: 100 });
240
+ assert.ok(near(base.costUSD, 1000 * 0.00000125 + 100 * 0.00001), "abaixo do corte usa preço base");
241
+
242
+ const alto = priceGemini("gemini-2.5-pro", { promptTokenCount: 250000, candidatesTokenCount: 1000 });
243
+ assert.ok(near(alto.costUSD, 250000 * 0.0000025 + 1000 * 0.000015), "acima de 200k dobra");
244
+ });
245
+
246
+ t("áudio na entrada é cobrado à parte do texto", () => {
247
+ const r = PRICING.gemini["gemini-2.5-flash"];
248
+ const p = priceGemini("gemini-2.5-flash", {
249
+ promptTokenCount: 1000,
250
+ promptTokensDetails: [{ modality: "AUDIO", tokenCount: 400 }, { modality: "TEXT", tokenCount: 600 }],
251
+ });
252
+ assert.ok(near(p.costUSD, 600 * r.input + 400 * r.audioInput));
253
+ assert.ok(p.costUSD > 1000 * r.input, "tratar áudio como texto subfaturaria");
254
+ });
255
+
256
+ t("variante com sufixo casa pelo prefixo MAIS LONGO e é marcada aproximada", () => {
257
+ const p = priceGemini("gemini-2.5-flash-lite-preview-06", { promptTokenCount: 1000 });
258
+ assert.strictEqual(p.pricedAs, "gemini-2.5-flash-lite", "não pode cair no 'gemini-2.5-flash', que é mais caro");
259
+ assert.strictEqual(p.approxPricing, true, "preço de variante não listada tem que sair sinalizado");
260
+ });
261
+
262
+ t("modelos 1.5/2.0 removidos não são precificados por engano", () => {
263
+ // Saíram da página oficial; manter preço não verificável seria fabricar custo.
264
+ for (const legado of ["gemini-1.5-pro", "gemini-1.5-flash", "gemini-2.0-flash"]) {
265
+ assert.strictEqual(priceGemini(legado, { promptTokenCount: 100 }).costUSD, null, legado);
266
+ }
267
+ });
268
+
269
+ console.log(`\n${fail === 0 ? "🎉 TODOS PASSARAM" : "💥 FALHAS"}: ${ok} ok, ${fail} fail`);
270
+ process.exitCode = fail === 0 ? 0 : 1;
package/src/wrapper.js CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { CloudBrokerClient } from "./client.js";
7
- import { PRICING } from "./pricing.js";
7
+ import { PRICING, priceGemini } from "./pricing.js";
8
8
  import { cbStore } from "./context.js";
9
9
 
10
10
  export class CloudBrokerAI {
@@ -35,12 +35,18 @@ export class CloudBrokerAI {
35
35
  // Adiciona trackDeploy ao cliente wrappado
36
36
  client.trackDeploy = cb.trackDeploy.bind(cb);
37
37
 
38
- // Detecta provider pelo tipo do cliente
38
+ // Detecta provider pelo tipo do cliente.
39
+ // O sinal do Gemini é `models.generateContent`, não `models`: o cliente da
40
+ // OpenAI também tem `.models` (list/retrieve), então checar só o objeto
41
+ // casaria com o cliente errado.
39
42
  const isOpenAI = client?.constructor?.name === "OpenAI" || !!client?.chat?.completions;
40
43
  const isAnthropic = client?.constructor?.name === "Anthropic" || !!client?.messages?.create;
44
+ const isGemini = client?.constructor?.name === "GoogleGenAI"
45
+ || typeof client?.models?.generateContent === "function";
41
46
 
42
47
  if (isOpenAI) return CloudBrokerAI.#wrapOpenAI(client, cb, { product, team, debug });
43
48
  if (isAnthropic) return CloudBrokerAI.#wrapAnthropic(client, cb, { product, team, debug });
49
+ if (isGemini) return CloudBrokerAI.#wrapGemini(client, cb, { product, team, debug });
44
50
 
45
51
  if (debug) console.warn("[CloudBroker] Provider não reconhecido — retornando cliente original.");
46
52
  return client;
@@ -254,6 +260,123 @@ export class CloudBrokerAI {
254
260
  return client;
255
261
  }
256
262
 
263
+ /**
264
+ * Instrumenta o cliente do @google/genai (`new GoogleGenAI({apiKey})`).
265
+ *
266
+ * Diferenças relevantes em relação a OpenAI/Anthropic:
267
+ *
268
+ * - A API é de argumento único (`models.generateContent({model, contents})`),
269
+ * sem segundo parâmetro de options. Por isso o `cbLabel` é aceito das duas
270
+ * formas: no segundo argumento (convenção dos outros wrappers) e dentro do
271
+ * próprio params — neste caso removido antes de repassar, pra nunca vazar
272
+ * campo desconhecido para a API do provider.
273
+ * - O usage vem em `usageMetadata` (camelCase). Os nomes foram conferidos nos
274
+ * tipos do @google/genai@2.15.0, não na doc pública: a doc mostra um
275
+ * formato snake_case que pertence a outra superfície da API, e usá-lo
276
+ * daria token zero em silêncio.
277
+ * - `costUSD` pode ser `null` (modelo fora da tabela de preço). Isso é
278
+ * deliberado: registrar tokens sem inventar preço. Quem consome precisa
279
+ * olhar `unpriced` — tratar `null` como zero vira "custo real zero".
280
+ */
281
+ static #wrapGemini(client, cb, { product, team, debug }) {
282
+ const models = client.models;
283
+ if (!models || typeof models.generateContent !== "function") return client;
284
+
285
+ // Extrai o cbLabel e devolve os params limpos pra repassar ao provider.
286
+ const splitLabel = (params, options) => {
287
+ let label = null, fwd = params;
288
+ try {
289
+ label = options?.cbLabel ?? null;
290
+ if (!label && params && typeof params === "object" && params.cbLabel != null) {
291
+ label = params.cbLabel;
292
+ }
293
+ if (!label) label = cbStore.getStore()?.label ?? null;
294
+ if (params && typeof params === "object" && "cbLabel" in params) {
295
+ const { cbLabel: _l, ...rest } = params;
296
+ fwd = rest;
297
+ }
298
+ } catch { /* instrumentação nunca altera a chamada do cliente */ }
299
+ return { label, fwd };
300
+ };
301
+
302
+ const buildEvent = (model, usage, { label, start, streaming, endpoint }) => {
303
+ const priced = priceGemini(model, usage || {});
304
+ return {
305
+ provider: "gemini",
306
+ model,
307
+ product: product || null,
308
+ team: team || null,
309
+ label,
310
+ inputTokens: priced.inputTokens,
311
+ outputTokens: priced.outputTokens,
312
+ totalTokens: priced.totalTokens,
313
+ costUSD: priced.costUSD,
314
+ // Só o Gemini separa esses dois, e são justamente os que mais mexem no
315
+ // valor: cache barateia ~10x, thinking é cobrado como output.
316
+ cachedTokens: priced.cachedTokens,
317
+ thoughtsTokens: priced.thoughtsTokens,
318
+ unpriced: priced.unpriced,
319
+ pricingNote: priced.reason,
320
+ latencyMs: Date.now() - start,
321
+ endpoint,
322
+ streaming: !!streaming,
323
+ };
324
+ };
325
+
326
+ // ── generateContent (não-streaming) ───────────────────────────────────────
327
+ const originalGenerate = models.generateContent.bind(models);
328
+ models.generateContent = async (params, options) => {
329
+ const start = Date.now();
330
+ const { label, fwd } = splitLabel(params, options);
331
+
332
+ const res = await originalGenerate(fwd);
333
+
334
+ try {
335
+ const model = res?.modelVersion || params?.model || "unknown";
336
+ cb.track(buildEvent(model, res?.usageMetadata, {
337
+ label, start, streaming: false, endpoint: "models.generateContent",
338
+ }));
339
+ } catch (err) {
340
+ if (debug) console.warn("[CloudBroker] track error:", err.message);
341
+ }
342
+
343
+ return res;
344
+ };
345
+
346
+ // ── generateContentStream ────────────────────────────────────────────────
347
+ if (typeof models.generateContentStream === "function") {
348
+ const originalStream = models.generateContentStream.bind(models);
349
+ models.generateContentStream = async (params, options) => {
350
+ const start = Date.now();
351
+ const { label, fwd } = splitLabel(params, options);
352
+
353
+ const stream = await originalStream(fwd);
354
+
355
+ let model = params?.model || "unknown";
356
+ let usage = null;
357
+ const onChunk = (chunk) => {
358
+ if (chunk?.modelVersion) model = chunk.modelVersion;
359
+ // O usage do Gemini é cumulativo e chega no(s) chunk(s) final(is):
360
+ // o último que vier é o total da chamada.
361
+ if (chunk?.usageMetadata) usage = chunk.usageMetadata;
362
+ };
363
+ const onDone = () => {
364
+ try {
365
+ cb.track(buildEvent(model, usage, {
366
+ label, start, streaming: true, endpoint: "models.generateContentStream",
367
+ }));
368
+ } catch (err) {
369
+ if (debug) console.warn("[CloudBroker] track error:", err.message);
370
+ }
371
+ };
372
+
373
+ return CloudBrokerAI.#instrumentStream(stream, onChunk, onDone);
374
+ };
375
+ }
376
+
377
+ return client;
378
+ }
379
+
257
380
  /**
258
381
  * Registra um deploy para análise de custo
259
382
  * Atalho para CloudBrokerClient.trackDeploy() sem precisar envolver um cliente de IA