diff --git a/CHANGELOG.md b/CHANGELOG.md index b089dbd..1e27d64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · semantic ver ## [Unreleased] +### Added + +- **feat(reliability):** named compression profiles for callers and both proxy + hosts. `OMNIGLYPH_PROFILE=coding-safe` keeps system authority, tool schemas, + reminders and live tool results as native text while allowing old closed + history to collapse; `balanced`, `aggressive` (the unchanged default) and + `passthrough` complete the policy set. Safe-profile boundaries cannot be + weakened by per-call or Worker overrides, and their model scope can only + narrow the measured default allowlist. (thanks @alteixeira20) + ### Fixed - **fix(render):** glyph surgery so the Spleen 5×8 `K` no longer reads as `H`. diff --git a/README.md b/README.md index 73d2463..ce19160 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. Safe profiles keep authority, schemas, reminders, and live tool results native while collapsing only eligible old history; `aggressive` preserves the existing default and `passthrough` disables transforms. - `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..2d6e2fa 100644 --- a/src/core/applicability.ts +++ b/src/core/applicability.ts @@ -1,5 +1,10 @@ /** Applicability helpers for OmniGlyph's production-safe model scope. */ +import { + resolveCompressionProfile, + type CompressionProfileName, +} from './safety-policy.js'; + export type OmniGlyphApplicabilityReason = | 'eligible' | 'unsupported_model' @@ -45,7 +50,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 +71,38 @@ 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 matchesModelBase(model: string, candidate: string): boolean { + return model === candidate || model.startsWith(`${candidate}-`); +} + +function allowedModelBasesForScope(scope: CompressionProfileName): string[] { + if (scope === 'passthrough') return []; + const configured = configuredModelBases(); + if (scope === 'aggressive') return configured; + return configured.filter((candidate) => { + const base = baseModelId(candidate); + return DEFAULT_MODEL_BASES.some((verified) => matchesModelBase(base, verified)); + }); +} + +function activeCompressionScope(): CompressionProfileName { + const raw = typeof process !== 'undefined' ? process.env?.OMNIGLYPH_PROFILE : undefined; + try { + return resolveCompressionProfile(raw).name; + } catch { + return 'passthrough'; + } +} + +function allowedModelBases(): string[] { + return allowedModelBasesForScope(activeCompressionScope()); +} + /** Current effective allowed-model scope (Claude + GPT). */ export function getAllowedModelBases(): string[] { return allowedModelBases(); @@ -89,20 +121,33 @@ 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: CompressionProfileName, +): boolean { if (typeof model !== 'string') return false; const base = baseModelId(model); - return allowedModelBases().some((b) => base === b || base.startsWith(`${b}-`)); + return allowedModelBasesForScope(scope).some((candidate) => matchesModelBase(base, candidate)); +} + +/** Pure model gate for a caller-selected compression profile. Safe profiles + * may narrow configured scope but never promote a model beyond the measured + * built-in defaults. */ +export function isOmniGlyphSupportedModelForScope( + model: string | null | undefined, + scope: CompressionProfileName, +): 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, activeCompressionScope()); } /** 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, activeCompressionScope()); } /** Canonical set of Anthropic Messages routes OmniGlyph transforms. Shared with diff --git a/src/core/index.ts b/src/core/index.ts index 93fdb4e..c696838 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,13 +1,21 @@ export { + DEFAULT_MODEL_BASES, getAllowedModelBases, getConfiguredModelBases, isOmniGlyphSupportedGptModel, isOmniGlyphSupportedModel, + isOmniGlyphSupportedModelForScope, setAllowedModelBases, shouldTransformAnthropicMessages, type OmniGlyphApplicabilityInput, type OmniGlyphApplicabilityReason, } from './applicability.js'; +export { + mergeCompressionProfileOptions, + resolveCompressionProfile, + type CompressionProfile, + type CompressionProfileName, +} from './safety-policy.js'; export { buildCountTokensBodies, buildBaselineCountTokensBody, diff --git a/src/core/library.ts b/src/core/library.ts index e9be356..f4c879f 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; + /** Semantic compression policy. Unset preserves the existing behavior. */ + readonly profile?: CompressionProfileName; } export interface OmniGlyphTransformInput { @@ -106,19 +113,23 @@ 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..615b191 --- /dev/null +++ b/src/core/safety-policy.ts @@ -0,0 +1,116 @@ +import type { TransformOptions } from './transform.js'; + +export type CompressionProfileName = + | 'coding-safe' + | 'balanced' + | 'aggressive' + | 'passthrough'; + +export interface CompressionProfile { + readonly name: CompressionProfileName; + readonly description: string; + readonly transform: Readonly; +} + +const CODING_SAFE: CompressionProfile = { + name: 'coding-safe', + description: 'keep authority and live request state native; collapse only old history', + transform: { + compress: true, + compressSystem: false, + compressTools: false, + compressReminders: false, + compressToolResults: false, + collapseHistory: true, + historyAmortizationHorizon: 4, + gptHistory: { + keepTail: 12, + keepRecentPairs: 12, + minCollapsePrefix: 16, + minCollapseTokens: 4_000, + }, + }, +}; + +const BALANCED: CompressionProfile = { + name: 'balanced', + description: 'keep authority and live request state native with a shorter protected history tail', + transform: { + compress: true, + compressSystem: false, + compressTools: false, + compressReminders: false, + compressToolResults: false, + collapseHistory: true, + historyAmortizationHorizon: 3, + gptHistory: { + keepTail: 8, + keepRecentPairs: 8, + minCollapsePrefix: 12, + minCollapseTokens: 3_000, + }, + }, +}; + +const AGGRESSIVE: CompressionProfile = { + name: 'aggressive', + description: 'existing transform policy', + transform: {}, +}; + +const PASSTHROUGH: CompressionProfile = { + 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 AGGRESSIVE; + if (value === 'safe' || value === 'coding' || value === 'coding-safe') return CODING_SAFE; + if (value === 'balanced') return BALANCED; + if (value === 'off' || value === 'disabled' || value === 'passthrough') return PASSTHROUGH; + throw new Error( + `invalid OMNIGLYPH_PROFILE '${raw}'; expected coding-safe, balanced, aggressive or passthrough`, + ); +} + +export function mergeCompressionProfileOptions( + profile: CompressionProfile, + overrides: TransformOptions = {}, +): TransformOptions { + if (profile.name === 'passthrough') return { ...overrides, compress: false }; + if (profile.name === 'aggressive') return { ...overrides }; + + const baseHistory = profile.transform.gptHistory; + const callerHistory = overrides.gptHistory; + const floor = (base: number | undefined, value: number | undefined): number | undefined => { + if (base === undefined) return value; + if (value === undefined) return base; + return Math.max(base, value); + }; + + return { + ...profile.transform, + ...overrides, + compressSystem: false, + compressTools: false, + compressReminders: false, + compressToolResults: false, + collapseHistory: overrides.collapseHistory === false + ? false + : profile.transform.collapseHistory, + historyAmortizationHorizon: floor( + profile.transform.historyAmortizationHorizon, + overrides.historyAmortizationHorizon, + ), + gptHistory: { + ...baseHistory, + ...callerHistory, + keepTail: floor(baseHistory?.keepTail, callerHistory?.keepTail), + keepRecentPairs: floor(baseHistory?.keepRecentPairs, callerHistory?.keepRecentPairs), + minCollapsePrefix: floor(baseHistory?.minCollapsePrefix, callerHistory?.minCollapsePrefix), + minCollapseTokens: floor(baseHistory?.minCollapseTokens, callerHistory?.minCollapseTokens), + }, + }; +} diff --git a/src/node.ts b/src/node.ts index 6795d9d..18a8eed 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; +} + +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 { @@ -893,20 +919,11 @@ async function main(): Promise { imageDumpDir = undefined; } } - // Transform options pass through empty — the proxy uses the DEFAULTS - // baked into transform.ts. There are no behavior toggles: system slab, - // reminders, tool_results, and history compression all run - // unconditionally; the per-block break-even gate decides per-call - // whether to actually image each piece. The function-form `transform` - // below is ONLY a kill switch (OMNIGLYPH_DISABLE / dashboard toggle → - // compress:false); on the active path it returns {}, so the gate always - // runs on static DEFAULTS — charsPerToken=4, priorWarm*=0 — which leaves - // the warm-baseline and anti-flapping burn terms inert. That is - // deliberate, NOT an oversight: there is no live-α feedback loop from - // the dashboard. Telemetry (2026-06, 897 sessions / 21,347 measured - // 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. + // The aggressive/default profile passes through empty options and therefore + // uses transform.ts defaults. Named safe profiles tighten which request + // regions may be rendered, while the per-block break-even gate remains the + // final decision for every eligible region. There is intentionally no live + // dashboard feedback loop into the profitability inputs. const tracker: Tracker = new FileTracker(opts.eventsFile); // Sidecar dir for oversized 4xx request-body samples. Lives next to the @@ -942,29 +959,16 @@ async function main(): Promise { openAIUpstream: opts.openAIUpstream, openAIApiKey: opts.openAIApiKey, captureErrorReqBody: opts.captureErrorReqBody, - // Per-request transform options: - // 1. Runtime kill switch — when the dashboard "passthrough" toggle - // is off, force compress=false so /v1/messages forwards - // untransformed. Lets the operator instantly disable the proxy - // when upstream is unhealthy without restarting. - // 2. Otherwise use DEFAULTS in transform.ts for break-even gating. + // Per-request options combine the named policy with live kill switches. transform: () => { - // A/B harness: OMNIGLYPH_DISABLE=1 forces passthrough (compress=false) for the - // 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: process.env.OMNIGLYPH_PROFILE, + 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 diff --git a/src/worker.ts b/src/worker.ts index 4551a81..44d0572 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'; @@ -24,6 +28,7 @@ export interface Env { OPENAI_UPSTREAM?: string; /** Optional override — if set, replaces whatever Authorization the client sent. */ OPENAI_API_KEY?: string; + OMNIGLYPH_PROFILE?: string; COMPRESS?: string; COMPRESS_TOOLS?: string; COMPRESS_REMINDERS?: string; @@ -65,6 +70,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, + ); +} + export default { async fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise { // ── Caller auth ──────────────────────────────────────────────────── @@ -95,24 +118,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/public-api.test.ts b/tests/public-api.test.ts index a63b88b..4121b49 100644 --- a/tests/public-api.test.ts +++ b/tests/public-api.test.ts @@ -4,6 +4,7 @@ import { getAllowedModelBases, isOmniGlyphSupportedGptModel, isOmniGlyphSupportedModel, + resolveCompressionProfile, setAllowedModelBases, shouldTransformAnthropicMessages, transformAnthropicMessages, @@ -29,6 +30,10 @@ afterEach(() => { }); describe('public library API', () => { + it('exports compression profiles from the package root', () => { + expect(resolveCompressionProfile('coding-safe').name).toBe('coding-safe'); + }); + it('recognizes Fable 5 (with suffix aliases) as the default scope; Opus is OFF by default', () => { expect(isOmniGlyphSupportedModel('claude-fable-5')).toBe(true); expect(isOmniGlyphSupportedModel('claude-fable-5-high')).toBe(true); @@ -299,6 +304,51 @@ describe('public library API', () => { expect(transformed.cache.markerCount).toBe(0); }); + it('keeps authority, tool schemas, and live tool output native in coding-safe mode', async () => { + const input = { + model: 'claude-fable-5', + system: 'Security and operating instructions. '.repeat(1_200), + tools: [{ + name: 'read_file', + description: 'Read a file from disk. '.repeat(250), + input_schema: { + type: 'object', + properties: { path: { type: 'string' } }, + required: ['path'], + }, + }], + messages: [ + { role: 'user', content: 'Inspect the repository.' }, + { + role: 'assistant', + content: [{ type: 'tool_use', id: 'call_1', name: 'read_file', input: { path: 'src/index.ts' } }], + }, + { + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: 'call_1', + content: 'exact live source line\n'.repeat(1_000), + }], + }, + { role: 'user', content: 'Continue using the exact result.' }, + ], + }; + + const transformed = await transformAnthropicMessages({ + body: enc.encode(JSON.stringify(input)), + model: input.model, + options: { profile: 'coding-safe' }, + }); + const output = JSON.parse(dec.decode(transformed.body)) as typeof input; + + expect(transformed.applied).toBe(false); + expect(transformed.info.imageCount).toBe(0); + expect(output.system).toEqual(input.system); + expect(output.tools).toEqual(input.tools); + expect(output.messages).toEqual(input.messages); + }); + it('preserves the exact Claude Code OAuth identity as the first system block', async () => { // Subscription OAuth requests are classified as Claude Code traffic only // when this exact identity stays a separate top-level system TEXT block. diff --git a/tests/safety-profile.test.ts b/tests/safety-profile.test.ts new file mode 100644 index 0000000..9a39af7 --- /dev/null +++ b/tests/safety-profile.test.ts @@ -0,0 +1,164 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + isOmniGlyphSupportedModel, + isOmniGlyphSupportedModelForScope, + setAllowedModelBases, +} from '../src/core/applicability.js'; +import { + mergeCompressionProfileOptions, + resolveCompressionProfile, +} from '../src/core/safety-policy.js'; +import { resolveNodeTransformOptions } from '../src/node.js'; +import { resolveWorkerTransformOptions } from '../src/worker.js'; + +const previousModels = process.env.OMNIGLYPH_MODELS; +const previousProfile = process.env.OMNIGLYPH_PROFILE; + +afterEach(() => { + setAllowedModelBases(null); + 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('coding-safe compression profile', () => { + it('keeps authority and live request state native', () => { + const options = mergeCompressionProfileOptions( + resolveCompressionProfile('coding-safe'), + ); + + expect(options).toMatchObject({ + compress: true, + compressSystem: false, + compressTools: false, + compressReminders: false, + compressToolResults: false, + collapseHistory: true, + historyAmortizationHorizon: 4, + }); + expect(options.gptHistory?.keepTail).toBe(12); + }); + + it('does not let caller overrides weaken the native-state boundary', () => { + const options = mergeCompressionProfileOptions( + resolveCompressionProfile('coding-safe'), + { + compressSystem: true, + compressTools: true, + compressReminders: true, + compressToolResults: true, + historyAmortizationHorizon: 1, + gptHistory: { + keepTail: 0, + keepRecentPairs: 0, + minCollapsePrefix: 1, + minCollapseTokens: 1, + }, + }, + ); + + expect(options.compressSystem).toBe(false); + expect(options.compressTools).toBe(false); + expect(options.compressReminders).toBe(false); + expect(options.compressToolResults).toBe(false); + expect(options.historyAmortizationHorizon).toBe(4); + expect(options.gptHistory).toMatchObject({ + keepTail: 12, + keepRecentPairs: 12, + minCollapsePrefix: 16, + minCollapseTokens: 4_000, + }); + }); + + it('keeps the default unchanged and supports balanced or passthrough policy', () => { + const aggressive = resolveCompressionProfile(undefined); + expect(aggressive.name).toBe('aggressive'); + expect(mergeCompressionProfileOptions(aggressive)).toEqual({}); + + const balanced = mergeCompressionProfileOptions( + resolveCompressionProfile('balanced'), + ); + expect(balanced.compressSystem).toBe(false); + expect(balanced.gptHistory?.keepTail).toBe(8); + + const passthrough = mergeCompressionProfileOptions( + resolveCompressionProfile('passthrough'), + { compress: true }, + ); + expect(passthrough.compress).toBe(false); + }); + + it('does not let safe scopes promote configured experimental models', () => { + process.env.OMNIGLYPH_MODELS = 'claude-fable-5,gpt-5.6,grok-4.5'; + + expect(isOmniGlyphSupportedModelForScope('claude-fable-5', 'coding-safe')).toBe(true); + expect(isOmniGlyphSupportedModelForScope('gpt-5.6', 'balanced')).toBe(true); + expect(isOmniGlyphSupportedModelForScope('grok-4.5', 'coding-safe')).toBe(false); + expect(isOmniGlyphSupportedModelForScope('grok-4.5', '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, + compressTools: false, + compressReminders: false, + compressToolResults: false, + collapseHistory: true, + }); + + 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, + }).compress).toBe(false); + }); + + it('makes the Worker profile authoritative over permissive transform flags', () => { + const options = resolveWorkerTransformOptions({ + OMNIGLYPH_PROFILE: 'coding-safe', + COMPRESS: 'true', + COMPRESS_TOOLS: 'true', + COMPRESS_REMINDERS: 'true', + COMPRESS_TOOL_RESULTS: 'true', + }); + + expect(options).toMatchObject({ + compress: true, + compressSystem: false, + compressTools: false, + compressReminders: false, + compressToolResults: false, + collapseHistory: true, + }); + }); +});