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