From 04af8b1517d907f379d62a45395dad934ffbd414 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Tue, 18 Aug 2026 12:13:48 -0300 Subject: [PATCH] =?UTF-8?q?feat(compression):=20adota=20omniglyph=201.4.0,?= =?UTF-8?q?=20perfis=20sem=C3=A2nticos=20e=20contabilidade=20com=20evid?= =?UTF-8?q?=C3=AAncia=20(#10647)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(compression): target-wire OmniGlyph stage and transport fidelity gate Roda o OmniGlyph depois da tradução para o wire real do provedor, em vez do corpo de origem. Um cliente OpenAI roteado para Claude deixava de comprimir com skip:source_format_not_claude porque o corpo ainda estava em formato OpenAI quando a engine era avaliada. - dispatch nativo por wire: Anthropic Messages, OpenAI Chat Completions e OpenAI Responses (input[] preservado, sem achatar para messages[]); - estágio target-wire pós-translateRequest, com guarda contra dupla compressão no caminho Claude→OpenAI; - preserveSystemPrompt do OmniRoute mapeado para compressSystem: false; - imageTransportPolicy: fidelidade de bytes/dimensões separada de supportsVision; só Anthropic/Claude tem recibo byte-preserving, o resto é fail-closed; - contagem de tokens de data URL PNG no wire OpenAI (marcador ;base64,); - README e i18n en/pt-BR com claims escopados ao caminho medido. * feat(compression): adota omniglyph 1.4.0 e tira o gate de modelo da env do host O 1.4.0 introduziu escopos de segurança e passou a resolvê-los dentro de isOmniGlyphSupportedModel() lendo process.env.OMNIGLYPH_PROFILE. Somado ao OMNIGLYPH_MODELS que já existia, duas variáveis do ambiente do host decidiam em silêncio o gate de TODO request do OmniRoute: passthrough desligaria a engine inteira e OMNIGLYPH_MODELS admitiria modelos sem recibo medido, enquanto a UI segue prometendo "Claude Fable 5 na rota direta medida". O adapter passa a usar isOmniGlyphSupportedModelForScope() com escopo explícito e fixa o escopo mais restrito como teto: a env só pode ESTREITAR a allowlist, nunca alargar. Os dois wires compartilham a mesma lista no pacote desde o 1.4.0, então uma checagem cobre Anthropic e GPT. - omniglyph ^1.3.1 -> ^1.4.0 (lock em 1.4.0); - testes de regressão para os dois caminhos de sequestro por env; - teste de contrato dos exports novos (escopo, perfis, accounting). O 1.4.0 também traz, sem mudança de código aqui: correção do glyph K que era lido como H, remoção do backtracking polinomial no secret-guard, overrides do pnpm em pnpm-workspace.yaml e as transitivas vulneráveis resolvidas. * feat(compression): expõe os perfis semânticos do omniglyph nos três wires O 1.4.0 trouxe perfis nomeados (coding-safe, balanced, aggressive, passthrough), mas só transformAnthropicMessages() os resolve sozinho: os transformadores OpenAI recebem TransformOptions cru e ignorariam o campo. Um perfil escolhido pelo operador valeria no wire Claude e sumiria no OpenAI. O adapter passa a mesclar o perfil com mergeCompressionProfileOptions() antes de chamar Chat Completions e Responses. O default segue aggressive — a política que os recibos publicados mediram. Medido nesta base: com coding-safe/balanced, uma sessão sem histórico acumulado para em below_min_chars e a engine não faz nada, porque os dois fixam minCompressChars no máximo e desligam system/tools/tool-results. Como a engine é opt-in, um default assim entregaria "ligado, 0% de ganho". O perfil é TETO, não piso: mergeCompressionProfileOptions não deixa um override do chamador reabrir uma lane lossy que o perfil fechou. Coberto por teste, por ser contra-intuitivo. Também fecha um caminho em que o OmniRoute violaria a própria política: o wire OpenAI do pacote não tem compressSystem — honra apenas compressTools, gptHistory, minCompressChars e reflow, e sempre troca a instrução por um ponteiro para a imagem. Com preserveSystemPrompt ligado, imagear assim queimaria o prefixo quente que a decisão cache-aware está protegendo, sem nada no corpo devolvido denunciando. A engine agora pula com skip:system_preservation_unsupported_on_wire. * feat(compression): contabilidade física do omniglyph com grau de evidência 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". * feat(compression): perfil do omniglyph configurável, persistido e documentado Fecha o caminho do operador: o perfil já existia no adapter, mas só como default de código. Agora atravessa schema Zod, normalizador do banco, API de settings e a página dedicada do engine. - OmniglyphConfig tipado + omniglyphConfigSchema (z.enum dos quatro perfis); - normalizeOmniglyphConfig: nome desconhecido vindo do storage cai para o default em vez de virar "roda com a política padrão"; - seletor na página do engine, com PATCH próprio — o perfil vive fora do mapa `engines`, e mandá-lo junto reescreveria o mapa inteiro (o store persiste o mapa como uma linha JSON só); - i18n en/pt-BR descrevendo o custo medido de cada perfil, não só o nome; - README e COMPRESSION_ENGINES.md com a regra do teto e o motivo de o default não ser o perfil mais seguro. Corrige de passagem um teste-irmão que ninguém via: o gate de transporte na UI deixou de dizer "direct Anthropic" quando os wires OpenAI nativos entraram, mas tests/unit/ui/omniglyphContextPage.test.tsx continuou afirmando a cópia antiga. O arquivo inteiro estava excluído do vitest.config.ts como "#8618 pre-existing failure", então a quebra passou silenciosa. Com a asserção alinhada o arquivo fecha 3/3, e a exclusão sai — o próprio comentário mandava removê-la quando corrigida. A doc não nomeia OMNIGLYPH_MODELS: o gate de docs fabricadas está certo em apontar que o OmniRoute nunca lê essa env — quem lê é o pacote. * fix(i18n): paridade do locale vi com as chaves novas do perfil do omniglyph `tests/unit/i18n-vi-completeness.test.ts` exige paridade ESTRITA de chaves entre en e vi — diferente do ratchet `i18n:check-ui-coverage`, que passa com 80%. As 11 chaves do seletor de perfil entraram só em en e pt-BR, e o gate de cobertura seguiu verde, então a quebra só apareceu na matriz completa do CI. --------- Co-authored-by: Xiangzhe Co-authored-by: adevwithpurpose --- README.md | 2 +- docs/compression/COMPRESSION_ENGINES.md | 28 +++ open-sse/handlers/chatCore.ts | 18 +- .../compression/engines/omniglyphAdapter.ts | 197 ++++++++++++++---- .../services/compression/engines/types.ts | 12 +- .../compression/imageTransportPolicy.ts | 33 +++ .../compression/omniglyphTelemetry.ts | 156 ++++++++++++++ .../services/compression/stackedStepCore.ts | 3 + open-sse/services/compression/stats.ts | 2 +- .../services/compression/strategySelector.ts | 12 ++ open-sse/services/compression/types.ts | 37 ++++ package-lock.json | 8 +- package.json | 2 +- .../omniglyph/OmniglyphContextPageClient.tsx | 78 +++++++ .../codex-responses-ws/compression.ts | 16 +- src/i18n/messages/en.json | 39 +++- src/i18n/messages/pt-BR.json | 39 +++- src/i18n/messages/vi.json | 21 ++ src/lib/db/compression.ts | 19 ++ .../validation/compressionConfigSchemas.ts | 14 ++ .../compression/image-aware-tokens.test.ts | 32 ++- .../compression/omniglyph-adapter.test.ts | 178 +++++++++++++++- .../omniglyph-chatcore-plumbing.test.ts | 25 ++- .../unit/compression/omniglyph-import.test.ts | 28 +++ .../compression/omniglyph-plumbing.test.ts | 134 ++++++++++++ .../omniglyph-profile-config.test.ts | 83 ++++++++ .../compression/omniglyph-single-mode.test.ts | 10 +- .../compression/omniglyph-telemetry.test.ts | 121 +++++++++++ tests/unit/ui/omniglyphContextPage.test.tsx | 68 +++++- vitest.config.ts | 1 - 30 files changed, 1322 insertions(+), 94 deletions(-) create mode 100644 open-sse/services/compression/imageTransportPolicy.ts create mode 100644 open-sse/services/compression/omniglyphTelemetry.ts create mode 100644 tests/unit/compression/omniglyph-profile-config.test.ts create mode 100644 tests/unit/compression/omniglyph-telemetry.test.ts diff --git a/README.md b/README.md index 3f14eccce9..da6c91f3bf 100644 --- a/README.md +++ b/README.md @@ -877,7 +877,7 @@ Engines run in pipeline order; each is independently toggleable and configurable 9AggressiveSummarization + progressive aging of old turns 10LLMLingua-2ML semantic pruning via MobileBERT ONNX — code-safe, async 11UltraHeuristic token pruning with an optional small-model (SLM) tier - 12OmniGlyphExperimental context-as-image encoding routed to Claude Fable 5 (most aggressive; opt-in) + 12OmniGlyphExperimental context-as-image encoding for measured Claude Fable 5 on the direct Anthropic wire; GPT 5.6 transformers remain fail-closed pending provider receipts. Four compression profiles (aggressive default, balanced, coding-safe, passthrough) (most aggressive; opt-in) Code blocks, URLs and structured data are **always preserved** byte-perfect. **One-click presets** combine the engines: diff --git a/docs/compression/COMPRESSION_ENGINES.md b/docs/compression/COMPRESSION_ENGINES.md index 8b0268b800..22cffe7c42 100644 --- a/docs/compression/COMPRESSION_ENGINES.md +++ b/docs/compression/COMPRESSION_ENGINES.md @@ -19,8 +19,36 @@ OmniRoute compression is built around engine contracts. A mode can run one engin | `aggressive` | Caveman + history/tool summarizers | Long chat sessions | | `ultra` | Caveman + pruning helpers | Context-limit recovery | | `rtk` | RTK | Terminal, shell, build, test, and git output | +| `omniglyph` | OmniGlyph | Context-as-image on the native provider wire | | `stacked` | Pipeline, default `rtk -> caveman` | Mixed tool logs and prose, max savings | +### OmniGlyph compression profiles + +The `omniglyph` engine (package `omniglyph`, 1.4.0+) accepts a named semantic profile, set +globally through `omniglyph.profile` in the compression settings or per step through the +stacked pipeline's step config: + +| Profile | Boundary | +| -------------- | --------------------------------------------------------------------------- | +| `aggressive` | Default. The policy the published receipts measured — images system, tool docs and dense history | +| `balanced` | Keeps live state native, protects the last 8 turns, collapses older closed history | +| `coding-safe` | Keeps authority, tool schemas and live tool output native, protects the last 12 turns | +| `passthrough` | Routes without transforming; the engine is skipped | + +The profile is a **ceiling, not a floor**: `mergeCompressionProfileOptions` in the package +refuses to let a caller override reopen a lossy lane the profile closed, so a per-step +`preserveSystemPrompt: false` cannot re-enable system compression under `coding-safe`. + +Measured on this codebase: `coding-safe` and `balanced` raise `minCompressChars` to its +maximum and keep system, tool schemas and tool results native, so a session that has not +accumulated history yet stops at `below_min_chars` and the engine transforms nothing. That +is why the default is `aggressive` rather than the safest profile. + +The package resolves its own model scope and profile from its environment configuration. +OmniRoute never delegates the decision: the adapter pins the model gate to the package's +most restrictive scope, so host environment settings can only narrow the allowlist, never +widen it past OmniRoute's measured receipts. + ## Engine Registry The registry lives in `open-sse/services/compression/engines/registry.ts`. Engines expose a shared diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 6188c44352..d54689472c 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -89,6 +89,7 @@ import { checkResourcePressureGuard } from "../utils/resourcePressure.ts"; import { normalizeHeaders } from "../utils/headers.ts"; import { resolveChatCoreRequestFormat } from "./chatCore/requestFormat.ts"; import { resolveChatCoreTargetFormat } from "./chatCore/targetFormat.ts"; +import { resolveOmniGlyphTransport } from "../services/compression/imageTransportPolicy.ts"; import { stripStore, usesClaudeBridge } from "./chatCore/agentRouterProtocol.ts"; import { defaultClaudeToolType } from "./chatCore/claudeToolDefaults.ts"; import { injectSystemPrompt, injectCustomSystemPrompt } from "../services/systemPrompt.ts"; @@ -1611,16 +1612,13 @@ export async function handleChatCore({ // models, which is intentionally NOT `false` so the gate still preserves images. supportsVision: getResolvedModelCapabilities({ provider, model: effectiveModel }) .supportsVision, - // Rotas diretas oficiais ('anthropic' API key e 'claude' OAuth) vs agregadores: - // o engine omniglyph exige 'direct' — agregadores redimensionam imagens - // (medido 2026-07-06). OAuth 'claude' é rota direta oficial (#7863). - providerTransport: - provider === "anthropic" || - provider === "claude" || - provider === "openai" || - provider === "xai" - ? ("direct" as const) - : ("aggregator" as const), + // 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 545c152672..c407b9c9ab 100644 --- a/open-sse/services/compression/engines/omniglyphAdapter.ts +++ b/open-sse/services/compression/engines/omniglyphAdapter.ts @@ -9,6 +9,7 @@ * - modelo fora da allowlist medida → skip:model_not_approved * - providerTransport !== 'direct' → skip:transport_not_direct * (agregadores redimensionam imagens e destroem a legibilidade — medido) + * - imageTransportFidelity !== 'byte-preserving' → skip:transport_fidelity_unknown/resizes * - wire não é Claude/OpenAI suportado → skip:target_format_not_supported * - gate de rentabilidade interno do omniglyph decide o resto (patches 28px * exatos; texto esparso/pequeno passa direto) → skip:not_profitable @@ -20,14 +21,65 @@ import type { CompressionEngine, CompressionEngineApplyOptions } from "./types.t import type { CompressionResult } from "../types.ts"; import { createCompressionStats } from "../stats.ts"; import { - isOmniGlyphSupportedGptModel, - isOmniGlyphSupportedModel, + buildOmniGlyphAccounting, + type OmniGlyphAccounting, +} from "../omniglyphTelemetry.ts"; +import { + isOmniGlyphSupportedModelForScope, + mergeCompressionProfileOptions, + resolveCompressionProfile, transformAnthropicMessages, transformOpenAIChatCompletions, transformOpenAIResponses, + type CompressionProfile, + type OmniGlyphSafetyScope, } from "omniglyph"; import { isModelImageable } from "omniglyph/applicability"; +/** + * Teto de modelos do OmniRoute — sempre o escopo mais restrito do pacote. + * + * `isOmniGlyphSupportedModel()` resolve o escopo lendo `OMNIGLYPH_PROFILE` do + * processo, e a lista base sai de `OMNIGLYPH_MODELS`. Duas variáveis do HOST + * decidiriam, em silêncio, o gate de todo request do OmniRoute: `passthrough` + * desligaria a engine inteira e `OMNIGLYPH_MODELS` ADMITIRIA modelos sem + * recibo medido — enquanto a UI continua prometendo "Claude Fable 5 na rota + * direta medida". Fixar o escopo mais restrito faz o gate só poder ESTREITAR + * pela env, nunca alargar, e mantém a decisão na configuração do OmniRoute. + */ +const MEASURED_MODEL_SCOPE: OmniGlyphSafetyScope = "coding-safe"; + +/** + * Perfil padrão do OmniRoute. + * + * `aggressive` é a política que os recibos publicados mediram. `coding-safe` e + * `balanced` fixam `minCompressChars` no máximo e só colapsam histórico antigo: + * medido nesta base, uma sessão sem histórico acumulado fica em + * `below_min_chars` e a engine não faz nada — o operador veria "ligado, 0% de + * ganho". Ficam disponíveis como escolha explícita, não como default. + */ +const DEFAULT_PROFILE: OmniGlyphSafetyScope = "aggressive"; + +/** Perfil do passo (mais específico) > perfil global > default do OmniRoute. */ +function resolveProfileName(options?: CompressionEngineApplyOptions): string { + const step = options?.stepConfig?.profile; + if (typeof step === "string" && step.trim()) return step; + const global = (options?.config as { omniglyph?: { profile?: unknown } } | undefined)?.omniglyph + ?.profile; + if (typeof global === "string" && global.trim()) return global; + return DEFAULT_PROFILE; +} + +/** + * O modelo precisa passar no teto medido E no escopo em vigor. Os dois wires + * (Anthropic e GPT) compartilham a mesma allowlist no pacote desde 1.4.0, então + * uma única checagem cobre os dois. + */ +function isModelWithinScope(model: string, scope: OmniGlyphSafetyScope): boolean { + if (!isOmniGlyphSupportedModelForScope(model, MEASURED_MODEL_SCOPE)) return false; + return isOmniGlyphSupportedModelForScope(model, scope); +} + function skip(body: Record, reason: string): CompressionResult { try { return { @@ -80,9 +132,36 @@ async function applyOmniglyph( const model = options?.model ?? (body as { model?: string }).model ?? ""; if (options?.supportsVision !== true) return skip(body, "no_vision"); if (options?.providerTransport !== "direct") return skip(body, "transport_not_direct"); + // Keep the old direct-call contract usable for standalone callers, but let + // production callers override it explicitly. The chat pipeline supplies + // `unknown` for every provider without a byte-preservation receipt. + if ( + options?.imageTransportFidelity !== undefined && + options.imageTransportFidelity !== "byte-preserving" + ) { + return skip( + body, + options.imageTransportFidelity === "resizes" + ? "transport_resizes_images" + : "transport_fidelity_unknown" + ); + } const stage = options?.compressionStage ?? "pre-translation"; const wireFormat = resolveWireFormat(body, options); + // A source/target format mismatch means the body is still on the wrong wire, + // even when the source itself is native Claude. Defer the engine until the + // translated provider body so Claude→OpenAI cannot be imaged once before + // translation and then considered again on the target wire. + const sourceWireFormat = options?.sourceFormat ?? wireFormat; + if ( + stage === "pre-translation" && + options?.targetFormat && + sourceWireFormat && + options.targetFormat !== sourceWireFormat + ) { + return skip(body, "requires_post_translation"); + } // The pre-translation lane is retained for the existing native Claude // passthrough. OpenAI requests must wait until translation has produced the // exact provider wire, otherwise Responses input[] would be flattened by the @@ -104,62 +183,98 @@ async function applyOmniglyph( ) { return skip(body, "source_format_not_openai_responses"); } - const supportedModel = - wireFormat === "claude" - ? isOmniGlyphSupportedModel(model) - : isOmniGlyphSupportedGptModel(model); - if (!supportedModel) return skip(body, "model_not_approved"); + let profile: CompressionProfile; + try { + profile = resolveCompressionProfile(resolveProfileName(options)); + } catch { + // `resolveCompressionProfile` lança em nome desconhecido. Um perfil que o + // pacote não entende não pode virar "roda com a política padrão". + return skip(body, "invalid_profile"); + } + if (profile.name === "passthrough") return skip(body, "profile_passthrough"); + if (!isModelWithinScope(model, profile.name)) { + return skip(body, "model_not_approved"); + } + const preserveSystemPrompt = + (typeof options?.stepConfig?.preserveSystemPrompt === "boolean" + ? options.stepConfig.preserveSystemPrompt + : options?.config?.preserveSystemPrompt) === true; + // `compressSystem` só existe no transform Anthropic. Os wires OpenAI honram + // apenas compressTools/gptHistory/minCompressChars/reflow e sempre trocam a + // instrução por um ponteiro para a imagem. Imagear o system quando o OmniRoute + // decidiu preservá-lo queimaria o prefixo quente que a política cache-aware + // está protegendo — e nada no corpo devolvido denunciaria isso. Sem como + // honrar a política nesse wire, a engine pula. + if (preserveSystemPrompt && wireFormat !== "claude") { + return skip(body, "system_preservation_unsupported_on_wire"); + } // OmniGlyph 1.3.x deliberately keeps unverified families (currently Grok) // text-only until the operator acknowledges them via its own env gate. if (!isModelImageable(model)) return skip(body, "model_not_imageable"); - const started = Date.now(); let outBody: Record; + let accounting: OmniGlyphAccounting | undefined; try { - const encoded = new TextEncoder().encode(JSON.stringify(body)); - // Branch explicitly so TS narrows each transformer's return type: - // the Anthropic wrapper reports `applied`, the OpenAI ones `info.compressed`. - let applied: boolean; - let transformed: { body: Uint8Array; info: { compressed: boolean; reason?: string } }; - if (wireFormat === "claude") { - const result = await transformAnthropicMessages({ body: encoded, model }); - transformed = result; - applied = result.applied; - } else { - const result = - wireFormat === "openai" - ? await transformOpenAIChatCompletions(encoded) - : await transformOpenAIResponses(encoded); - transformed = result; - applied = result.info.compressed; - } - if (!applied) return skip(body, transformed.info?.reason ?? "not_profitable"); - outBody = JSON.parse(new TextDecoder().decode(transformed.body)) as Record; + // The upstream OpenAI transformer resolves its billing/render profile from + // body.model. Keep the provider body byte-compatible on output, but use the + // already-resolved engine model for that internal gate when a translator + // omitted the model or left an alias in place. + const transformBody = + wireFormat !== "claude" && model && body.model !== model ? { ...body, model } : body; + const encoded = new TextEncoder().encode(JSON.stringify(transformBody)); + const overrides = preserveSystemPrompt ? { compressSystem: false } : {}; + // Só `transformAnthropicMessages` resolve o perfil por conta própria; os + // transformadores OpenAI recebem TransformOptions cru e ignorariam o campo. + const openAIOptions = mergeCompressionProfileOptions(profile, overrides); + const result = + wireFormat === "claude" + ? await transformAnthropicMessages({ + body: encoded, + model, + options: { ...overrides, profile: profile.name }, + }) + : wireFormat === "openai" + ? await transformOpenAIChatCompletions(encoded, openAIOptions) + : await transformOpenAIResponses(encoded, openAIOptions); + const applied = "applied" in result ? result.applied : result.info.compressed; + 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 = { id: "omniglyph", name: "OmniGlyph", description: - "Contexto-como-imagem (Anthropic Fable 5, rota direta): system prompt, tool docs e histórico viram páginas PNG densas — ~10× menos tokens no bloco convertido.", + "Contexto-como-imagem para Claude Fable 5 na rota direta medida; wires GPT nativos ficam disponíveis apenas após recibo de fidelidade do provedor.", icon: "image", targets: ["messages", "tool_results"], stackable: true, @@ -169,7 +284,7 @@ export const omniglyphEngine: CompressionEngine = { id: "omniglyph", name: "OmniGlyph", description: - "Contexto-como-imagem para Claude Fable 5 e GPT 5.6 via wires nativos Anthropic/OpenAI em rota direta.", + "Contexto-como-imagem para Claude Fable 5 na rota direta medida; transformadores GPT nativos permanecem fail-closed até validação do provedor.", inputScope: "mixed", targetLatencyMs: 250, // render+encode PNG de páginas grandes supportsPreview: true, diff --git a/open-sse/services/compression/engines/types.ts b/open-sse/services/compression/engines/types.ts index 0a394baccf..a70d6368d0 100644 --- a/open-sse/services/compression/engines/types.ts +++ b/open-sse/services/compression/engines/types.ts @@ -7,6 +7,9 @@ export type CompressionWireFormat = "claude" | "openai" | "openai-responses" | s export type CompressionStage = "pre-translation" | "post-translation"; +/** Whether an upstream route preserves OmniGlyph PNG bytes and dimensions. */ +export type ImageTransportFidelity = "byte-preserving" | "resizes" | "unknown"; + export interface EngineConfigField { key: string; type: "boolean" | "number" | "string" | "select" | "multiselect"; @@ -42,8 +45,11 @@ export interface CompressionEngineApplyOptions { /** Como o request chega ao provider: rota direta oficial ('direct') vs * agregador que pode reprocessar imagens ('aggregator'). O engine omniglyph * exige 'direct' — medição 2026-07-06: agregadores redimensionam as páginas - * e destroem a legibilidade. undefined = desconhecido = skip (fail-closed). */ + * e destroem a legibilidade. A política de produção também informa + * imageTransportFidelity; chamadas legadas sem esse campo mantêm o gate direct. */ providerTransport?: "direct" | "aggregator"; + /** Independent image-fidelity gate; direct HTTP does not imply byte preservation. */ + imageTransportFidelity?: ImageTransportFidelity; /** Protocol shape before the current compression stage. */ sourceFormat?: CompressionWireFormat; /** Protocol shape expected by the upstream provider. */ @@ -55,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/imageTransportPolicy.ts b/open-sse/services/compression/imageTransportPolicy.ts new file mode 100644 index 0000000000..ae1736d1fb --- /dev/null +++ b/open-sse/services/compression/imageTransportPolicy.ts @@ -0,0 +1,33 @@ +/** + * Provider-level image transport policy for loss-sensitive compression engines. + * + * `supportsVision` only says that a model can read images. OmniGlyph also needs + * the PNG bytes and dimensions to survive the provider route unchanged. The + * allowlist below contains only paths with an existing OmniRoute receipt; + * everything else is deliberately classified as unknown and skipped. + */ + +import type { ImageTransportFidelity } from "./engines/types.ts"; + +export type OmniGlyphTransportPolicy = { + providerTransport: "direct" | "aggregator"; + imageTransportFidelity: ImageTransportFidelity; +}; + +const BYTE_PRESERVING_PROVIDERS = new Set(["anthropic", "claude"]); + +export function resolveOmniGlyphTransport( + provider: string | null | undefined +): OmniGlyphTransportPolicy { + const normalized = typeof provider === "string" ? provider.trim().toLowerCase() : ""; + if (BYTE_PRESERVING_PROVIDERS.has(normalized)) { + return { + providerTransport: "direct", + imageTransportFidelity: "byte-preserving", + }; + } + return { + providerTransport: "aggregator", + imageTransportFidelity: "unknown", + }; +} 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/stats.ts b/open-sse/services/compression/stats.ts index ab6779700f..25c9feed3e 100644 --- a/open-sse/services/compression/stats.ts +++ b/open-sse/services/compression/stats.ts @@ -77,7 +77,7 @@ function isOpenAIResponsesPngImagePart(value: unknown): value is OpenAIResponses } function pngDimensionsFromDataUrl(value: string): { width: number; height: number } | null { - const marker = ",base64,"; + const marker = ";base64,"; const markerIndex = value.indexOf(marker); if (markerIndex < 0) return null; return decodePngDimensions(value.slice(markerIndex + marker.length)); diff --git a/open-sse/services/compression/strategySelector.ts b/open-sse/services/compression/strategySelector.ts index 7fd80a0694..2c9624730a 100644 --- a/open-sse/services/compression/strategySelector.ts +++ b/open-sse/services/compression/strategySelector.ts @@ -11,6 +11,7 @@ import type { CompressionEngineApplyOptions, CompressionStage, CompressionWireFormat, + ImageTransportFidelity, } from "./engines/types.ts"; import { applyLiteCompression } from "./lite.ts"; import { cavemanCompress } from "./caveman.ts"; @@ -266,6 +267,7 @@ export function applyCompression( options?: { model?: string; supportsVision?: boolean | null; + imageTransportFidelity?: ImageTransportFidelity; sourceFormat?: CompressionWireFormat; targetFormat?: CompressionWireFormat; compressionStage?: CompressionStage; @@ -292,6 +294,7 @@ function runCompression( options?: { model?: string; supportsVision?: boolean | null; + imageTransportFidelity?: ImageTransportFidelity; sourceFormat?: CompressionWireFormat; targetFormat?: CompressionWireFormat; compressionStage?: CompressionStage; @@ -479,6 +482,9 @@ 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; compressionStage?: CompressionStage; @@ -501,6 +507,9 @@ 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; compressionStage?: CompressionStage; @@ -693,6 +702,9 @@ 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; compressionStage?: CompressionStage; diff --git a/open-sse/services/compression/types.ts b/open-sse/services/compression/types.ts index 5905a7b49f..f35dfac37f 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. @@ -157,6 +158,22 @@ export interface LiveZoneConfig { enabled: boolean; } +/** Perfil semântico do OmniGlyph (pacote 1.4.0+). */ +export type OmniglyphProfile = "coding-safe" | "balanced" | "aggressive" | "passthrough"; + +/** + * Política do OmniGlyph escolhida pelo operador. + * + * O perfil é um TETO: `mergeCompressionProfileOptions` do pacote não deixa um + * override reabrir uma lane que o perfil fechou. Trocar de `aggressive` para + * `coding-safe` mantém system, schemas de tools e tool results nativos, ao custo + * medido de a engine não fazer nada até a sessão acumular histórico + * (`minCompressChars` vai ao máximo). Por isso o default é `aggressive`. + */ +export interface OmniglyphConfig { + profile: OmniglyphProfile; +} + /** Lite detail settings for proactive request-time transformations. */ export interface LiteConfig { /** Truncate tool-result strings over 2,000 characters before provider dispatch. */ @@ -206,6 +223,8 @@ export interface CompressionConfig { comboOverrides: Record; compressionComboId?: string | null; stackedPipeline?: CompressionPipelineStep[]; + /** Política do engine OmniGlyph (perfil semântico). */ + omniglyph?: OmniglyphConfig; /** Opt-in QuantumLock cache-prefix stabilization (default off). */ quantumLock?: QuantumLockConfig; /** Opt-in per-step fidelity gate (default disabled). */ @@ -303,6 +322,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 +366,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; @@ -460,6 +487,16 @@ export const DEFAULT_COMPRESSION_LANGUAGE_CONFIG: CompressionLanguageConfig = { enabledPacks: ["en"], }; +/** + * `aggressive` é a política que os recibos publicados mediram. Medido nesta + * base: com `coding-safe`/`balanced`, uma sessão sem histórico acumulado para em + * `below_min_chars` e a engine não faz nada — como o OmniGlyph é opt-in, esse + * default entregaria "ligado, 0% de ganho". + */ +export const DEFAULT_OMNIGLYPH_CONFIG: OmniglyphConfig = { + profile: "aggressive", +}; + export const DEFAULT_CONTEXT_EDITING_CONFIG: ContextEditingConfig = { enabled: false, }; diff --git a/package-lock.json b/package-lock.json index 8af4841897..2741e67b37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,7 +60,7 @@ "next-intl": "^4.13.6", "next-themes": "^0.4.6", "node-machine-id": "^1.1.12", - "omniglyph": "^1.0.2", + "omniglyph": "^1.4.0", "onnxruntime-node": "~1.24.3", "open": "^11.0.1", "ora": "^9.4.1", @@ -29929,9 +29929,9 @@ "license": "MIT" }, "node_modules/omniglyph": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/omniglyph/-/omniglyph-1.3.1.tgz", - "integrity": "sha512-6QnZCoXYczjsPN2x+XpbimimjO6kCoSZUzsdSvoKjtw28U1U724VgLICBNaLX4FFs5jd7SrYNNs9Aee2iIkcoA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/omniglyph/-/omniglyph-1.4.0.tgz", + "integrity": "sha512-4zAqDW9pBb2i+fiGOVLIKbdecZeo55UmKQoLku1apxo3TSA4gfqcMo2MqQpal1VicckUwTbexFLasW7qngXUHA==", "license": "MIT", "dependencies": { "gpt-tokenizer": "^3.4.0" diff --git a/package.json b/package.json index d6ff6ad849..bc810f2f84 100644 --- a/package.json +++ b/package.json @@ -305,7 +305,7 @@ "next-intl": "^4.13.6", "next-themes": "^0.4.6", "node-machine-id": "^1.1.12", - "omniglyph": "^1.0.2", + "omniglyph": "^1.4.0", "open": "^11.0.1", "ora": "^9.4.1", "parse5": "^8.0.1", diff --git a/src/app/(dashboard)/dashboard/context/omniglyph/OmniglyphContextPageClient.tsx b/src/app/(dashboard)/dashboard/context/omniglyph/OmniglyphContextPageClient.tsx index 016888454d..5a14d801e3 100644 --- a/src/app/(dashboard)/dashboard/context/omniglyph/OmniglyphContextPageClient.tsx +++ b/src/app/(dashboard)/dashboard/context/omniglyph/OmniglyphContextPageClient.tsx @@ -17,10 +17,22 @@ import { SAMPLE_BEFORE_TEXT, SAMPLE_PAGE_PNG_DATA_URI, SAMPLE_METRICS } from "./ interface CompressionConfigLite { engines?: Record; + omniglyph?: { profile?: string }; } type EngineMap = Record; +/** Perfis do pacote, na ordem do mais permissivo ao mais restrito. O primeiro é + * o default: a política que os recibos publicados mediram. */ +const PROFILES = [ + { id: "aggressive", key: "aggressive" }, + { id: "balanced", key: "balanced" }, + { id: "coding-safe", key: "codingSafe" }, + { id: "passthrough", key: "passthrough" }, +] as const; + +type ProfileId = (typeof PROFILES)[number]["id"]; + /** The measured fail-closed gate chain, in evaluation order. Every no-op is telemetered * as `skip:`; the engine only fires when all pass. */ const GATES = [ @@ -157,6 +169,40 @@ function GatesCard() { ); } +function ProfileCard(props: { + profile: ProfileId; + disabled: boolean; + onChange: (next: ProfileId) => void; +}) { + const t = useTranslations("omniglyph"); + const selected = PROFILES.find((p) => p.id === props.profile) ?? PROFILES[0]; + return ( + +
+

{t("profileTitle")}

+

{t("profileDescription")}

+ + + {t(`profiles.${selected.key}.description`)} + +
+
+ ); +} + function EnableCard(props: { enabled: boolean; disabled: boolean; @@ -199,6 +245,7 @@ function EnableCard(props: { export default function OmniglyphContextPageClient() { const [engines, setEngines] = useState({}); const [enabled, setEnabled] = useState(false); + const [profile, setProfile] = useState("aggressive"); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [status, setStatus] = useState<"" | "saved" | "error">(""); @@ -210,6 +257,8 @@ export default function OmniglyphContextPageClient() { const e = data?.engines ?? {}; setEngines(e); setEnabled(e.omniglyph?.enabled === true); + const stored = data?.omniglyph?.profile; + if (PROFILES.some((p) => p.id === stored)) setProfile(stored as ProfileId); }) .catch(() => {}) .finally(() => setLoading(false)); @@ -245,12 +294,41 @@ export default function OmniglyphContextPageClient() { } }; + // O perfil vive na config do engine (não no mapa `engines`), então é um PATCH + // próprio — misturá-lo no payload do toggle reescreveria o mapa inteiro. + const changeProfile = async (next: ProfileId) => { + const previous = profile; + setProfile(next); + setSaving(true); + setStatus(""); + try { + const res = await fetch("/api/settings/compression", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ omniglyph: { profile: next } }), + }); + if (res.ok) { + setStatus("saved"); + setTimeout(() => setStatus(""), 2000); + } else { + setProfile(previous); + setStatus("error"); + } + } catch { + setProfile(previous); + setStatus("error"); + } finally { + setSaving(false); + } + }; + return (
+ omniglyph mode. This is a preview and remains off by default until end-to-end validation is complete.", + "enableDescription": "Runs last in the stack after text engines clean the request, using the native provider wire; OpenAI Chat/Responses requests are compressed after translation so their shape is preserved. It also runs standalone through omniglyph mode. This is a preview and remains off by default until end-to-end validation is complete.", + "profileTitle": "Compression profile", + "profileDescription": "The profile is a ceiling, not a floor: a stricter profile cannot be reopened by a per-step override. Measured on this codebase: with coding-safe or balanced, a session that has not accumulated history yet compresses nothing, because both raise the minimum-characters threshold and keep the system prompt, tool schemas and tool results native.", + "profiles": { + "aggressive": { + "label": "Aggressive (default)", + "description": "The policy the published receipts measured. Images the system prompt, tool documentation and dense history." + }, + "balanced": { + "label": "Balanced", + "description": "Keeps live state native and protects the last 8 turns; only collapses older closed history." + }, + "codingSafe": { + "label": "Coding-safe", + "description": "Keeps authority, tool schemas and live tool output native, protecting the last 12 turns. Compresses nothing until the session accumulates history." + }, + "passthrough": { + "label": "Passthrough", + "description": "Routes without transforming. The engine is skipped and the request is forwarded untouched." + } + }, + "profileAria": "Select the OmniGlyph compression profile", "saved": "Saved.", "saveFailed": "Could not save.", "enableAria": "Enable the OmniGlyph engine", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index f9edbcd9ba..0d79c2a0d2 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -8560,13 +8560,13 @@ }, "omniglyph": { "preview": "Visualização", - "description": "Compressão de contexto como imagem. Renderiza o prompt de sistema, a documentação de ferramentas e o histórico denso como páginas PNG compactas que o Claude Fable 5 lê em vez de texto. Os tokens de imagem são cobrados por dimensões em vez de caracteres, então o bloco convertido custa cerca de 10× menos. Somente na rota direta da Anthropic.", + "description": "Compressão de contexto como imagem. Renderiza o prompt de sistema, a documentação de ferramentas e o histórico denso como páginas PNG compactas para a rota medida do Claude Fable 5. O pacote OmniGlyph também expõe transformadores nativos para GPT 5.6, mas o OmniRoute mantém essas rotas fail-closed até existirem recibos de fidelidade do provedor e de leitura.", "economicsTitle": "A economia", "economics": { "fewerTokens": "menos tokens no bloco convertido", "savings": "economia de ponta a ponta (medida)", "imageTokens": "tokens de imagem para uma página de 1568×728 (cerca de 28 mil caracteres)", - "accuracy": "precisão de leitura no Fable 5 (n=30)" + "accuracy": "recibo de leitura do Fable 5 (escopo medido)" }, "beforeAfterTitle": "Antes → depois", "blockSavings": "−{percent}% de tokens neste bloco", @@ -8579,18 +8579,18 @@ "gates": { "model": { "label": "Modelo", - "pass": "claude-fable-5", - "why": "Somente o Fable 5 lê páginas densas com 100% de precisão (medido, n=30). GPT-5.5 e Gemini 2.5 Flash são bloqueados." + "pass": "claude-fable-5 (medido); gpt-5.6 (HOLD)", + "why": "Somente a rota medida do Fable 5 está habilitada no OmniRoute. O GPT 5.6 tem suporte estrutural no pacote, mas continua bloqueado até seu recibo do provedor; famílias não verificadas, como Grok, permanecem em texto." }, "transport": { "label": "Transporte", - "pass": "direto Anthropic", - "why": "Agregadores reamostram imagens e destroem a legibilidade — apenas a rota direta é confiável." + "pass": "provedor direto", + "why": "Agregadores podem reamostrar imagens e destruir a legibilidade — apenas uma rota direta do provedor é confiável." }, "format": { "label": "Formato", - "pass": "Claude nativo", - "why": "O corpo deve usar o formato Claude e nunca colocar um papel system dentro de messages." + "pass": "Claude/OpenAI nativo", + "why": "O corpo precisa corresponder ao wire do provedor: Claude Messages, OpenAI Chat Completions ou input[] nativo do OpenAI Responses." }, "profitable": { "label": "Rentável", @@ -8599,7 +8599,28 @@ } }, "enableTitle": "Ativar a engine", - "enableDescription": "Executa por último na pilha (depois que RTK/Caveman limpa o texto, o OmniGlyph converte o restante em imagens) e também roda de forma independente pelo modo omniglyph. Este é um preview e permanece desativado por padrão até que a validação de ponta a ponta seja concluída.", + "enableDescription": "Executa por último na pilha, depois que os engines de texto limpam a solicitação, usando o wire nativo do provedor; solicitações OpenAI Chat/Responses são comprimidas depois da tradução para preservar seu formato. Também roda de forma independente pelo modo omniglyph. Este é um preview e permanece desativado por padrão até que a validação de ponta a ponta seja concluída.", + "profileTitle": "Perfil de compressão", + "profileDescription": "O perfil é um teto, não um piso: um perfil mais restrito não pode ser reaberto por um override de passo. Medido nesta base: com coding-safe ou balanced, uma sessão que ainda não acumulou histórico não comprime nada, porque os dois elevam o mínimo de caracteres e mantêm o system prompt, os schemas de tools e os tool results nativos.", + "profiles": { + "aggressive": { + "label": "Agressivo (padrão)", + "description": "A política que os recibos publicados mediram. Imageia o system prompt, a documentação de tools e o histórico denso." + }, + "balanced": { + "label": "Equilibrado", + "description": "Mantém o estado vivo nativo e protege os últimos 8 turnos; só colapsa histórico antigo já fechado." + }, + "codingSafe": { + "label": "Seguro para código", + "description": "Mantém autoridade, schemas de tools e saída de ferramenta nativos, protegendo os últimos 12 turnos. Não comprime nada até a sessão acumular histórico." + }, + "passthrough": { + "label": "Sem transformação", + "description": "Só roteia. A engine é pulada e o request segue intacto." + } + }, + "profileAria": "Selecionar o perfil de compressão do OmniGlyph", "saved": "Salvo.", "saveFailed": "Não foi possível salvar.", "enableAria": "Ativar a engine OmniGlyph", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 39ce2a4652..a18723573d 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -8600,6 +8600,27 @@ }, "enableTitle": "Bật bộ máy", "enableDescription": "Chạy cuối cùng trong ngăn xếp (sau khi RTK/Caveman làm sạch văn bản, OmniGlyph chuyển phần còn lại thành hình ảnh) và cũng có thể chạy độc lập qua chế độ omniglyph. Đây là bản xem trước và mặc định vẫn tắt cho đến khi hoàn tất kiểm thử đầu cuối.", + "profileTitle": "Hồ sơ nén", + "profileDescription": "Hồ sơ là giới hạn trên, không phải giới hạn dưới: một hồ sơ chặt chẽ hơn không thể bị mở lại bằng ghi đè ở từng bước. Đo trên chính mã nguồn này: với coding-safe hoặc balanced, một phiên chưa tích lũy lịch sử sẽ không nén gì cả, vì cả hai đều nâng ngưỡng số ký tự tối thiểu và giữ nguyên system prompt, lược đồ công cụ và kết quả công cụ.", + "profiles": { + "aggressive": { + "label": "Tích cực (mặc định)", + "description": "Chính sách mà các biên nhận đã công bố đo được. Kết xuất system prompt, tài liệu công cụ và lịch sử dày đặc thành ảnh." + }, + "balanced": { + "label": "Cân bằng", + "description": "Giữ nguyên trạng thái đang hoạt động và bảo vệ 8 lượt gần nhất; chỉ gộp lịch sử cũ đã khép lại." + }, + "codingSafe": { + "label": "An toàn cho lập trình", + "description": "Giữ nguyên thẩm quyền, lược đồ công cụ và đầu ra công cụ đang hoạt động, bảo vệ 12 lượt gần nhất. Không nén gì cho đến khi phiên tích lũy lịch sử." + }, + "passthrough": { + "label": "Không biến đổi", + "description": "Chỉ định tuyến. Engine bị bỏ qua và yêu cầu được chuyển tiếp nguyên vẹn." + } + }, + "profileAria": "Chọn hồ sơ nén của OmniGlyph", "saved": "Đã lưu.", "saveFailed": "Không thể lưu.", "enableAria": "Bật bộ máy OmniGlyph", diff --git a/src/lib/db/compression.ts b/src/lib/db/compression.ts index 7dd03bdbb8..58e4a4a889 100644 --- a/src/lib/db/compression.ts +++ b/src/lib/db/compression.ts @@ -26,6 +26,8 @@ import { DEFAULT_CODEX_RESPONSES_CONFIG, type CodexResponsesConfig, type ContextEditingConfig, + DEFAULT_OMNIGLYPH_CONFIG, + type OmniglyphConfig, type EngineToggle, type HeadroomConfig, type McpAccessibilityConfig, @@ -304,6 +306,19 @@ function normalizeLanguageConfig(value: unknown): CompressionLanguageConfig { }; } +function normalizeOmniglyphConfig(value: unknown): OmniglyphConfig { + const record = toRecord(value); + const profile = record.profile; + // Um perfil desconhecido não pode virar "roda com a política padrão": cai para + // o default explícito, e o adapter ainda falha fechado se algo passar por aqui. + return { + profile: + profile === "coding-safe" || profile === "balanced" || profile === "passthrough" + ? profile + : DEFAULT_OMNIGLYPH_CONFIG.profile, + }; +} + function normalizeContextEditingConfig(value: unknown): ContextEditingConfig { const record = toRecord(value); return { @@ -621,6 +636,7 @@ export async function getCompressionSettings(): Promise { ...buildDetailConfigDefaults(), contextBudget: normalizeContextBudgetConfig(undefined), contextEditing: { ...DEFAULT_CONTEXT_EDITING_CONFIG }, + omniglyph: { ...DEFAULT_OMNIGLYPH_CONFIG }, liveZone: { enabled: false }, engines: {}, activeComboId: null, @@ -744,6 +760,9 @@ export async function getCompressionSettings(): Promise { case "contextEditing": config.contextEditing = normalizeContextEditingConfig(parsed); break; + case "omniglyph": + config.omniglyph = normalizeOmniglyphConfig(parsed); + break; case "liveZone": config.liveZone = { enabled: toRecord(parsed).enabled === true }; break; diff --git a/src/shared/validation/compressionConfigSchemas.ts b/src/shared/validation/compressionConfigSchemas.ts index e644fcb688..50a21aaf10 100644 --- a/src/shared/validation/compressionConfigSchemas.ts +++ b/src/shared/validation/compressionConfigSchemas.ts @@ -346,6 +346,19 @@ export const contextBudgetConfigSchema = z }) .strict(); +/** + * Perfil semântico do OmniGlyph. O perfil é um TETO: o pacote não deixa um + * override reabrir uma lane que o perfil fechou. `aggressive` é o default e a + * política que os recibos publicados mediram; `coding-safe`/`balanced` mantêm + * system, schemas de tools e tool results nativos, e não comprimem nada até a + * sessão acumular histórico. + */ +export const omniglyphConfigSchema = z + .object({ + profile: z.enum(["coding-safe", "balanced", "aggressive", "passthrough"]), + }) + .strict(); + export const compressionSettingsUpdateSchema = z .object({ enabled: z.boolean().optional(), @@ -373,6 +386,7 @@ export const compressionSettingsUpdateSchema = z ccr: ccrConfigSchema.optional(), contextBudget: contextBudgetConfigSchema.optional(), contextEditing: contextEditingConfigSchema.optional(), + omniglyph: omniglyphConfigSchema.optional(), liveZone: z.object({ enabled: z.boolean() }).strict().optional(), engines: z.record(z.string(), engineToggleSchema).optional(), enginesExplicit: z.boolean().optional(), diff --git a/tests/unit/compression/image-aware-tokens.test.ts b/tests/unit/compression/image-aware-tokens.test.ts index 8a0e224dd4..32c18336c1 100644 --- a/tests/unit/compression/image-aware-tokens.test.ts +++ b/tests/unit/compression/image-aware-tokens.test.ts @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert"; import { estimateCompressionTokens } from "../../../open-sse/services/compression/stats.ts"; -import { transformAnthropicMessages } from "omniglyph"; +import { transformAnthropicMessages, transformOpenAIChatCompletions } from "omniglyph"; const CHARS_PER_TOKEN = 4; @@ -67,3 +67,33 @@ test("regressão: corpo sem imagem estima o MESMO valor de antes (char-count pur const expected = Math.ceil(JSON.stringify(plainBody).length / CHARS_PER_TOKEN); assert.equal(estimateCompressionTokens(plainBody), expected); }); + +test("estimateCompressionTokens contabiliza image_url PNG no wire OpenAI", async () => { + const originalBody = { + model: "gpt-5.6", + messages: [ + { role: "system", content: DENSE }, + { role: "user", content: "oi" }, + ], + }; + const encoded = new TextEncoder().encode(JSON.stringify(originalBody)); + const result = await transformOpenAIChatCompletions(encoded); + assert.equal(result.info.compressed, true); + const compressedBody = JSON.parse(new TextDecoder().decode(result.body)) as Record< + string, + unknown + >; + assert.ok(JSON.stringify(compressedBody).includes('"type":"image_url"')); + + const originalEstimate = estimateCompressionTokens(originalBody); + const compressedEstimate = estimateCompressionTokens(compressedBody); + const naiveEstimate = Math.ceil(JSON.stringify(compressedBody).length / CHARS_PER_TOKEN); + assert.ok( + compressedEstimate < naiveEstimate, + `expected image-aware (${compressedEstimate}) < base64 char estimate (${naiveEstimate})` + ); + assert.ok( + compressedEstimate < originalEstimate, + `expected OpenAI image compression (${compressedEstimate}) < original (${originalEstimate})` + ); +}); diff --git a/tests/unit/compression/omniglyph-adapter.test.ts b/tests/unit/compression/omniglyph-adapter.test.ts index c003846dfb..0961a090d1 100644 --- a/tests/unit/compression/omniglyph-adapter.test.ts +++ b/tests/unit/compression/omniglyph-adapter.test.ts @@ -18,11 +18,17 @@ function claudeBody(): Record { messages: [{ role: "user", content: [{ type: "text", text: "oi" }] }], }; } -const OK = { model: "claude-fable-5", supportsVision: true, providerTransport: "direct" as const }; +const OK = { + model: "claude-fable-5", + supportsVision: true, + providerTransport: "direct" as const, + imageTransportFidelity: "byte-preserving" as const, +}; const GPT_OK = { model: "gpt-5.6", supportsVision: true, providerTransport: "direct" as const, + imageTransportFidelity: "byte-preserving" as const, sourceFormat: "openai" as const, targetFormat: "openai" as const, compressionStage: "post-translation" as const, @@ -82,6 +88,15 @@ test("skip fail-closed: sem supportsVision / transporte agregador / undefined", } }); +test("skip fail-closed: rota direta sem recibo de fidelidade de imagem", async () => { + const r = await omniglyphEngine.applyAsync!(claudeBody(), { + ...OK, + imageTransportFidelity: "unknown", + }); + assert.equal(r.compressed, false); + assert.ok(r.stats?.techniquesUsed.includes("skip:transport_fidelity_unknown")); +}); + test("skip: modelo fora da allowlist medida", async () => { const body = { ...claudeBody(), model: "gpt-5.5" }; const r = await omniglyphEngine.applyAsync!(body, { ...OK, model: "gpt-5.5" }); @@ -110,6 +125,31 @@ test("cache_control do cliente sobrevive byte a byte", async () => { assert.ok(JSON.stringify(r.body).includes('"cache_control"')); }); +test("preserveSystemPrompt mantém o sistema nativo e ainda pode comprimir tool_result", async () => { + const body = { + ...claudeBody(), + messages: [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_1", + content: DENSE, + }, + ], + }, + ], + }; + const r = await omniglyphEngine.applyAsync!(body, { + ...OK, + config: { preserveSystemPrompt: true } as never, + }); + assert.equal(r.compressed, true); + assert.equal((r.body as { system?: unknown }).system, DENSE); + assert.ok(JSON.stringify(r.body).includes('"type":"image"')); +}); + test("apply síncrono é pass-through seguro (engine async-only)", () => { const body = claudeBody(); const r = omniglyphEngine.apply(body, OK); @@ -147,3 +187,139 @@ test("OpenAI não roda no estágio pré-tradução", async () => { assert.equal(r.compressed, false); assert.ok(r.stats?.techniquesUsed.includes("skip:requires_post_translation")); }); + +// OmniGlyph 1.4.0 introduziu escopos de segurança (`coding-safe`/`balanced`/ +// `aggressive`/`passthrough`) e passou a resolvê-los, dentro de +// `isOmniGlyphSupportedModel()`, lendo `process.env.OMNIGLYPH_PROFILE`. Isso +// transforma uma variável de ambiente do HOST num gate silencioso de TODO +// request do OmniRoute: um `OMNIGLYPH_PROFILE=passthrough` exportado no shell +// do processo desligaria a engine sem que nenhuma configuração do OmniRoute +// tivesse mudado — e sem nenhum sinal na UI. A política é do OmniRoute; o +// adapter tem de passar o escopo explicitamente. +async function withEnv(key: string, value: string, fn: () => Promise): Promise { + const had = Object.prototype.hasOwnProperty.call(process.env, key); + const previous = process.env[key]; + process.env[key] = value; + try { + return await fn(); + } finally { + if (had) process.env[key] = previous; + else delete process.env[key]; + } +} + +test("OMNIGLYPH_PROFILE do host não decide o gate de modelo do OmniRoute", async () => { + const r = await withEnv("OMNIGLYPH_PROFILE", "passthrough", () => + omniglyphEngine.applyAsync!(claudeBody(), OK) + ); + assert.equal( + r.compressed, + true, + "env do processo não pode desligar a engine: o escopo vem da config do OmniRoute" + ); +}); + +test("OMNIGLYPH_PROFILE do host não amplia a allowlist de modelos do OmniRoute", async () => { + const body = { ...claudeBody(), model: "claude-sonnet-5" }; + const r = await withEnv("OMNIGLYPH_PROFILE", "aggressive", () => + withEnv("OMNIGLYPH_MODELS", "claude-fable-5,claude-sonnet-5", () => + omniglyphEngine.applyAsync!(body, { ...OK, model: "claude-sonnet-5" }) + ) + ); + assert.equal(r.compressed, false, "modelo sem recibo medido não pode entrar via env do host"); + assert.ok(r.stats?.techniquesUsed.includes("skip:model_not_approved")); +}); + +// --------------------------------------------------------------------------- +// Perfis semânticos (OmniGlyph 1.4.0) +// +// `transformAnthropicMessages()` resolve e mescla o perfil sozinho, mas os +// transformadores OpenAI recebem `TransformOptions` cru e NÃO conhecem +// `profile`. Sem o merge explícito do host, um perfil escolhido pelo operador +// valeria no wire Claude e seria silenciosamente ignorado no wire OpenAI. +// --------------------------------------------------------------------------- + +/** Histórico longo o bastante para o colapso valer mesmo com o system nativo. */ +function claudeBodyWithHistory(): Record { + const messages: Array> = []; + for (let i = 0; i < 40; i++) { + messages.push({ role: "user", content: [{ type: "text", text: `pergunta ${i}: ${DENSE}` }] }); + messages.push({ + role: "assistant", + content: [{ type: "text", text: `resposta ${i}: ${DENSE}` }], + }); + } + messages.push({ role: "user", content: [{ type: "text", text: "oi" }] }); + return { model: "claude-fable-5", max_tokens: 128, system: DENSE, messages }; +} + +test("perfil coding-safe chega ao wire OpenAI (não só ao wrapper Anthropic)", async () => { + const aggressive = await omniglyphEngine.applyAsync!(openaiChatBody(), { + ...GPT_OK, + stepConfig: { profile: "aggressive" } as never, + }); + assert.equal(aggressive.compressed, true, "aggressive é a política medida atual"); + + const codingSafe = await omniglyphEngine.applyAsync!(openaiChatBody(), { + ...GPT_OK, + stepConfig: { profile: "coding-safe" } as never, + }); + assert.equal( + codingSafe.compressed, + false, + "coding-safe mantém system e schemas de tools nativos no wire OpenAI" + ); + assert.ok( + codingSafe.stats?.techniquesUsed.some((t) => t.startsWith("skip:below_min_chars")), + `esperado skip por minCompressChars, veio ${JSON.stringify(codingSafe.stats?.techniquesUsed)}` + ); +}); + +test("perfil é TETO, não piso: coding-safe mantém o system nativo mesmo sem preserveSystemPrompt", async () => { + const body = claudeBodyWithHistory(); + const r = await omniglyphEngine.applyAsync!(body, { + ...OK, + stepConfig: { profile: "coding-safe", preserveSystemPrompt: false } as never, + }); + assert.equal(r.compressed, true, "o histórico antigo ainda colapsa"); + assert.equal( + (r.body as { system?: unknown }).system, + DENSE, + "coding-safe não deixa um override do chamador reabrir a compressão do system" + ); +}); + +test("perfil passthrough desliga a engine sem tocar no corpo", async () => { + const body = claudeBodyWithHistory(); + const r = await omniglyphEngine.applyAsync!(body, { + ...OK, + stepConfig: { profile: "passthrough" } as never, + }); + assert.equal(r.compressed, false); + assert.ok(r.stats?.techniquesUsed.includes("skip:profile_passthrough")); + assert.deepEqual(r.body, body); +}); + +test("perfil inválido falha fechado, não propaga exceção", async () => { + const r = await omniglyphEngine.applyAsync!(claudeBody(), { + ...OK, + stepConfig: { profile: "turbo-max" } as never, + }); + assert.equal(r.compressed, false); + assert.ok(r.stats?.techniquesUsed.includes("skip:invalid_profile")); +}); + +test("preserveSystemPrompt: wire OpenAI pula, porque o pacote não sabe preservar system lá", async () => { + // O transform OpenAI do OmniGlyph 1.4.0 honra apenas compressTools, gptHistory, + // minCompressChars e reflow — não existe compressSystem nesse wire, e a + // instrução vira sempre um ponteiro para a imagem. Imagear assim queimaria o + // prefixo quente que a decisão cache-aware do OmniRoute mandou preservar, e o + // OmniRoute não teria como saber. Sem opção de honrar a política, pula. + const r = await omniglyphEngine.applyAsync!(openaiChatBody(), { + ...GPT_OK, + config: { preserveSystemPrompt: true } as never, + }); + assert.equal(r.compressed, false); + assert.ok(r.stats?.techniquesUsed.includes("skip:system_preservation_unsupported_on_wire")); + assert.deepEqual(r.body, openaiChatBody()); +}); diff --git a/tests/unit/compression/omniglyph-chatcore-plumbing.test.ts b/tests/unit/compression/omniglyph-chatcore-plumbing.test.ts index 73f7af4875..8dc8221da0 100644 --- a/tests/unit/compression/omniglyph-chatcore-plumbing.test.ts +++ b/tests/unit/compression/omniglyph-chatcore-plumbing.test.ts @@ -1,12 +1,27 @@ import { readFileSync } from "node:fs"; import assert from "node:assert/strict"; import { test } from "node:test"; +import { resolveOmniGlyphTransport } from "../../../open-sse/services/compression/imageTransportPolicy.ts"; -test("chatCore treats both Anthropic providers as direct OmniGlyph transports", () => { +test("chatCore uses the measured Anthropic byte-preserving transport policy", () => { const chatCore = readFileSync("open-sse/handlers/chatCore.ts", "utf8"); - assert.match( - chatCore, - /providerTransport:\s*provider === "anthropic"\s*\|\|\s*provider === "claude"[\s\S]{0,160}?"direct"/ - ); + assert.match(chatCore, /resolveOmniGlyphTransport\(provider\)/); + const translationIndex = chatCore.indexOf("translatedBody = translateRequest("); + const postCompressionIndex = chatCore.indexOf("if (runPostTranslationCompression"); + assert.ok(translationIndex >= 0, "chatCore deve manter a tradução explícita"); + assert.ok(postCompressionIndex > translationIndex, "OmniGlyph target-wire roda após tradução"); + assert.match(chatCore, /compressionStage: "post-translation"/); + assert.deepEqual(resolveOmniGlyphTransport("anthropic"), { + providerTransport: "direct", + imageTransportFidelity: "byte-preserving", + }); + assert.deepEqual(resolveOmniGlyphTransport("claude"), { + providerTransport: "direct", + imageTransportFidelity: "byte-preserving", + }); + assert.deepEqual(resolveOmniGlyphTransport("openai"), { + providerTransport: "aggregator", + imageTransportFidelity: "unknown", + }); }); diff --git a/tests/unit/compression/omniglyph-import.test.ts b/tests/unit/compression/omniglyph-import.test.ts index 35d3f0a756..04014451b5 100644 --- a/tests/unit/compression/omniglyph-import.test.ts +++ b/tests/unit/compression/omniglyph-import.test.ts @@ -6,6 +6,7 @@ test("pacote omniglyph exporta a API que o adapter consome", async () => { assert.equal(typeof mod.transformAnthropicMessages, "function"); assert.equal(typeof mod.transformOpenAIChatCompletions, "function"); assert.equal(typeof mod.transformOpenAIResponses, "function"); + assert.equal(typeof mod.transformRequest, "function"); assert.equal(typeof mod.isOmniGlyphSupportedModel, "function"); assert.equal(typeof mod.isOmniGlyphSupportedGptModel, "function"); assert.equal(mod.isOmniGlyphSupportedModel("claude-fable-5"), true); @@ -14,3 +15,30 @@ test("pacote omniglyph exporta a API que o adapter consome", async () => { const applicability = await import("omniglyph/applicability"); assert.equal(typeof applicability.isModelImageable, "function"); }); + +// Superfícies introduzidas no 1.4.0. Sem esta asserção, uma remoção upstream só +// apareceria em runtime — o adapter importa esses símbolos diretamente. +test("omniglyph 1.4.0 exporta escopo de segurança, perfis e accounting", async () => { + const mod = await import("omniglyph"); + + // Gate de modelo por escopo explícito (não pela env do processo). + assert.equal(typeof mod.isOmniGlyphSupportedModelForScope, "function"); + assert.equal(mod.isOmniGlyphSupportedModelForScope("claude-fable-5", "coding-safe"), true); + assert.equal(mod.isOmniGlyphSupportedModelForScope("claude-sonnet-5", "coding-safe"), false); + assert.equal( + mod.isOmniGlyphSupportedModelForScope("claude-fable-5", "passthrough"), + false, + "passthrough não habilita modelo nenhum" + ); + + // Perfis semânticos. + assert.equal(typeof mod.resolveCompressionProfile, "function"); + assert.equal(typeof mod.mergeCompressionProfileOptions, "function"); + assert.equal(typeof mod.shouldKeepToolResultSharp, "function"); + assert.equal(mod.resolveCompressionProfile("coding-safe").name, "coding-safe"); + assert.throws(() => mod.resolveCompressionProfile("nao-existe")); + + // Contabilidade física normalizada. + assert.equal(typeof mod.normalizeAccounting, "function"); + assert.equal(typeof mod.providerActualInputTokens, "function"); +}); diff --git a/tests/unit/compression/omniglyph-plumbing.test.ts b/tests/unit/compression/omniglyph-plumbing.test.ts index e5231a10a7..dd1b8f571a 100644 --- a/tests/unit/compression/omniglyph-plumbing.test.ts +++ b/tests/unit/compression/omniglyph-plumbing.test.ts @@ -18,6 +18,20 @@ const body = () => ({ messages: [{ role: "user", content: [{ type: "text", text: "oi" }] }], }); +const openaiBody = () => ({ + model: "gpt-5.6", + messages: [ + { role: "system", content: DENSE }, + { role: "user", content: "oi" }, + ], +}); + +const responsesBody = () => ({ + model: "gpt-5.6", + instructions: DENSE, + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "oi" }] }], +}); + // Agora que `estimateCompressionTokens` (stats.ts) é image-aware (Task 6), o guard // honesto de inflação agregada do stacked (`guardPipelineInflation` em // pipelineGuards.ts) não reverte mais a saída imageada do omniglyph: o estimador @@ -30,6 +44,7 @@ test("stacked com step omniglyph recebe providerTransport (engine roda, não é model: "claude-fable-5", supportsVision: true, providerTransport: "direct", + imageTransportFidelity: "byte-preserving", config: { stackedPipeline: [{ engine: "rtk" }, { engine: "omniglyph" }] } as never, }); const omniglyphStep = r.stats?.engineBreakdown?.find((e) => e.engine === "omniglyph"); @@ -59,3 +74,122 @@ test("stacked sem providerTransport 'direct' pula omniglyph (transport_not_direc assert.ok(omniglyphStep, "omniglyph step deveria aparecer no engineBreakdown mesmo pulado"); assert.ok(omniglyphStep!.techniquesUsed.includes("skip:transport_not_direct")); }); + +test("stacked pós-tradução preserva o wire OpenAI Chat e executa OmniGlyph nativo", async () => { + registerBuiltinCompressionEngines(); + const r = await applyCompressionAsync(openaiBody(), "stacked", { + model: "gpt-5.6", + supportsVision: true, + providerTransport: "direct", + imageTransportFidelity: "byte-preserving", + sourceFormat: "openai", + targetFormat: "openai", + compressionStage: "post-translation", + config: { stackedPipeline: [{ engine: "rtk" }, { engine: "omniglyph" }] } as never, + }); + const omniglyphStep = r.stats?.engineBreakdown?.find((e) => e.engine === "omniglyph"); + assert.ok(omniglyphStep); + assert.ok(omniglyphStep!.techniquesUsed.includes("omniglyph:context-as-image")); + assert.equal(r.compressed, true); + assert.ok(JSON.stringify(r.body).includes('"type":"image_url"')); + assert.ok( + r.stats?.validationWarnings?.some((warning) => warning.includes("rtk: skipped (stage")), + "engines sem suporte ao estágio devem ser explicitamente pulados" + ); +}); + +test("stacked pós-tradução não adapta input[] Responses para messages[] antes do OmniGlyph", async () => { + registerBuiltinCompressionEngines(); + const r = await applyCompressionAsync(responsesBody(), "stacked", { + model: "gpt-5.6", + supportsVision: true, + providerTransport: "direct", + imageTransportFidelity: "byte-preserving", + sourceFormat: "openai-responses", + targetFormat: "openai-responses", + compressionStage: "post-translation", + config: { stackedPipeline: [{ engine: "omniglyph" }] } as never, + }); + assert.equal(r.compressed, true); + assert.ok(JSON.stringify(r.body).includes('"type":"input_image"')); + assert.ok(Array.isArray((r.body as { input?: unknown }).input)); +}); + +test("OpenAI→Claude usa o wire traduzido sem uma segunda tradução", async () => { + registerBuiltinCompressionEngines(); + const translatedClaudeBody = body(); + const r = await applyCompressionAsync(translatedClaudeBody, "omniglyph", { + model: "claude-fable-5", + supportsVision: true, + providerTransport: "direct", + imageTransportFidelity: "byte-preserving", + sourceFormat: "openai", + targetFormat: "claude", + compressionStage: "post-translation", + }); + assert.equal(r.compressed, true); + assert.ok(JSON.stringify(r.body).includes('"type":"image"')); + assert.ok(!JSON.stringify(r.body).includes('"type":"image_url"')); +}); + +test("Claude→OpenAI aguarda o wire alvo e não imageia o corpo fonte", async () => { + registerBuiltinCompressionEngines(); + const pre = await applyCompressionAsync(body(), "omniglyph", { + model: "claude-fable-5", + supportsVision: true, + providerTransport: "direct", + imageTransportFidelity: "byte-preserving", + sourceFormat: "claude", + targetFormat: "openai", + compressionStage: "pre-translation", + }); + assert.equal(pre.compressed, false); + assert.ok(pre.stats?.techniquesUsed.includes("skip:requires_post_translation")); + + const post = await applyCompressionAsync( + { + model: "gpt-5.6", + messages: [ + { role: "system", content: DENSE }, + { role: "user", content: "oi" }, + ], + }, + "omniglyph", + { + model: "gpt-5.6", + supportsVision: true, + providerTransport: "direct", + imageTransportFidelity: "byte-preserving", + sourceFormat: "claude", + targetFormat: "openai", + compressionStage: "post-translation", + } + ); + 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-profile-config.test.ts b/tests/unit/compression/omniglyph-profile-config.test.ts new file mode 100644 index 0000000000..d24f361bae --- /dev/null +++ b/tests/unit/compression/omniglyph-profile-config.test.ts @@ -0,0 +1,83 @@ +/** + * Perfil semântico do OmniGlyph (pacote 1.4.0) — persistência ponta a ponta. + * + * Cobre: + * 1. compressionSettingsUpdateSchema aceita omniglyph:{profile} e RECUSA nome inválido + * 2. updateCompressionSettings / getCompressionSettings fazem round-trip do perfil + * 3. o default persistido é `aggressive` (a política que os recibos mediram) + * 4. um perfil desconhecido vindo do storage cai para o default, não vaza adiante + */ +import { describe, it, beforeEach, afterEach, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { compressionSettingsUpdateSchema } from "../../../src/shared/validation/compressionConfigSchemas.ts"; +import { DEFAULT_OMNIGLYPH_CONFIG } from "../../../open-sse/services/compression/types.ts"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-omniglyph-profile-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../../src/lib/db/core.ts"); +const { getCompressionSettings, updateCompressionSettings } = await import( + "../../../src/lib/db/compression.ts" +); + +beforeEach(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +}); + +afterEach(() => { + core.resetDbInstance(); +}); + +after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; +}); + +describe("omniglyph profile config", () => { + it("o schema aceita os quatro perfis e recusa qualquer outro nome", () => { + for (const profile of ["coding-safe", "balanced", "aggressive", "passthrough"]) { + const parsed = compressionSettingsUpdateSchema.safeParse({ omniglyph: { profile } }); + assert.equal(parsed.success, true, `perfil válido recusado: ${profile}`); + } + // Um nome inválido tem de morrer no schema. Se passar, o adapter ainda falha + // fechado (skip:invalid_profile), mas o operador veria a engine desligada + // sem nenhum erro na tela de configuração. + assert.equal( + compressionSettingsUpdateSchema.safeParse({ omniglyph: { profile: "turbo-max" } }).success, + false + ); + assert.equal( + compressionSettingsUpdateSchema.safeParse({ omniglyph: { profile: "" } }).success, + false + ); + }); + + it("o default é aggressive — a política que os recibos publicados mediram", async () => { + assert.equal(DEFAULT_OMNIGLYPH_CONFIG.profile, "aggressive"); + assert.equal((await getCompressionSettings()).omniglyph?.profile, "aggressive"); + }); + + it("round-trip do perfil escolhido pelo operador", async () => { + await updateCompressionSettings({ omniglyph: { profile: "coding-safe" } }); + assert.equal((await getCompressionSettings()).omniglyph?.profile, "coding-safe"); + + await updateCompressionSettings({ omniglyph: { profile: "passthrough" } }); + assert.equal((await getCompressionSettings()).omniglyph?.profile, "passthrough"); + }); + + it("perfil desconhecido no storage cai para o default em vez de vazar adiante", async () => { + await updateCompressionSettings({ omniglyph: { profile: "coding-safe" } }); + // Simula uma linha gravada por uma versão futura/adulterada, contornando o schema. + await updateCompressionSettings({ omniglyph: { profile: "turbo-max" } } as never); + assert.equal((await getCompressionSettings()).omniglyph?.profile, "aggressive"); + }); +}); diff --git a/tests/unit/compression/omniglyph-single-mode.test.ts b/tests/unit/compression/omniglyph-single-mode.test.ts index d9ff66fb99..79accde726 100644 --- a/tests/unit/compression/omniglyph-single-mode.test.ts +++ b/tests/unit/compression/omniglyph-single-mode.test.ts @@ -3,8 +3,13 @@ import assert from "node:assert"; import { applyCompressionAsync } from "../../../open-sse/services/compression/strategySelector.ts"; import { registerBuiltinCompressionEngines } from "../../../open-sse/services/compression/engines/index.ts"; -const DENSE = "X".repeat(500) + "\n" + - Array.from({ length: 400 }, (_, i) => `const row_${i} = compute(${i * 17}, "${"v".repeat(80)}");`).join("\n"); +const DENSE = + "X".repeat(500) + + "\n" + + Array.from( + { length: 400 }, + (_, i) => `const row_${i} = compute(${i * 17}, "${"v".repeat(80)}");` + ).join("\n"); const body = () => ({ model: "claude-fable-5", max_tokens: 128, @@ -18,6 +23,7 @@ test("modo omniglyph sozinho comprime (selecionar o modo é o enable)", async () model: "claude-fable-5", supportsVision: true, providerTransport: "direct", + imageTransportFidelity: "byte-preserving", }); assert.equal(r.compressed, true); assert.ok(JSON.stringify(r.body).includes('"type":"image"')); 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); +}); diff --git a/tests/unit/ui/omniglyphContextPage.test.tsx b/tests/unit/ui/omniglyphContextPage.test.tsx index 816d1a25ee..d3893947b9 100644 --- a/tests/unit/ui/omniglyphContextPage.test.tsx +++ b/tests/unit/ui/omniglyphContextPage.test.tsx @@ -108,7 +108,11 @@ describe("OmniglyphContextPage", () => { expect(img!.getAttribute("src")).toMatch(/^data:image\/png;base64,/); // Gates expect(text).toContain("claude-fable-5"); - expect(text).toContain("direct Anthropic"); + // A cópia do gate deixou de dizer "direct Anthropic" quando os wires OpenAI + // nativos entraram: transporte direto é condição de TODO provider, e o que + // restringe a rota ao Anthropic é o recibo de fidelidade de bytes, não o + // rótulo do transporte. + expect(text).toContain("direct provider"); // Config control expect(container.querySelector('[data-testid="omniglyph-enable-toggle"]')).toBeTruthy(); }); @@ -138,4 +142,66 @@ describe("OmniglyphContextPage", () => { expect(engines.rtk?.enabled).toBe(true); expect(engines.caveman?.enabled).toBe(false); }); + + it("carrega o perfil salvo e faz PATCH só do perfil, sem reescrever o mapa de engines", async () => { + const puts: CapturedPut[] = []; + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + const stored = { + enabled: true, + engines: { rtk: { enabled: true, level: "standard" } }, + omniglyph: { profile: "coding-safe" }, + }; + vi.spyOn(globalThis, "fetch").mockImplementation( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = (init?.method ?? "GET").toUpperCase(); + if (url.includes("/api/settings/compression")) { + if (method === "PUT") { + puts.push({ url, body: JSON.parse(String(init?.body ?? "{}")) }); + return json(stored); + } + return json(stored); + } + return json({}, 404); + } + ); + + const { default: Page } = await import( + "../../../src/app/(dashboard)/dashboard/context/omniglyph/OmniglyphContextPageClient" + ); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const select = container.querySelector( + '[data-testid="omniglyph-profile-select"]' + ) as HTMLSelectElement | null; + expect(select, "o seletor de perfil deve existir").toBeTruthy(); + expect(select!.value, "o perfil salvo tem de vir selecionado").toBe("coding-safe"); + + // Os quatro perfis do pacote, com aggressive como primeiro (default). + expect(Array.from(select!.options).map((o) => o.value)).toEqual([ + "aggressive", + "balanced", + "coding-safe", + "passthrough", + ]); + + await act(async () => { + select!.value = "passthrough"; + select!.dispatchEvent(new Event("change", { bubbles: true })); + }); + await flush(); + + expect(puts.length).toBe(1); + expect(puts[0]!.body).toEqual({ omniglyph: { profile: "passthrough" } }); + // O perfil vive fora do mapa `engines`: mandá-lo junto reescreveria o mapa inteiro. + expect(puts[0]!.body.engines).toBeUndefined(); + }); }); diff --git a/vitest.config.ts b/vitest.config.ts index 27c5f552aa..06fe4a94ed 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -88,7 +88,6 @@ export default defineConfig({ "src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx", // #8618 — pre-existing failure; remove this exclusion when fixed "tests/unit/ui/compression-combos-routing-mode-6760.test.tsx", // #8618 — pre-existing failure; remove this exclusion when fixed "tests/unit/ui/use-local-storage-pool-migration.test.tsx", // #8618 — pre-existing failure; remove this exclusion when fixed - "tests/unit/ui/omniglyphContextPage.test.tsx", // #8618 — pre-existing failure; remove this exclusion when fixed "tests/unit/ui/waterfallInspector.test.tsx", // #8618 — pre-existing failure; remove this exclusion when fixed "tests/unit/ui/playground-compare-column.test.tsx", // #8618 — pre-existing failure; remove this exclusion when fixed "tests/unit/ui/playground-chat-tab.test.tsx", // #8618 — pre-existing failure; remove this exclusion when fixed