diff --git a/CHANGELOG.md b/CHANGELOG.md index 82a27f3..de38f0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · semantic ver ## [Unreleased] +### Added + +- **feat(reliability):** named `coding-safe`, `balanced`, `aggressive` and + `passthrough` compression profiles for library callers and + `OMNIGLYPH_PROFILE` in both proxy hosts, with non-weakenable native + authority/live-state boundaries and separate Anthropic/GPT history floors. + (thanks @alteixeira20) + ### Changed - **perf(render):** harden the rendered-page LRU with canonical full SHA-256 diff --git a/README.md b/README.md index 73d2463..c008b75 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,7 @@ bulky request block ──► profitability gate ──► reflow + render (1-bi All off by default — the compression path above is unchanged unless you set them. `omniglyph --help` lists every flag. - `OMNIGLYPH_GUARD_SECRETS=text|redact` — keep API keys, tokens, and other credentials out of rendered images: `text` never images a block that holds one, `redact` masks it in place. Never alters what the upstream API receives. +- `OMNIGLYPH_PROFILE=coding-safe|balanced|aggressive|passthrough` — choose a semantic compression boundary. `coding-safe` keeps authority, schemas, live tool state, and a 12-turn tail native; `balanced` protects an 8-turn tail; `aggressive` is the unchanged default; `passthrough` routes without transforming. - `OMNIGLYPH_KEEP_SYSTEM_TEXT=1` — never image the session config (system prompt, tool docs, reminders); tool outputs and old history still convert. Guards against Anthropic's refusal classifier on system-shaped images. - `OMNIGLYPH_MODELS` — comma-separated model bases to image (default `claude-fable-5,gpt-5.6`; `off` disables). **Grok** is supported opt-in on the OpenAI-compatible wire but stays fail-closed: text-only until acked via `OMNIGLYPH_UNVERIFIED_MODELS=grok-4.5`, pending its own reading receipt. diff --git a/src/core/applicability.ts b/src/core/applicability.ts index 5c2b1e5..372aa0a 100644 --- a/src/core/applicability.ts +++ b/src/core/applicability.ts @@ -8,6 +8,12 @@ export type OmniGlyphApplicabilityReason = | 'empty_body' | 'model_unverified'; +export type OmniGlyphSafetyScope = + | 'coding-safe' + | 'balanced' + | 'aggressive' + | 'passthrough'; + export interface OmniGlyphApplicabilityInput { readonly model?: string | null; readonly method?: string | null; @@ -45,7 +51,7 @@ let runtimeModelBases: readonly string[] | null = null; * ~2pp arithmetic, 6/15 dense-hex recall vs Fable's 100/100; GPT 5.5 likewise * degrades on imaged history/context) — so silently imaging them is the wrong * default. Both stay opt-in via the dashboard chips or OMNIGLYPH_MODELS. */ -const DEFAULT_MODEL_BASES = ['claude-fable-5', 'gpt-5.6']; +export const DEFAULT_MODEL_BASES = ['claude-fable-5', 'gpt-5.6'] as const; function falsey(v: string): boolean { return /^(0|false|no|off|none)$/i.test(v.trim()); @@ -66,11 +72,50 @@ function envOrDefaultBases(): string[] { return trimmed.split(',').map((s) => s.trim()).filter(Boolean); } -function allowedModelBases(): string[] { +function configuredModelBases(): string[] { if (runtimeModelBases !== null) return [...runtimeModelBases]; return envOrDefaultBases(); } +function parseSafetyScope(raw: string | undefined): OmniGlyphSafetyScope { + const value = (raw ?? '').trim().toLowerCase(); + if (!value || value === 'aggressive' || value === 'legacy') return 'aggressive'; + if (value === 'safe' || value === 'coding' || value === 'coding-safe') { + return 'coding-safe'; + } + if (value === 'balanced') return 'balanced'; + return 'passthrough'; +} + +function activeSafetyScope(): OmniGlyphSafetyScope { + const raw = typeof process !== 'undefined' ? process.env?.OMNIGLYPH_PROFILE : undefined; + return parseSafetyScope(raw); +} + +function modelBaseMatches(id: string, candidate: string): boolean { + const target = candidate.toLowerCase(); + return id === target || id.startsWith(`${target}-`); +} + +function safetyAllowsConfiguredBase( + candidate: string, + scope: OmniGlyphSafetyScope, +): boolean { + if (scope === 'passthrough') return false; + if (scope === 'aggressive') return true; + const id = baseModelId(candidate).toLowerCase(); + return DEFAULT_MODEL_BASES.some((safe) => modelBaseMatches(id, safe)); +} + +function allowedModelBasesForScope(scope: OmniGlyphSafetyScope): string[] { + return configuredModelBases().filter((candidate) => + safetyAllowsConfiguredBase(candidate, scope)); +} + +function allowedModelBases(): string[] { + return allowedModelBasesForScope(activeSafetyScope()); +} + /** Current effective allowed-model scope (Claude + GPT). */ export function getAllowedModelBases(): string[] { return allowedModelBases(); @@ -89,20 +134,32 @@ export function setAllowedModelBases(list: readonly string[] | null): void { /** Membership test against the single allowed scope. Matches exact base or `-suffix` * alias; [variant] tags stripped first. */ -function isAllowed(model: string | null | undefined): boolean { +function isAllowedForScope( + model: string | null | undefined, + scope: OmniGlyphSafetyScope, +): boolean { if (typeof model !== 'string') return false; - const base = baseModelId(model); - return allowedModelBases().some((b) => base === b || base.startsWith(`${b}-`)); + const base = baseModelId(model).toLowerCase(); + return allowedModelBasesForScope(scope).some((candidate) => + modelBaseMatches(base, candidate)); +} + +/** Pure model gate for an explicitly selected semantic compression profile. */ +export function isOmniGlyphSupportedModelForScope( + model: string | null | undefined, + scope: OmniGlyphSafetyScope, +): boolean { + return isAllowedForScope(model, scope); } /** True when OmniGlyph may transform this Anthropic model. */ export function isOmniGlyphSupportedModel(model: string | null | undefined): boolean { - return isAllowed(model); + return isAllowedForScope(model, activeSafetyScope()); } /** True when OmniGlyph may transform this GPT model. Shares the single OMNIGLYPH_MODELS scope. */ export function isOmniGlyphSupportedGptModel(model: string | null | undefined): boolean { - return isAllowed(model); + return isAllowedForScope(model, activeSafetyScope()); } /** Canonical set of Anthropic Messages routes OmniGlyph transforms. Shared with diff --git a/src/core/index.ts b/src/core/index.ts index 93fdb4e..5938fd3 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -3,10 +3,12 @@ export { getConfiguredModelBases, isOmniGlyphSupportedGptModel, isOmniGlyphSupportedModel, + isOmniGlyphSupportedModelForScope, setAllowedModelBases, shouldTransformAnthropicMessages, type OmniGlyphApplicabilityInput, type OmniGlyphApplicabilityReason, + type OmniGlyphSafetyScope, } from './applicability.js'; export { buildCountTokensBodies, @@ -18,6 +20,7 @@ export { export { transformAnthropicMessages, renderTextToImages, + type CompressionProfileName, type OmniGlyphOptions, type OmniGlyphReason, type OmniGlyphTransformInput, @@ -26,6 +29,12 @@ export { type RenderedTextImage, type RenderTextToImagesResult, } from './library.js'; +export { + mergeCompressionProfileOptions, + resolveCompressionProfile, + shouldKeepToolResultSharp, + type CompressionProfile, +} from './safety-policy.js'; export { transformRequest, type TransformInfo as OmniGlyphTransformInfo, diff --git a/src/core/library.ts b/src/core/library.ts index e9be356..430a6ec 100644 --- a/src/core/library.ts +++ b/src/core/library.ts @@ -1,4 +1,4 @@ -import { isOmniGlyphSupportedModel } from './applicability.js'; +import { isOmniGlyphSupportedModelForScope } from './applicability.js'; import { countCacheControlMarkers } from './measurement.js'; import { renderTextToPngsWithCharLimit, @@ -19,8 +19,13 @@ import { type KeepSharpBlock, type RecoverableBlock, } from './transform.js'; +import { + mergeCompressionProfileOptions, + resolveCompressionProfile, + type CompressionProfileName, +} from './safety-policy.js'; -export type { KeepSharpBlock, RecoverableBlock }; +export type { CompressionProfileName, KeepSharpBlock, RecoverableBlock }; export type BytesLike = Uint8Array | ArrayBuffer | ArrayBufferView; @@ -31,6 +36,8 @@ export interface OmniGlyphOptions > { /** Test/debug-only bypass. Product hosts should prefer their dashboard setting. */ readonly compress?: boolean; + /** Named semantic policy. Unset preserves the existing aggressive behavior. */ + readonly profile?: CompressionProfileName; } export interface OmniGlyphTransformInput { @@ -106,19 +113,22 @@ export async function transformAnthropicMessages( input: OmniGlyphTransformInput, ): Promise { const original = toUint8Array(input.body); - if (!isOmniGlyphSupportedModel(input.model)) { - return { - body: original, - applied: false, - reason: 'unsupported_model', - detail: input.model ?? undefined, - info: emptyInfo('unsupported_model'), - cache: { ownsCacheControl: false, markerCount: countCacheControlMarkers(original) }, - }; - } try { - const { body, info } = await transformRequest(original, input.options); + const { profile: requestedProfile, ...overrides } = input.options ?? {}; + const profile = resolveCompressionProfile(requestedProfile); + if (!isOmniGlyphSupportedModelForScope(input.model, profile.name)) { + return { + body: original, + applied: false, + reason: 'unsupported_model', + detail: input.model ?? undefined, + info: emptyInfo('unsupported_model'), + cache: { ownsCacheControl: false, markerCount: countCacheControlMarkers(original) }, + }; + } + const options = mergeCompressionProfileOptions(profile, overrides); + const { body, info } = await transformRequest(original, options); const reason = classifyReason(info); const markerCount = countCacheControlMarkers(body); return { diff --git a/src/core/safety-policy.ts b/src/core/safety-policy.ts new file mode 100644 index 0000000..6da2950 --- /dev/null +++ b/src/core/safety-policy.ts @@ -0,0 +1,241 @@ +import type { HistoryCollapseOptions } from './history.js'; +import type { GptHistoryOptions } from './openai-history.js'; +import type { KeepSharpBlock, TransformOptions } from './transform.js'; + +export type CompressionProfileName = + | 'coding-safe' + | 'balanced' + | 'aggressive' + | 'passthrough'; + +export interface CompressionProfile { + readonly name: CompressionProfileName; + readonly description: string; + readonly transform: Readonly; +} + +type AnthropicHistoryPolicy = Partial< + Pick +>; + +const SAMPLE_HEAD_CHARS = 64_000; +const SAMPLE_TAIL_CHARS = 32_000; + +function boundedSample(text: string): string { + if (text.length <= SAMPLE_HEAD_CHARS + SAMPLE_TAIL_CHARS) return text; + return `${text.slice(0, SAMPLE_HEAD_CHARS)}\n${text.slice(-SAMPLE_TAIL_CHARS)}`; +} + +function looksStructured(text: string): boolean { + const value = text.trim(); + if (!( + (value.startsWith('{') && value.endsWith('}')) || + (value.startsWith('[') && value.endsWith(']')) + )) return false; + try { + const parsed: unknown = JSON.parse(value); + return parsed !== null && typeof parsed === 'object'; + } catch { + return false; + } +} + +function lineSignalsMachineState(line: string): boolean { + const value = line.trimStart(); + const sourcePrefixes = [ + 'import ', 'export ', 'const ', 'let ', 'var ', 'function ', 'class ', + 'interface ', 'type ', 'def ', 'async def ', 'fn ', 'package ', '#include ', + 'public ', 'private ', 'protected ', + ]; + if (sourcePrefixes.some((prefix) => value.startsWith(prefix))) return true; + if ( + value.startsWith('diff --git ') || value.startsWith('@@ -') || + value.startsWith('+++ ') || value.startsWith('--- ') || + value.startsWith('at ') || value.startsWith('Traceback (most recent call last)') || + value.startsWith('Caused by:') || value.startsWith('panic:') + ) return true; + return line.includes('error TS') || line.includes('warning TS') || + line.includes('fatal error:') || line.includes('undefined reference') || + line.includes('SyntaxError:') || line.includes('TypeError:') || + line.includes('AssertionError:') || line.includes('FAILED') || + line.includes('FAIL ') || line.includes('PASS ') || line.includes('test result:'); +} + +function hasExactMachineToken(text: string): boolean { + // All expressions operate on a bounded sample and avoid nested quantifiers. + return /\b[0-9a-f]{7,40}\b/.test(text) || + /\b[A-Z][A-Z0-9_]{2,}\b/.test(text) || + /--[A-Za-z][A-Za-z0-9_-]*/.test(text) || + /(?:^|\s)(?:\.\.\/|\.\/|\/)[^\s:]+:\d+(?::\d+)?\b/m.test(text); +} + +/** + * Conservative classifier for exact live coding state. False positives only + * retain text; false negatives could send machine-readable state through OCR. + */ +export function shouldKeepToolResultSharp(block: KeepSharpBlock): boolean { + if (!block.text) return false; + const sample = boundedSample(block.text); + if (sample.includes('```') || looksStructured(sample)) return true; + for (const line of sample.split('\n')) { + if (lineSignalsMachineState(line)) return true; + } + return hasExactMachineToken(sample); +} + +const PROFILES: Record = { + 'coding-safe': { + name: 'coding-safe', + description: 'keep authority and live tool state native; collapse only old closed history', + transform: { + compress: true, + compressSystem: false, + compressTools: false, + compressReminders: false, + compressToolResults: false, + minCompressChars: Number.MAX_SAFE_INTEGER, + collapseHistory: true, + historyAmortizationHorizon: 4, + reflow: true, + anthropicHistory: { keepTail: 12, minCollapsePrefix: 16 }, + gptHistory: { + keepTail: 12, + keepRecentPairs: 12, + minCollapsePrefix: 16, + minCollapseTokens: 4_000, + }, + keepSharp: shouldKeepToolResultSharp, + }, + }, + balanced: { + name: 'balanced', + description: 'keep live state native with a shorter protected history tail', + transform: { + compress: true, + compressSystem: false, + compressTools: false, + compressReminders: false, + compressToolResults: false, + minCompressChars: Number.MAX_SAFE_INTEGER, + collapseHistory: true, + historyAmortizationHorizon: 3, + reflow: true, + anthropicHistory: { keepTail: 8, minCollapsePrefix: 12 }, + gptHistory: { + keepTail: 8, + keepRecentPairs: 8, + minCollapsePrefix: 12, + minCollapseTokens: 3_000, + }, + keepSharp: shouldKeepToolResultSharp, + }, + }, + aggressive: { + name: 'aggressive', + description: 'existing transform policy', + transform: {}, + }, + passthrough: { + name: 'passthrough', + description: 'route only; disable context transforms', + transform: { compress: false }, + }, +}; + +export function resolveCompressionProfile(raw?: string): CompressionProfile { + const value = (raw ?? '').trim().toLowerCase(); + if (!value || value === 'aggressive' || value === 'legacy') return PROFILES.aggressive; + if (value === 'safe' || value === 'coding' || value === 'coding-safe') { + return PROFILES['coding-safe']; + } + if (value === 'balanced') return PROFILES.balanced; + if (value === 'off' || value === 'disabled' || value === 'passthrough') { + return PROFILES.passthrough; + } + throw new Error( + `invalid OMNIGLYPH_PROFILE '${raw}'; expected coding-safe, balanced, aggressive or passthrough`, + ); +} + +function maxDefined(base: number | undefined, caller: number | undefined): number | undefined { + if (base === undefined) return caller; + if (caller === undefined) return base; + return Math.max(base, caller); +} + +function tightenAnthropicHistory( + base: AnthropicHistoryPolicy | undefined, + caller: AnthropicHistoryPolicy | undefined, +): AnthropicHistoryPolicy | undefined { + if (!base && !caller) return undefined; + return { + ...base, + ...caller, + keepTail: maxDefined(base?.keepTail, caller?.keepTail), + minCollapsePrefix: maxDefined(base?.minCollapsePrefix, caller?.minCollapsePrefix), + }; +} + +function tightenGptHistory( + base: Partial | undefined, + caller: Partial | undefined, +): Partial | undefined { + if (!base && !caller) return undefined; + return { + ...base, + ...caller, + keepTail: maxDefined(base?.keepTail, caller?.keepTail), + keepRecentPairs: maxDefined(base?.keepRecentPairs, caller?.keepRecentPairs), + minCollapsePrefix: maxDefined(base?.minCollapsePrefix, caller?.minCollapsePrefix), + minCollapseTokens: maxDefined(base?.minCollapseTokens, caller?.minCollapseTokens), + }; +} + +/** Merge caller overrides without allowing safe profiles to regain lossy lanes. */ +export function mergeCompressionProfileOptions( + profile: CompressionProfile, + overrides: TransformOptions = {}, +): TransformOptions { + const baseKeep = profile.transform.keepSharp; + const callerKeep = overrides.keepSharp; + const keepSharp = baseKeep && callerKeep + ? (block: KeepSharpBlock): boolean => { + let base = false; + let caller = false; + try { base = baseKeep(block) === true; } catch { /* retain caller result */ } + try { caller = callerKeep(block) === true; } catch { /* retain base result */ } + return base || caller; + } + : callerKeep ?? baseKeep; + const merged: TransformOptions = { + ...profile.transform, + ...overrides, + ...(keepSharp ? { keepSharp } : {}), + }; + + if (profile.name === 'passthrough') return { ...merged, compress: false }; + if (profile.name === 'aggressive') return merged; + + return { + ...merged, + compress: overrides.compress === false ? false : true, + compressSystem: false, + compressTools: false, + compressReminders: false, + compressToolResults: false, + minCompressChars: maxDefined( + profile.transform.minCompressChars, + overrides.minCompressChars, + ), + collapseHistory: overrides.collapseHistory === false ? false : true, + historyAmortizationHorizon: maxDefined( + profile.transform.historyAmortizationHorizon, + overrides.historyAmortizationHorizon, + ), + anthropicHistory: tightenAnthropicHistory( + profile.transform.anthropicHistory, + overrides.anthropicHistory, + ), + gptHistory: tightenGptHistory(profile.transform.gptHistory, overrides.gptHistory), + }; +} diff --git a/src/core/transform.ts b/src/core/transform.ts index 0656d00..cca5bf9 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -35,7 +35,11 @@ import { appendIdsBlock, factSheetText } from './factsheet.js'; import { guardImagedText } from './secret-guard.js'; import { stripSchemaDescriptions, schemaHasStructure } from './schema-strip.js'; import { bytesToBase64 } from './png.js'; -import { collapseHistory, HISTORY_SYNTHETIC_INTRO } from './history.js'; +import { + collapseHistory, + HISTORY_SYNTHETIC_INTRO, + type HistoryCollapseOptions, +} from './history.js'; import type { GptHistoryOptions } from './openai-history.js'; import { CACHE_CREATE_RATE, CACHE_READ_RATE } from './baseline.js'; import { renderTextToPngsCached } from './render-cache.js'; @@ -135,6 +139,11 @@ export interface TransformOptions { collapseHistory?: boolean; /** GPT only: history-collapse tuning overrides (keepTail / collapseChunk / …). */ gptHistory?: Partial; + /** Anthropic history policy. Only semantic boundary controls are exposed; + * renderer geometry and protected-prefix placement remain internal. */ + anthropicHistory?: Partial< + Pick + >; /** Re-pack image-bound text into a ↵-delimited stream to fill `cols` (~29%→75-80% * glyph-fill). ON by default (98.95% char accuracy at L1 OCR eval, +1pp vs baseline). * Hard newlines become visible ↵ glyphs — tell the model via system prompt. */ @@ -182,6 +191,7 @@ const DEFAULTS: Required = { // GPT-only knobs; the Anthropic transform ignores them but Required<> needs them. collapseHistory: true, gptHistory: {}, + anthropicHistory: {}, }; /** @@ -1584,6 +1594,7 @@ async function runHistoryCollapseAndFinalize( req.messages, historyProfitable, { + ...o.anthropicHistory, cols: o.cols, protectedPrefix: 0, reflow: o.reflow, preserveReminderText: o.compressSystem === false, denseCols: page.cols, denseCharsPerImage: page.charsPerImage, maxHeightPx: page.maxHeightPx, @@ -2354,6 +2365,7 @@ export async function transformRequest( req.messages, historyProfitable, { + ...o.anthropicHistory, cols: o.cols, protectedPrefix: slabAnchorIdx >= 0 ? slabAnchorIdx + 1 : 0, reflow: o.reflow, preserveReminderText: keepSystemText, denseCols: page.cols, denseCharsPerImage: page.charsPerImage, maxHeightPx: page.maxHeightPx, diff --git a/src/node.ts b/src/node.ts index 6795d9d..cd7380b 100644 --- a/src/node.ts +++ b/src/node.ts @@ -13,6 +13,11 @@ import * as os from 'node:os'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { spawnSync } from 'node:child_process'; import { createProxy, parseGatewayHeaders, resolveUpstreams, type ProxyConfig } from './core/proxy.js'; +import { + mergeCompressionProfileOptions, + resolveCompressionProfile, +} from './core/safety-policy.js'; +import type { TransformOptions } from './core/transform.js'; import { resolveOpenAIApiKey } from './node-auth.js'; import { parseExportArgv, @@ -68,6 +73,27 @@ export interface RuntimeConfig { captureErrorReqBody: boolean; } +export interface NodeTransformOptionsInput { + readonly profile?: string; + readonly keepSystemText: boolean; + readonly forcePassthrough: boolean; + readonly compressionEnabled: boolean; +} + +/** Resolve the named policy together with the two operational kill switches. */ +export function resolveNodeTransformOptions( + input: NodeTransformOptionsInput, +): TransformOptions { + if (input.forcePassthrough || !input.compressionEnabled) return { compress: false }; + const overrides: TransformOptions = input.keepSystemText + ? { compressSystem: false, compressTools: false, compressReminders: false } + : {}; + return mergeCompressionProfileOptions( + resolveCompressionProfile(input.profile), + overrides, + ); +} + const DEFAULT_CONFIG_FILE = path.join(os.homedir(), '.config', 'omniglyph', 'config.json'); function normalizeModelsConfig(value: unknown): string | undefined { @@ -907,6 +933,7 @@ async function main(): Promise { // rows) showed 5 mode flips ever and losses at 0.8% of wins — all // one-time cache-create amortization — so closing the loop would not // change decisions. Re-run that reconciliation before wiring one in. + const compressionProfile = resolveCompressionProfile(process.env.OMNIGLYPH_PROFILE); const tracker: Tracker = new FileTracker(opts.eventsFile); // Sidecar dir for oversized 4xx request-body samples. Lives next to the @@ -953,18 +980,14 @@ async function main(): Promise { // whole process, so the "normal" arm can be scripted on its own port while // still logging real usage + count_tokens baselines to its own OMNIGLYPH_LOG. // (The dashboard kill switch does the same thing at runtime.) - if (forcePassthrough || !dashboard.getCompressionEnabled()) return { compress: false }; - // OMNIGLYPH_KEEP_SYSTEM_TEXT: session config (system prompt, tool docs, - // init blocks) stays native text; only tool_results - // and collapsed history image. Guards against Anthropic's - // reasoning_extraction refusal classifier, which fires on system- - // prompt-shaped content rendered inside user-message images (2.6% of - // reminder-imaged requests vs 0% uncompressed, events.jsonl 2026-07-11). - if (/^(1|true|on|yes)$/i.test(process.env.OMNIGLYPH_KEEP_SYSTEM_TEXT ?? '')) { - return { compressSystem: false, compressTools: false, compressReminders: false }; - } - // Active path: use DEFAULTS in transform.ts for break-even gating. - return {}; + return resolveNodeTransformOptions({ + profile: compressionProfile.name, + keepSystemText: /^(1|true|on|yes)$/i.test( + process.env.OMNIGLYPH_KEEP_SYSTEM_TEXT ?? '', + ), + forcePassthrough, + compressionEnabled: dashboard.getCompressionEnabled(), + }); }, onRequest: async (e) => { // Feed the dashboard BEFORE tracker.emit — toTrackEvent strips @@ -1091,6 +1114,9 @@ async function main(): Promise { console.log(`[OmniGlyph] openai upstream → ${routes.openai}`); console.log(`[OmniGlyph] tracking events → ${opts.eventsFile}`); console.log(`[OmniGlyph] dashboard → http://127.0.0.1:${opts.port}/`); + if (compressionProfile.name !== 'aggressive') { + console.log(`[OmniGlyph] compression profile → ${compressionProfile.name}`); + } if (opts.captureErrorReqBody) { console.warn( `[OmniGlyph] OMNIGLYPH_DEBUG_CAPTURE_4XX=1 — persisting full 4xx request bodies ` + diff --git a/src/worker.ts b/src/worker.ts index 69f6658..dc29cee 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -12,6 +12,10 @@ */ import { createProxy, type ProxyConfig } from './core/proxy.js'; +import { + mergeCompressionProfileOptions, + resolveCompressionProfile, +} from './core/safety-policy.js'; import type { TransformOptions } from './core/transform.js'; import { toTrackEvent, JsonLogTracker, noopTracker, type Tracker } from './core/tracker.js'; import { setRenderCacheMaxBytes } from './core/render-cache.js'; @@ -25,6 +29,8 @@ export interface Env { OPENAI_UPSTREAM?: string; /** Optional override — if set, replaces whatever Authorization the client sent. */ OPENAI_API_KEY?: string; + /** Semantic transform boundary; aggressive preserves the legacy default. */ + OMNIGLYPH_PROFILE?: string; COMPRESS?: string; COMPRESS_TOOLS?: string; COMPRESS_REMINDERS?: string; @@ -69,6 +75,24 @@ async function secretsMatch(a: string, b: string): Promise { const truthy = (v: string | undefined, fallback: boolean): boolean => v == null ? fallback : v === '1' || v.toLowerCase() === 'true'; +export function resolveWorkerTransformOptions(env: Env): TransformOptions { + const overrides: TransformOptions = { + compress: truthy(env.COMPRESS, true), + compressTools: truthy(env.COMPRESS_TOOLS, true), + compressReminders: truthy(env.COMPRESS_REMINDERS, true), + compressToolResults: truthy(env.COMPRESS_TOOL_RESULTS, true), + minCompressChars: env.MIN_COMPRESS_CHARS ? Number(env.MIN_COMPRESS_CHARS) : 2000, + minReminderChars: env.MIN_REMINDER_CHARS ? Number(env.MIN_REMINDER_CHARS) : 0, + minToolResultChars: env.MIN_TOOL_RESULT_CHARS ? Number(env.MIN_TOOL_RESULT_CHARS) : 0, + cols: env.COLS ? Number(env.COLS) : 100, + multiCol: env.MULTI_COL ? Math.max(1, Number(env.MULTI_COL) | 0) : 2, + }; + return mergeCompressionProfileOptions( + resolveCompressionProfile(env.OMNIGLYPH_PROFILE), + overrides, + ); +} + const nonNegativeInt = (value: string | undefined): number | undefined => { const raw = value?.trim(); if (!raw) return undefined; @@ -108,24 +132,7 @@ export default { req.headers.delete('x-omniglyph-secret'); } - const transform: TransformOptions = { - compress: truthy(env.COMPRESS, true), - compressTools: truthy(env.COMPRESS_TOOLS, true), - compressReminders: truthy(env.COMPRESS_REMINDERS, true), - compressToolResults: truthy(env.COMPRESS_TOOL_RESULTS, true), - minCompressChars: env.MIN_COMPRESS_CHARS ? Number(env.MIN_COMPRESS_CHARS) : 2000, - // 500 chars — CPU/latency floor only, not a correctness guard. The - // No floors — the content-aware `isCompressionProfitable()` gate - // decides per-block based on actual pixel cost vs text cost. Host - // can still set a floor via env if they want observability buckets - // (e.g. MIN_TOOL_RESULT_CHARS=200 to skip absurdly small dumps). - minReminderChars: env.MIN_REMINDER_CHARS ? Number(env.MIN_REMINDER_CHARS) : 0, - minToolResultChars: env.MIN_TOOL_RESULT_CHARS ? Number(env.MIN_TOOL_RESULT_CHARS) : 0, - cols: env.COLS ? Number(env.COLS) : 100, - // R2 multi-column ON (2 cols) — single-col drops below break-even on - // real tool-doc slabs. Override via MULTI_COL=1 if OCR misreads layout. - multiCol: env.MULTI_COL ? Math.max(1, Number(env.MULTI_COL) | 0) : 2, - }; + const transform = resolveWorkerTransformOptions(env); const trackingOn = truthy(env.OMNIGLYPH_TRACK, true); // Workers Logs ingests stdout as separate log lines. Emit one JSON line // per event so downstream (Logpush → R2/S3) reads the same JSONL shape diff --git a/tests/safety-profile.test.ts b/tests/safety-profile.test.ts new file mode 100644 index 0000000..a2b3ca8 --- /dev/null +++ b/tests/safety-profile.test.ts @@ -0,0 +1,286 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + isOmniGlyphSupportedModel, + isOmniGlyphSupportedModelForScope, + mergeCompressionProfileOptions, + resolveCompressionProfile, + shouldKeepToolResultSharp, + transformAnthropicMessages, + transformOpenAIChatCompletions, +} from '../src/core/index.js'; +import { resolveNodeTransformOptions } from '../src/node.js'; +import { resolveWorkerTransformOptions } from '../src/worker.js'; + +const enc = new TextEncoder(); +const dec = new TextDecoder(); +const previousModels = process.env.OMNIGLYPH_MODELS; +const previousProfile = process.env.OMNIGLYPH_PROFILE; + +afterEach(() => { + if (previousModels === undefined) delete process.env.OMNIGLYPH_MODELS; + else process.env.OMNIGLYPH_MODELS = previousModels; + if (previousProfile === undefined) delete process.env.OMNIGLYPH_PROFILE; + else process.env.OMNIGLYPH_PROFILE = previousProfile; +}); + +describe('compression safety profiles', () => { + it('keeps the legacy transform policy when no profile is selected', () => { + const profile = resolveCompressionProfile(); + expect(profile.name).toBe('aggressive'); + expect(mergeCompressionProfileOptions(profile)).toEqual({}); + }); + + it('keeps authority, schemas and live tool state native in coding-safe mode', () => { + const options = mergeCompressionProfileOptions(resolveCompressionProfile('coding-safe')); + expect(options).toMatchObject({ + compress: true, + compressSystem: false, + compressTools: false, + compressReminders: false, + compressToolResults: false, + minCompressChars: Number.MAX_SAFE_INTEGER, + collapseHistory: true, + historyAmortizationHorizon: 4, + anthropicHistory: { keepTail: 12, minCollapsePrefix: 16 }, + gptHistory: { + keepTail: 12, + keepRecentPairs: 12, + minCollapsePrefix: 16, + minCollapseTokens: 4_000, + }, + }); + }); + + it('allows stricter caller settings but cannot weaken a safe boundary', () => { + const options = mergeCompressionProfileOptions( + resolveCompressionProfile('balanced'), + { + compressSystem: true, + compressTools: true, + compressReminders: true, + compressToolResults: true, + historyAmortizationHorizon: 6, + anthropicHistory: { keepTail: 2, minCollapsePrefix: 2 }, + gptHistory: { + keepTail: 2, + keepRecentPairs: 2, + minCollapsePrefix: 2, + minCollapseTokens: 2, + }, + }, + ); + expect(options.compressSystem).toBe(false); + expect(options.compressTools).toBe(false); + expect(options.compressReminders).toBe(false); + expect(options.compressToolResults).toBe(false); + expect(options.historyAmortizationHorizon).toBe(6); + expect(options.anthropicHistory).toMatchObject({ keepTail: 8, minCollapsePrefix: 12 }); + expect(options.gptHistory).toMatchObject({ + keepTail: 8, + keepRecentPairs: 8, + minCollapsePrefix: 12, + minCollapseTokens: 3_000, + }); + }); + + it('cannot turn passthrough back into a transform', () => { + const options = mergeCompressionProfileOptions( + resolveCompressionProfile('passthrough'), + { compress: true, compressToolResults: true }, + ); + expect(options.compress).toBe(false); + }); + + it('rejects unknown profile names instead of silently broadening policy', () => { + expect(() => resolveCompressionProfile('turbo')).toThrow( + "invalid OMNIGLYPH_PROFILE 'turbo'", + ); + }); + + it('recognizes exact coding-state shapes conservatively', () => { + expect(shouldKeepToolResultSharp({ + kind: 'tool_result', + text: 'diff --git a/src/a.ts b/src/a.ts\n@@ -1 +1 @@', + })).toBe(true); + expect(shouldKeepToolResultSharp({ + kind: 'tool_result', + text: '{"commit":"a1b2c3d4","ok":true}', + })).toBe(true); + expect(shouldKeepToolResultSharp({ + kind: 'tool_result', + text: 'ordinary prose without machine state', + })).toBe(false); + }); + + it('safe scopes cannot promote models outside the measured default set', () => { + process.env.OMNIGLYPH_MODELS = + 'claude-fable-5,gpt-5.6,claude-opus-4-8,grok-4.5'; + expect(isOmniGlyphSupportedModelForScope('claude-fable-5', 'coding-safe')).toBe(true); + expect(isOmniGlyphSupportedModelForScope('gpt-5.6', 'balanced')).toBe(true); + expect(isOmniGlyphSupportedModelForScope('claude-opus-4-8', 'coding-safe')).toBe(false); + expect(isOmniGlyphSupportedModelForScope('grok-4.5', 'balanced')).toBe(false); + expect(isOmniGlyphSupportedModelForScope('claude-opus-4-8', 'aggressive')).toBe(true); + expect(isOmniGlyphSupportedModelForScope('claude-fable-5', 'passthrough')).toBe(false); + }); + + it('applies the environment profile to the production model gate', () => { + process.env.OMNIGLYPH_MODELS = 'claude-fable-5,grok-4.5'; + process.env.OMNIGLYPH_PROFILE = 'coding-safe'; + expect(isOmniGlyphSupportedModel('claude-fable-5')).toBe(true); + expect(isOmniGlyphSupportedModel('grok-4.5')).toBe(false); + + process.env.OMNIGLYPH_PROFILE = 'aggressive'; + expect(isOmniGlyphSupportedModel('grok-4.5')).toBe(true); + }); + + it('composes the Node profile with legacy and runtime kill switches', () => { + expect(resolveNodeTransformOptions({ + profile: 'coding-safe', + keepSystemText: false, + forcePassthrough: false, + compressionEnabled: true, + })).toMatchObject({ + compressSystem: false, + compressToolResults: false, + minCompressChars: Number.MAX_SAFE_INTEGER, + anthropicHistory: { keepTail: 12 }, + }); + expect(resolveNodeTransformOptions({ + profile: 'aggressive', + keepSystemText: true, + forcePassthrough: false, + compressionEnabled: true, + })).toEqual({ + compressSystem: false, + compressTools: false, + compressReminders: false, + }); + expect(resolveNodeTransformOptions({ + profile: 'coding-safe', + keepSystemText: false, + forcePassthrough: true, + compressionEnabled: true, + })).toEqual({ compress: false }); + }); + + it('makes the Worker profile authoritative over permissive bindings', () => { + const options = resolveWorkerTransformOptions({ + OMNIGLYPH_PROFILE: 'coding-safe', + COMPRESS: 'true', + COMPRESS_TOOLS: 'true', + COMPRESS_REMINDERS: 'true', + COMPRESS_TOOL_RESULTS: 'true', + MIN_COMPRESS_CHARS: '1', + }); + expect(options).toMatchObject({ + compress: true, + compressSystem: false, + compressTools: false, + compressReminders: false, + compressToolResults: false, + minCompressChars: Number.MAX_SAFE_INTEGER, + anthropicHistory: { keepTail: 12 }, + }); + }); + + it('keeps OpenAI system authority and tool schemas native', async () => { + const system = 'Exact developer authority. '.repeat(3_000); + const tool = { + type: 'function', + function: { + name: 'edit_file', + description: 'Exact tool contract. '.repeat(500), + parameters: { + type: 'object', + properties: { path: { type: 'string' } }, + required: ['path'], + }, + }, + }; + const body = enc.encode(JSON.stringify({ + model: 'gpt-5.6', + messages: [ + { role: 'system', content: system }, + { role: 'user', content: 'Keep this request native.' }, + ], + tools: [tool], + })); + const options = mergeCompressionProfileOptions( + resolveCompressionProfile('coding-safe'), + { charsPerToken: 1, minCompressChars: 1 }, + ); + const result = await transformOpenAIChatCompletions(body, options); + const output = JSON.parse(dec.decode(result.body)) as { + messages: Array<{ role: string; content: unknown }>; + tools: unknown[]; + }; + + expect(result.info.compressed).toBe(false); + expect(output.messages[0]).toEqual({ role: 'system', content: system }); + expect(output.tools).toEqual([tool]); + }); + + it('keeps native authority and live state while collapsing old Anthropic history', async () => { + const system = 'Exact operating authority. '.repeat(500); + const tool = { + name: 'edit_file', + description: 'Exact editing contract. '.repeat(300), + input_schema: { + type: 'object', + properties: { path: { type: 'string' }, content: { type: 'string' } }, + required: ['path', 'content'], + }, + }; + const messages: Array<{ + role: 'user' | 'assistant'; + content: unknown; + }> = Array.from({ length: 34 }, (_, index) => ({ + role: index % 2 === 0 ? 'user' : 'assistant', + content: `closed history ${index} ${'context '.repeat(700)}`, + })); + messages.push( + { + role: 'assistant', + content: [ + { type: 'tool_use', id: 'toolu_live', name: 'edit_file', input: { path: 'src/a.ts' } }, + ], + }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'toolu_live', + content: `src/a.ts:42 exact live result ${'x'.repeat(12_000)}`, + }, + { type: 'text', text: 'Continue from the exact live result.' }, + ], + }, + ); + + const result = await transformAnthropicMessages({ + model: 'claude-fable-5', + options: { profile: 'coding-safe', charsPerToken: 1 }, + body: enc.encode(JSON.stringify({ + model: 'claude-fable-5', + max_tokens: 128, + system, + tools: [tool], + messages, + })), + }); + const output = JSON.parse(dec.decode(result.body)) as { + system: string; + tools: unknown[]; + messages: unknown[]; + }; + + expect(result.applied).toBe(true); + expect(result.info.collapsedTurns).toBeGreaterThan(0); + expect(output.system).toBe(system); + expect(output.tools).toEqual([tool]); + expect(JSON.stringify(output.messages)).toContain('src/a.ts:42 exact live result'); + expect(JSON.stringify(output.messages)).toContain('Continue from the exact live result.'); + }); +});