From 638fe783a0a835c8bdf20be427a4dde86e684d00 Mon Sep 17 00:00:00 2001 From: Xiangzhe Date: Tue, 18 Aug 2026 00:23:47 -0300 Subject: [PATCH] =?UTF-8?q?feat(compression):=20contabilidade=20f=C3=ADsic?= =?UTF-8?q?a=20do=20omniglyph=20com=20grau=20de=20evid=C3=AAncia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O adapter descartava o TransformInfo inteiro, então a UI mostrava um número de economia sem dizer de onde ele vinha — contagem do provider, estimativa ou só diferença de bytes. O 1.4.0 expõe normalizeAccounting(), que classifica essa evidência e resolve a semântica de cache por família: Anthropic reporta input, cache-create e cache-read em buckets DISJUNTOS, enquanto OpenAI e xAI reportam cached como SUBCONJUNTO do input. Somar à mão dá double-count silencioso. O novo omniglyphTelemetry.ts não filtra por denylist — MONTA um objeto novo, campo a campo, só com número e enum. TransformInfo mistura contadores inofensivos com material que não pode ser persistido: bytes PNG, imageSourceText(s), recoverable[].text, os sha8 de system/CLAUDE.md/primeira mensagem, nomes de tags observadas e o bloco env (cwd, branch, versões). Copiar o objeto inteiro transformaria telemetria de compressão em vazamento de prompt. O teste de negação prova que segredo, caminho do operador, texto do system e base64 não aparecem, e varre a allowlist exigindo que toda string seja de um enum conhecido. - provider threaded do chatCore e do bridge Codex WS até a engine; ausente vira `unknown`, que faz o upstream recusar adivinhar buckets de cache; - contabilidade propagada para o engineBreakdown do passo (o agregado do pipeline soma todas as engines e não serviria); - skip não emite contabilidade: zeros ali seriam indistinguíveis de "a engine nem rodou". --- open-sse/handlers/chatCore.ts | 4 + .../compression/engines/omniglyphAdapter.ts | 39 +++-- .../services/compression/engines/types.ts | 4 + .../compression/omniglyphTelemetry.ts | 156 ++++++++++++++++++ .../services/compression/stackedStepCore.ts | 3 + .../services/compression/strategySelector.ts | 6 + open-sse/services/compression/types.ts | 9 + .../codex-responses-ws/compression.ts | 1 + .../compression/omniglyph-plumbing.test.ts | 25 +++ .../compression/omniglyph-telemetry.test.ts | 121 ++++++++++++++ 10 files changed, 356 insertions(+), 12 deletions(-) create mode 100644 open-sse/services/compression/omniglyphTelemetry.ts create mode 100644 tests/unit/compression/omniglyph-telemetry.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index be925b60bf..c8d296c075 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -1552,6 +1552,10 @@ export async function handleChatCore({ // OmniGlyph uses a measured provider/image-fidelity allowlist. Direct HTTP // alone is not proof that a route preserves PNG bytes and dimensions. ...resolveOmniGlyphTransport(provider), + // Sem o provider, a contabilidade do OmniGlyph cai para `unknown` e + // recusa deduzir a semântica de cache (Anthropic usa buckets disjuntos, + // OpenAI reporta cached como subconjunto do input). + provider, sourceFormat, targetFormat, compressionStage: "pre-translation" as const, diff --git a/open-sse/services/compression/engines/omniglyphAdapter.ts b/open-sse/services/compression/engines/omniglyphAdapter.ts index 93882b862e..c407b9c9ab 100644 --- a/open-sse/services/compression/engines/omniglyphAdapter.ts +++ b/open-sse/services/compression/engines/omniglyphAdapter.ts @@ -20,6 +20,10 @@ import type { CompressionEngine, CompressionEngineApplyOptions } from "./types.ts"; import type { CompressionResult } from "../types.ts"; import { createCompressionStats } from "../stats.ts"; +import { + buildOmniGlyphAccounting, + type OmniGlyphAccounting, +} from "../omniglyphTelemetry.ts"; import { isOmniGlyphSupportedModelForScope, mergeCompressionProfileOptions, @@ -209,6 +213,7 @@ async function applyOmniglyph( if (!isModelImageable(model)) return skip(body, "model_not_imageable"); const started = Date.now(); let outBody: Record; + let accounting: OmniGlyphAccounting | undefined; try { // The upstream OpenAI transformer resolves its billing/render profile from // body.model. Keep the provider body byte-compatible on output, but use the @@ -235,24 +240,34 @@ async function applyOmniglyph( if (!applied) return skip(body, result.info?.reason ?? "not_profitable"); outBody = JSON.parse(new TextDecoder().decode(result.body)) as Record; if (transformBody !== body && body.model !== undefined) outBody.model = body.model; + accounting = buildOmniGlyphAccounting({ + provider: options?.provider, + model, + originalBytes: encoded.byteLength, + transformedBytes: result.body.byteLength, + info: result.info, + durationMs: Date.now() - started, + }); } catch { // Fail-open: qualquer erro no encode/transform/decode (ex.: corpo não serializável, // render PNG estourando, JSON decodificado malformado) vira skip, nunca propaga. return skip(body, "transform_error"); } - return { - body: outBody, - compressed: true, - stats: createCompressionStats( - body, - outBody, - "stacked", - ["omniglyph:context-as-image"], - undefined, - Date.now() - started - ), - }; + const stats = createCompressionStats( + body, + outBody, + "stacked", + ["omniglyph:context-as-image"], + undefined, + Date.now() - started + ); + // A contabilidade só acompanha uma conversão que realmente aconteceu: um skip + // não tem economia para reportar, e inventar zeros ali viraria "0% de ganho" + // indistinguível de "a engine nem rodou". + if (accounting) stats.omniglyph = accounting; + + return { body: outBody, compressed: true, stats }; } export const omniglyphEngine: CompressionEngine = { diff --git a/open-sse/services/compression/engines/types.ts b/open-sse/services/compression/engines/types.ts index 0caaae9346..a70d6368d0 100644 --- a/open-sse/services/compression/engines/types.ts +++ b/open-sse/services/compression/engines/types.ts @@ -61,6 +61,10 @@ export interface CompressionEngineApplyOptions { stepConfig?: Record; /** Authenticated principal (API key id) making the request. Used by CCR to scope its store. */ principalId?: string; + /** Provider resolvido do alvo. A contabilidade do omniglyph depende dele: + * Anthropic reporta input/cache em buckets disjuntos, OpenAI/xAI reportam + * cached como subconjunto do input. Ausente => `unknown` (falha fechado). */ + provider?: string; } export interface CompressionEngine { diff --git a/open-sse/services/compression/omniglyphTelemetry.ts b/open-sse/services/compression/omniglyphTelemetry.ts new file mode 100644 index 0000000000..5e7471d126 --- /dev/null +++ b/open-sse/services/compression/omniglyphTelemetry.ts @@ -0,0 +1,156 @@ +/** + * Ponte de telemetria do OmniGlyph — allowlist positiva. + * + * `TransformInfo` mistura contadores inofensivos com material que NUNCA pode + * ser persistido: bytes PNG, `imageSourceText(s)`, `recoverable[].text`, os + * sha8 de system/CLAUDE.md/primeira mensagem, os nomes de tags observadas e o + * bloco `env` (cwd, branch, versões). Copiar o objeto inteiro seria transformar + * a telemetria de compressão num vazamento do prompt. + * + * Este módulo não filtra por denylist — ele MONTA um objeto novo, campo a + * campo, só com número e enum. Um campo novo no upstream não entra sozinho. + * + * `normalizeAccounting()` (OmniGlyph 1.4.0) faz a parte difícil: classifica o + * grau de evidência da economia e resolve a semântica de cache por provider — + * Anthropic reporta input/cache-create/cache-read em buckets DISJUNTOS, + * enquanto OpenAI e xAI reportam `cached` como SUBCONJUNTO do input. Somar à + * mão dá double-count silencioso. + */ + +import { + normalizeAccounting, + type AccountingProvider, + type OmniGlyphTransformInfo, + type SavingsEvidence, +} from "omniglyph"; + +/** Contabilidade segura de uma execução do OmniGlyph. Só número e enum. */ +export interface OmniGlyphAccounting { + provider: AccountingProvider; + model?: string; + bytes: { + original?: number; + transformed?: number; + reduced?: number; + compressionRatio?: number; + }; + tokens: { + estimatedOriginalInput?: number; + estimatedActualInput?: number; + estimatedReduced?: number; + image?: number; + }; + savings: { + /** De onde saiu o número: contagem do provider, estimativa ou só bytes. */ + evidence: SavingsEvidence; + inputTokensReduced?: number; + inputReductionRatio?: number; + }; + images: { + count: number; + bytes: number; + pixels?: number; + }; + /** Chars de origem imageados vs. mantidos como texto por turno. */ + chars: { + original?: number; + imaged?: number; + static?: number; + dynamic?: number; + outgoingText?: number; + }; + dynamicBlockCount?: number; + latencyMs?: number; +} + +/** + * A semântica de cache de `normalizeAccounting` depende da família do provider, + * não do nome comercial da rota. Rota desconhecida vira `unknown`, que faz o + * upstream falhar fechado em vez de adivinhar buckets de cache. + */ +export function toAccountingProvider(provider: string | null | undefined): AccountingProvider { + const normalized = typeof provider === "string" ? provider.trim().toLowerCase() : ""; + if (normalized === "anthropic" || normalized === "claude") return "anthropic"; + if (normalized === "openai" || normalized === "codex" || normalized === "chatgpt") { + return "openai"; + } + if (normalized === "xai" || normalized === "grok") return "xai"; + return "unknown"; +} + +function count(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; +} + +export function buildOmniGlyphAccounting(params: { + provider: string | null | undefined; + model?: string; + originalBytes: number; + transformedBytes: number; + info?: OmniGlyphTransformInfo | null; + durationMs?: number; +}): OmniGlyphAccounting { + const { info } = params; + const provider = toAccountingProvider(params.provider); + + // `baselineImagedTokens` é o custo em tokens de texto do que foi imageado (o + // "teria pago assim"); `imageTokens` é o que as imagens custam de fato. Os + // dois só existem no wire GPT — no Anthropic a evidência honesta cai para + // bytes, e é isso que o campo `evidence` passa a dizer em vez de exibir um + // número sem procedência. + const normalized = normalizeAccounting({ + provider, + ...(params.model ? { model: params.model } : {}), + originalBytes: params.originalBytes, + transformedBytes: params.transformedBytes, + ...(count(info?.baselineImagedTokens) !== undefined + ? { estimatedOriginalInputTokens: info!.baselineImagedTokens } + : {}), + ...(count(info?.imageTokens) !== undefined + ? { estimatedTransformedInputTokens: info!.imageTokens } + : {}), + ...(count(info?.imageTokens) !== undefined ? { imageTokens: info!.imageTokens } : {}), + ...(params.durationMs !== undefined ? { proxyAddedLatencyMs: params.durationMs } : {}), + }); + + const chars = { + ...(count(info?.origChars) !== undefined ? { original: info!.origChars } : {}), + ...(count(info?.compressedChars) !== undefined ? { imaged: info!.compressedChars } : {}), + ...(count(info?.staticChars) !== undefined ? { static: info!.staticChars } : {}), + ...(count(info?.dynamicChars) !== undefined ? { dynamic: info!.dynamicChars } : {}), + ...(count(info?.outgoingTextChars) !== undefined + ? { outgoingText: info!.outgoingTextChars } + : {}), + }; + + return { + provider: normalized.provider, + ...(normalized.model ? { model: normalized.model } : {}), + bytes: normalized.bytes, + tokens: { + ...(normalized.tokens.estimatedOriginalInput !== undefined + ? { estimatedOriginalInput: normalized.tokens.estimatedOriginalInput } + : {}), + ...(normalized.tokens.estimatedActualInput !== undefined + ? { estimatedActualInput: normalized.tokens.estimatedActualInput } + : {}), + ...(normalized.tokens.estimatedReduced !== undefined + ? { estimatedReduced: normalized.tokens.estimatedReduced } + : {}), + ...(normalized.tokens.image !== undefined ? { image: normalized.tokens.image } : {}), + }, + savings: normalized.savings, + images: { + count: count(info?.imageCount) ?? 0, + bytes: count(info?.imageBytes) ?? 0, + ...(count(info?.imagePixels) !== undefined ? { pixels: info!.imagePixels } : {}), + }, + chars, + ...(count(info?.dynamicBlockCount) !== undefined + ? { dynamicBlockCount: info!.dynamicBlockCount } + : {}), + ...(normalized.latency.proxyAddedMs !== undefined + ? { latencyMs: normalized.latency.proxyAddedMs } + : {}), + }; +} diff --git a/open-sse/services/compression/stackedStepCore.ts b/open-sse/services/compression/stackedStepCore.ts index ad15d5100d..81bf0c9af0 100644 --- a/open-sse/services/compression/stackedStepCore.ts +++ b/open-sse/services/compression/stackedStepCore.ts @@ -110,5 +110,8 @@ export function mergeStackStep( techniquesUsed: result.stats.techniquesUsed, ...(result.stats.rulesApplied ? { rulesApplied: result.stats.rulesApplied } : {}), ...(result.stats.durationMs !== undefined ? { durationMs: result.stats.durationMs } : {}), + // O agregado do pipeline soma tokens de todas as engines; a contabilidade + // física do omniglyph só faz sentido no passo que a produziu. + ...(result.stats.omniglyph ? { omniglyph: result.stats.omniglyph } : {}), }); } diff --git a/open-sse/services/compression/strategySelector.ts b/open-sse/services/compression/strategySelector.ts index 0200d66f88..b2fab39d28 100644 --- a/open-sse/services/compression/strategySelector.ts +++ b/open-sse/services/compression/strategySelector.ts @@ -482,6 +482,8 @@ export async function applyCompressionAsync( supportsVision?: boolean | null; /** Direct-to-provider vs. aggregator transport (gates transport-sensitive engines like omniglyph). */ providerTransport?: "direct" | "aggregator"; + /** Provider resolvido — a contabilidade do omniglyph depende dele. */ + provider?: string; imageTransportFidelity?: ImageTransportFidelity; sourceFormat?: CompressionWireFormat; targetFormat?: CompressionWireFormat; @@ -505,6 +507,8 @@ async function runCompressionAsync( supportsVision?: boolean | null; /** Direct-to-provider vs. aggregator transport (gates transport-sensitive engines like omniglyph). */ providerTransport?: "direct" | "aggregator"; + /** Provider resolvido — a contabilidade do omniglyph depende dele. */ + provider?: string; imageTransportFidelity?: ImageTransportFidelity; sourceFormat?: CompressionWireFormat; targetFormat?: CompressionWireFormat; @@ -698,6 +702,8 @@ interface StackOptions { supportsVision?: boolean | null; /** Direct-to-provider vs. aggregator transport (gates transport-sensitive engines like omniglyph). */ providerTransport?: "direct" | "aggregator"; + /** Provider resolvido — a contabilidade do omniglyph depende dele. */ + provider?: string; imageTransportFidelity?: ImageTransportFidelity; sourceFormat?: CompressionWireFormat; targetFormat?: CompressionWireFormat; diff --git a/open-sse/services/compression/types.ts b/open-sse/services/compression/types.ts index 5905a7b49f..ff30cc6403 100644 --- a/open-sse/services/compression/types.ts +++ b/open-sse/services/compression/types.ts @@ -16,6 +16,7 @@ import type { RiskGateConfig } from "./riskGate/riskGate.ts"; import type { PipelineCircuitBreakerConfig } from "./pipelineEngineBreaker.ts"; import type { RiskGateStats } from "./riskGate/riskGateStep.ts"; import type { QuantumLockConfig, QuantumLockStats } from "./quantumLock/quantumPatterns.ts"; +import type { OmniGlyphAccounting } from "./omniglyphTelemetry.ts"; // Re-export so consumers that already import from this module (e.g. src/lib/db/compression.ts) // can get ENGINE_IDS without a second bare `@omniroute/open-sse/...engineCatalog.ts` specifier. @@ -303,6 +304,12 @@ export interface CompressionStats { validationWarnings?: string[]; validationErrors?: string[]; fallbackApplied?: boolean; + /** + * Contabilidade física do OmniGlyph, normalizada pelo próprio pacote + * (`normalizeAccounting`). Só número e enum — ver `omniglyphTelemetry.ts` + * para a allowlist e o que nunca pode entrar aqui. + */ + omniglyph?: OmniGlyphAccounting; riskGate?: RiskGateStats; /** * Phase 4 (B): which `ultra` tier actually ran for this request. @@ -341,6 +348,8 @@ export interface CompressionStats { durationMs?: number; rejected?: boolean; rejectReason?: string; + /** Contabilidade física — presente só no passo omniglyph que comprimiu. */ + omniglyph?: OmniGlyphAccounting; }>; /** Present only when QuantumLock stabilized ≥1 fragment this run. */ quantumLock?: QuantumLockStats; diff --git a/src/app/api/internal/codex-responses-ws/compression.ts b/src/app/api/internal/codex-responses-ws/compression.ts index 72ca922fb4..6216dfa707 100644 --- a/src/app/api/internal/codex-responses-ws/compression.ts +++ b/src/app/api/internal/codex-responses-ws/compression.ts @@ -88,6 +88,7 @@ export async function applyResponsesWsCompression( const result = await applyCompressionAsync(adapter.body, mode, { model: ctx.model, ...resolveOmniGlyphTransport(ctx.provider), + provider: ctx.provider, config: settings as CompressionConfig, cachingContext, }); diff --git a/tests/unit/compression/omniglyph-plumbing.test.ts b/tests/unit/compression/omniglyph-plumbing.test.ts index 5ef741a020..dd1b8f571a 100644 --- a/tests/unit/compression/omniglyph-plumbing.test.ts +++ b/tests/unit/compression/omniglyph-plumbing.test.ts @@ -168,3 +168,28 @@ test("Claude→OpenAI aguarda o wire alvo e não imageia o corpo fonte", async ( assert.equal(post.compressed, true); assert.ok(JSON.stringify(post.body).includes('"type":"image_url"')); }); + +// O `provider` é threaded explicitamente por cada camada do selector (não é um +// spread cego), então uma camada nova pode derrubá-lo em silêncio: a +// contabilidade cairia para `unknown` e passaria a recusar a semântica de cache +// sem nenhum erro aparecer. +test("provider chega do selector até a contabilidade da engine", async () => { + registerBuiltinCompressionEngines(); + const r = await applyCompressionAsync(body(), "stacked", { + model: "claude-fable-5", + provider: "anthropic", + supportsVision: true, + providerTransport: "direct", + imageTransportFidelity: "byte-preserving", + config: { stackedPipeline: [{ engine: "omniglyph" }] } as never, + }); + assert.equal(r.compressed, true); + const omniglyphStep = r.stats?.engineBreakdown?.find((e) => e.engine === "omniglyph"); + assert.ok(omniglyphStep, "omniglyph step deveria aparecer no engineBreakdown"); + assert.equal( + omniglyphStep!.omniglyph?.provider, + "anthropic", + "sem o provider a contabilidade cai para unknown e recusa a semântica de cache" + ); + assert.equal(omniglyphStep!.omniglyph?.savings.evidence, "bytes-only"); +}); diff --git a/tests/unit/compression/omniglyph-telemetry.test.ts b/tests/unit/compression/omniglyph-telemetry.test.ts new file mode 100644 index 0000000000..9233d03266 --- /dev/null +++ b/tests/unit/compression/omniglyph-telemetry.test.ts @@ -0,0 +1,121 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { omniglyphEngine } from "../../../open-sse/services/compression/engines/omniglyphAdapter.ts"; + +// O OmniGlyph 1.4.0 expõe `normalizeAccounting()`, que classifica o GRAU DE +// EVIDÊNCIA da economia (provider-reported / estimated / bytes-only / +// unavailable) e resolve a semântica de cache por provider — Anthropic usa +// buckets disjuntos, OpenAI/xAI reportam cached como subconjunto do input. +// Somar à mão dá double-count. Antes disso o adapter descartava tudo e a UI +// exibia um número sem dizer de onde ele veio. + +const SEGREDO = "sk-ant-api03-SEGREDO-QUE-NAO-PODE-VAZAR-NA-TELEMETRIA"; +const DENSE = + "X".repeat(500) + + "\n" + + Array.from( + { length: 400 }, + (_, i) => `const row_${i} = compute(${i * 17}, "${"v".repeat(80)}");` + ).join("\n"); + +function claudeBody(): Record { + return { + model: "claude-fable-5", + max_tokens: 128, + system: `${DENSE}\nAPI_KEY=${SEGREDO}\ncwd=/home/operador/projeto-secreto`, + messages: [{ role: "user", content: [{ type: "text", text: "oi" }] }], + }; +} + +const OK = { + model: "claude-fable-5", + provider: "anthropic", + supportsVision: true, + providerTransport: "direct" as const, + imageTransportFidelity: "byte-preserving" as const, +}; + +test("stats do omniglyph carregam a contabilidade normalizada com grau de evidência", async () => { + const r = await omniglyphEngine.applyAsync!(claudeBody(), OK as never); + assert.equal(r.compressed, true); + + const accounting = r.stats?.omniglyph; + assert.ok(accounting, "compressão aplicada deve registrar a contabilidade"); + assert.equal(accounting.provider, "anthropic"); + assert.ok( + ["provider-reported", "estimated", "bytes-only", "unavailable"].includes( + accounting.savings.evidence + ), + `evidência inesperada: ${accounting.savings.evidence}` + ); + // Sem contadores do provider nesta etapa, a base honesta é o tamanho do corpo. + assert.equal(accounting.savings.evidence, "bytes-only"); + assert.ok(typeof accounting.bytes.original === "number" && accounting.bytes.original > 0); + assert.ok(typeof accounting.bytes.transformed === "number"); + assert.ok(typeof accounting.images.count === "number" && accounting.images.count > 0); + assert.ok(typeof accounting.images.bytes === "number" && accounting.images.bytes > 0); +}); + +test("telemetria não carrega conteúdo: nem base64, nem prompt, nem segredo, nem ambiente", async () => { + const r = await omniglyphEngine.applyAsync!(claudeBody(), OK as never); + assert.equal(r.compressed, true); + + const serialized = JSON.stringify(r.stats); + + // Valores do request. + assert.ok(!serialized.includes(SEGREDO), "segredo do system vazou na telemetria"); + assert.ok(!serialized.includes("projeto-secreto"), "caminho do operador vazou"); + assert.ok(!serialized.includes("const row_0 = compute"), "texto do system vazou"); + assert.ok(!serialized.includes("data:image/png;base64"), "data URL de imagem vazou"); + assert.ok(!serialized.includes("iVBORw0KGgo"), "cabeçalho PNG em base64 vazou"); + + // Chaves do TransformInfo que carregam conteúdo, hash de conteúdo ou ambiente. + for (const proibida of [ + "imageSourceText", + "imageSourceTexts", + "recoverable", + "systemSha8", + "claudeMdSha8", + "firstUserSha8", + "unknownStaticTags", + "churningStaticTags", + '"env"', + ]) { + assert.ok(!serialized.includes(proibida), `campo proibido na telemetria: ${proibida}`); + } + + // A allowlist é positiva: todo valor da contabilidade é número, string de enum + // conhecida, ou objeto desses. Nada de texto livre vindo do request. + const accounting = r.stats?.omniglyph; + assert.ok(accounting); + const enums = new Set([ + "anthropic", + "openai", + "xai", + "unknown", + "provider-reported", + "estimated", + "bytes-only", + "unavailable", + "claude-fable-5", + ]); + const walk = (value: unknown, path: string): void => { + if (value === undefined || value === null || typeof value === "number") return; + if (typeof value === "string") { + assert.ok(enums.has(value), `string fora da allowlist em ${path}: ${value}`); + return; + } + assert.equal(typeof value, "object", `tipo inesperado em ${path}`); + for (const [k, v] of Object.entries(value as Record)) walk(v, `${path}.${k}`); + }; + walk(accounting, "omniglyph"); +}); + +test("skip não inventa contabilidade", async () => { + const r = await omniglyphEngine.applyAsync!(claudeBody(), { + ...OK, + imageTransportFidelity: "unknown", + } as never); + assert.equal(r.compressed, false); + assert.equal(r.stats?.omniglyph, undefined); +});