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 +56 -0
- package/package.json +4 -3
- package/src/client.js +2 -2
- package/src/pricing.js +161 -3
- package/src/test-gemini-real.js +270 -0
- package/src/wrapper.js +125 -2
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.
|
|
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.
|
|
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.
|
|
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-
|
|
29
|
-
"gemini-
|
|
30
|
-
"gemini-
|
|
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
|