cloudbroker-sdk 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudbroker-sdk",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "CloudBroker SDK — Token Intelligence & Operational Financial Intelligence",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -8,7 +8,7 @@
8
8
  ".": "./src/index.js"
9
9
  },
10
10
  "scripts": {
11
- "test": "node src/test.js && node src/test-azure-real.js"
11
+ "test": "node src/test.js && node src/test-azure-real.js && node src/test-gemini-real.js"
12
12
  },
13
13
  "keywords": [
14
14
  "cloudbroker",
@@ -20,6 +20,7 @@
20
20
  "license": "MIT",
21
21
  "devDependencies": {
22
22
  "@azure/cosmos": "^4.10.0",
23
- "@azure/storage-blob": "^12.33.0"
23
+ "@azure/storage-blob": "^12.33.0",
24
+ "@google/genai": "^2.15.0"
24
25
  }
25
26
  }
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.7.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.7.0",
108
108
  sentAt: new Date().toISOString(),
109
109
  };
110
110
 
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,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