From 99c6dc7fd69523e9f2da974826fcd31a8a351163 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Mon, 4 May 2026 01:36:53 -0300 Subject: [PATCH] Release v3.7.9 (#1917) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(release): v3.7.9 — gemini-cli cloud code separation * chore(provider): Update Jina AI model catalog (#1874) Integrated into release/v3.7.9 * docs: update CHANGELOG for PR 1874 and retroactive credits * fix: resolve stream defaults and codex prompt mapping (#1873, #1872) * chore(compression): start caveman compression update * feat(compression): expand caveman compression and analytics pipeline Add caveman intensity levels, output mode instructions, validation, and preview diffs across the compression pipeline. Extend MCP and dashboard settings to support auto-trigger mode, system prompt preservation, MCP description compression, and caveman rule metadata. Record richer compression analytics with receipt fields, validation fallbacks, output mode data, and add the related database migration. Improve preservation handling for code, URLs, markdown, math, and other protected content while adding broad unit, integration, and golden-set coverage for caveman parity and compression behavior. * feat(compression): expose rule intensities and track usd savings Add estimated USD savings to compression analytics so saved tokens can be reported in cost terms alongside existing token metrics. Expose caveman rule intensity metadata for settings consumers and add a settings API route alias for rule lookup. Also preserve system prompts when aggressive compression falls back to lite mode. * feat(compression): RTK compression roadmap (#1889) * chore(rtk): initialize compression roadmap branch * feat(compression): add RTK engine and compression combos Introduce RTK command-aware tool-output compression alongside stacked RTK -> Caveman pipelines for mixed prompt contexts. Add engine registration, declarative RTK filter packs, language-aware Caveman rule loading, compression combo persistence and assignments, analytics grouped by engine/combo, and new MCP/API endpoints for configuration, previews, filters, and combo management. Expose the new capabilities in the dashboard with dedicated Context & Cache pages for Caveman, RTK, and compression combos, and update docs, i18n strings, migrations, and tests to cover the expanded compression surface. * feat(compression): expand RTK DSL, filter catalog, and recovery APIs Add RTK parity features across the compression pipeline, dashboard, and management APIs. This expands the built-in filter catalog, adds trust-gated custom filter loading, inline filter verification, code stripping, smarter detection, and optional redacted raw-output retention for authenticated recovery. Also extend Caveman with file-based multilingual rule packs, localized output-mode instructions, stricter preview/config schemas, engine registry metadata, analytics fields, and broad unit test coverage for RTK, rule loading, and stacked compression behavior. * fix(auth): protect oauth routes and health reset operations Require authenticated dashboard access for OAuth endpoints that can create or import provider connections when login enforcement is enabled. Move `/api/monitoring/health` to the readonly public route list so safe methods remain public while DELETE now returns 401 for anonymous requests. Also update Next.js native `.node` handling to avoid webpack parse failures from external packages such as ngrok and keytar, and add coverage for the new auth behavior. * build(compression): ship RTK rule and filter assets with app bundles Include compression JSON assets in Next output tracing, prepublish copies, and pack artifact policy checks so standalone and packaged builds can load RTK filters and caveman rule packs at runtime. Also harden compression runtime behavior by resolving alternate asset directories, scoping rule cache entries by source path, carrying RTK raw output pointers through stacked runs, degrading oversized preview diffs, and applying combo language/output mode defaults during chat routing. Add coverage for packaging rules, provider-scoped model parsing, smart truncate edge cases, raw output retention, and combo-driven compression behavior. * docs(workflows): update local repo paths to OmniRoute Replace outdated `/home/diegosouzapw/dev/proxys/9router` references with the current `OmniRoute` directory across deploy, release, and version bump workflow guides so local command examples match the renamed repository layout * feat(compression): complete RTK parity coverage * test(build): align next config assertions --------- Co-authored-by: diegosouzapw * feat(compression): expand caveman parity and MCP metadata compression Compress MCP registry and list metadata descriptions for tools, prompts, resources, and resource templates while keeping tool-response bodies unchanged. Expose those savings in compression status as `mcp_metadata_estimate` metadata rather than provider usage. Add Caveman rule-pack support for custom regex flags and match-specific replacement maps, update English rules for upstream parity, and tighten article and pleasantry handling. Also process RTK multipart text blocks independently so mixed media content compresses safely without duplicating output. * feat(compression): unify config validation and persist MCP savings Centralize compression config schemas across settings, preview, RTK, and combo APIs to enforce consistent validation for stacked pipelines and engine-specific options. Expand caveman and stacked compression behavior by applying default combos at runtime, surfacing validation and fallback metadata, and exposing aggressive and ultra adapter schemas for configuration UI and tests. Persist MCP description compression snapshots into analytics without counting them as provider usage, and extend the dashboard with the new RTK controls and localized labels. * fix(auth): require dashboard management auth for compression preview Block preview requests unless they come from a valid management session token so protected settings cannot be probed through the preview API. Add unit coverage for unauthenticated requests, invalid bearer tokens, and successful authenticated preview execution. * fix(compression): preserve stacked defaults and secure metadata routes Only apply saved default compression combos when they contain a stacked pipeline so seeded Caveman-only defaults do not replace the builtin stacked behavior. Also require management auth for compression language pack and rules metadata endpoints, and defer usage receipt attachment until compression analytics writes have completed to keep analytics records consistent. * fix(compression): align seeded standard savings combo with stacked default Update the seeded default compression combo to use the RTK then Caveman pipeline in both fresh installs and upgraded databases. Add a targeted migration and runtime guard that only rewrites the legacy seeded record when its original metadata and single-step pipeline still match, preserving user-customized default combos. Refresh docs and tests to reflect the stacked default and expanded RTK filter catalog. * docs(compression): document RTK+Caveman stacked savings ranges Refresh the compression docs and README to describe the default stacked pipeline in terms of eligible-context savings instead of the older generic token-saving range. Add upstream RTK and Caveman benchmark references, explain the multiplicative savings math behind the stacked default, and update feature summaries plus package metadata to match the revised positioning. * feat(image-gen): add NanoGPT image generation provider (#1899) Integrated into release/v3.7.9 * fix(codex): sanitize raw responses input (#1895) Integrated into release/v3.7.9 * Fix combo provider breaker profile handling (#1891) Integrated into release/v3.7.9 * fix(combos): align strategy contracts (#1892) Integrated into release/v3.7.9 * feat(proxy): move proxy configuration to dedicated System → Proxy page (#1907) Integrated into release/v3.7.9 * feat: add K/M/B/T cost shortener to prevent UI overflow (#1902) Integrated into release/v3.7.9 * feat(providers): implement bulk paste for extra API keys (#1916) Integrated into release/v3.7.9 * fix(migrations): treat duplicate-column ALTER as no-op (#1886) Integrated into release/v3.7.9 * fix(analytics): robust model pricing resolution, dark mode charts and SQL aggregation fixes (#1896) Integrated into release/v3.7.9 (migration renumbered to 044) * fix(oauth): per-connection mutex for rotating refresh tokens (#1885) Integrated into release/v3.7.9 * fix: resolve 3 bugs — Codex tool normalization (#1914), image gen proxy (#1904), zero-arg MCP tools (#1898) - fix(codex): flatten Chat Completions tool format to Responses format in normalizeCodexTools. Prevents 'Missing required parameter: tools[0].name' upstream errors when clients send {type:'function', function:{name,...}} instead of {type:'function', name,...}. - fix(proxy): add proxy-aware execution context to image generation route. Image requests now correctly use proxy settings from the connection's ProxyRegistry assignment, matching the pattern used by chat pipeline. - fix(translator): inject properties:{} into zero-argument MCP tool schemas during Anthropic→OpenAI translation. OpenAI strict mode requires explicit properties even for empty object schemas. Closes #1914, Closes #1904, Closes #1898 * chore(release): v3.7.9 — all changes in ONE commit * fix: allow local ollama provider connections (#1893) * fix(copilot): emit compatible reasoning text deltas (#1919) Integrated into release/v3.7.9 * fix(api-manager): show validation errors inline in modals, not behind backdrop (#1920) Integrated into release/v3.7.9 * docs: update changelog and pr body with merged prs * fix(providers): route agentrouter through anthropic endpoint headers Update the AgentRouter provider registry to use the Claude-compatible messages API and required Anthropic-style authentication headers. This bypasses unauthorized_client_error responses and exposes the supported model list through passthrough configuration. Also update the changelog and release PR notes to document the fix for #1921 * feat(logs): show compression tokens in request log UI (#1923) * docs: add PR #1923 to changelog --------- Co-authored-by: diegosouzapw Co-authored-by: backryun Co-authored-by: Aculeasis <42580940+Aculeasis@users.noreply.github.com> Co-authored-by: Raxxoor Co-authored-by: Randi <55005611+rdself@users.noreply.github.com> Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com> Co-authored-by: Tubagus <54710482+0xtbug@users.noreply.github.com> Co-authored-by: smartenok-ops Co-authored-by: Gi99lin <74502520+Gi99lin@users.noreply.github.com> Co-authored-by: ivan-mezentsev Co-authored-by: Andrew Munsell --- .agents/workflows/deploy-vps-akamai.md | 2 +- .agents/workflows/deploy-vps-both.md | 2 +- .agents/workflows/deploy-vps-local.md | 2 +- .agents/workflows/generate-release.md | 2 +- .agents/workflows/version-bump.md | 18 +- AGENTS.md | 34 +- CHANGELOG.md | 49 + README.md | 100 +- docs/API_REFERENCE.md | 45 +- docs/ARCHITECTURE.md | 18 +- docs/COMPRESSION_ENGINES.md | 143 ++ docs/COMPRESSION_GUIDE.md | 117 +- docs/COMPRESSION_LANGUAGE_PACKS.md | 96 + docs/COMPRESSION_RULES_FORMAT.md | 186 ++ docs/ENVIRONMENT.md | 6 + docs/FEATURES.md | 18 +- docs/MCP-SERVER.md | 74 +- docs/RTK_COMPRESSION.md | 234 ++ docs/SETUP_GUIDE.md | 2 +- docs/openapi.yaml | 200 +- electron/package-lock.json | 4 +- electron/package.json | 2 +- llm.txt | 4 +- next.config.mjs | 103 +- open-sse/config/embeddingRegistry.ts | 65 +- open-sse/config/imageRegistry.ts | 14 + open-sse/config/providerRegistry.ts | 34 +- open-sse/config/rerankRegistry.ts | 52 +- open-sse/executors/base.ts | 3 + open-sse/executors/codex.ts | 80 +- open-sse/handlers/chatCore.ts | 413 +++- open-sse/handlers/imageGeneration.ts | 1 + open-sse/handlers/responseSanitizer.ts | 3 + open-sse/mcp-server/README.md | 26 +- open-sse/mcp-server/descriptionCompressor.ts | 243 ++ open-sse/mcp-server/schemas/tools.ts | 153 +- open-sse/mcp-server/server.ts | 41 + open-sse/mcp-server/tools/advancedTools.ts | 31 +- open-sse/mcp-server/tools/compressionTools.ts | 168 +- open-sse/package.json | 2 +- open-sse/services/AGENTS.md | 7 +- open-sse/services/accountFallback.ts | 26 +- open-sse/services/autoCombo/index.ts | 11 +- open-sse/services/combo.ts | 63 +- open-sse/services/compression/aggressive.ts | 48 +- open-sse/services/compression/caveman.ts | 341 ++- open-sse/services/compression/cavemanRules.ts | 204 +- open-sse/services/compression/diffHelper.ts | 117 + .../compression/engines/cavemanAdapter.ts | 408 ++++ .../services/compression/engines/index.ts | 14 + .../services/compression/engines/registry.ts | 87 + .../compression/engines/rtk/codeStripper.ts | 128 + .../engines/rtk/commandDetector.ts | 465 ++++ .../compression/engines/rtk/deduplicator.ts | 37 + .../compression/engines/rtk/filterLoader.ts | 220 ++ .../compression/engines/rtk/filterSchema.ts | 182 ++ .../compression/engines/rtk/filters/aws.json | 34 + .../engines/rtk/filters/biome.json | 35 + .../engines/rtk/filters/build-eslint.json | 37 + .../engines/rtk/filters/build-typescript.json | 33 + .../engines/rtk/filters/build-vite.json | 41 + .../engines/rtk/filters/build-webpack.json | 34 + .../engines/rtk/filters/bundle-install.json | 35 + .../compression/engines/rtk/filters/curl.json | 34 + .../compression/engines/rtk/filters/df.json | 34 + .../engines/rtk/filters/docker-logs.json | 42 + .../engines/rtk/filters/docker-ps.json | 33 + .../compression/engines/rtk/filters/du.json | 33 + .../engines/rtk/filters/error-stacktrace.json | 41 + .../engines/rtk/filters/gcloud.json | 34 + .../engines/rtk/filters/generic-output.json | 32 + .../engines/rtk/filters/git-branch.json | 45 + .../engines/rtk/filters/git-diff.json | 33 + .../engines/rtk/filters/git-log.json | 33 + .../engines/rtk/filters/git-status.json | 40 + .../engines/rtk/filters/golangci-lint.json | 34 + .../engines/rtk/filters/json-output.json | 40 + .../compression/engines/rtk/filters/make.json | 38 + .../compression/engines/rtk/filters/mypy.json | 34 + .../engines/rtk/filters/npm-audit.json | 43 + .../engines/rtk/filters/npm-install.json | 41 + .../compression/engines/rtk/filters/nx.json | 34 + .../compression/engines/rtk/filters/pip.json | 41 + .../engines/rtk/filters/playwright.json | 42 + .../engines/rtk/filters/poetry-install.json | 35 + .../engines/rtk/filters/prettier.json | 40 + .../compression/engines/rtk/filters/ps.json | 34 + .../engines/rtk/filters/rsync.json | 33 + .../engines/rtk/filters/rubocop.json | 34 + .../compression/engines/rtk/filters/ruff.json | 40 + .../engines/rtk/filters/shell-find.json | 33 + .../engines/rtk/filters/shell-grep.json | 37 + .../engines/rtk/filters/shell-ls.json | 33 + .../compression/engines/rtk/filters/ssh.json | 43 + .../engines/rtk/filters/systemctl-status.json | 43 + .../engines/rtk/filters/terraform-plan.json | 34 + .../engines/rtk/filters/test-cargo.json | 42 + .../engines/rtk/filters/test-go.json | 41 + .../engines/rtk/filters/test-jest.json | 43 + .../engines/rtk/filters/test-pytest.json | 41 + .../engines/rtk/filters/test-vitest.json | 43 + .../engines/rtk/filters/tofu-plan.json | 34 + .../engines/rtk/filters/turbo.json | 34 + .../engines/rtk/filters/uv-sync.json | 35 + .../compression/engines/rtk/filters/wget.json | 34 + .../services/compression/engines/rtk/index.ts | 436 ++++ .../compression/engines/rtk/lineFilter.ts | 155 ++ .../compression/engines/rtk/rawOutput.ts | 112 + .../compression/engines/rtk/smartTruncate.ts | 67 + .../compression/engines/rtk/verify.ts | 107 + .../services/compression/engines/types.ts | 59 + open-sse/services/compression/index.ts | 97 +- .../services/compression/languageDetector.ts | 18 + open-sse/services/compression/lite.ts | 28 +- .../services/compression/messageContent.ts | 79 + open-sse/services/compression/outputMode.ts | 161 ++ open-sse/services/compression/preservation.ts | 202 +- .../services/compression/progressiveAging.ts | 38 +- open-sse/services/compression/ruleLoader.ts | 258 ++ .../services/compression/rules/_schema.json | 31 + .../compression/rules/de/context.json | 38 + .../services/compression/rules/de/filler.json | 70 + .../compression/rules/de/structural.json | 30 + .../compression/rules/en/context.json | 88 + .../services/compression/rules/en/dedup.json | 38 + .../services/compression/rules/en/filler.json | 129 + .../compression/rules/en/structural.json | 102 + .../services/compression/rules/en/ultra.json | 107 + .../compression/rules/es/context.json | 38 + .../services/compression/rules/es/filler.json | 70 + .../compression/rules/es/structural.json | 30 + .../compression/rules/fr/context.json | 38 + .../services/compression/rules/fr/filler.json | 70 + .../compression/rules/fr/structural.json | 30 + .../compression/rules/ja/context.json | 38 + .../services/compression/rules/ja/filler.json | 70 + .../compression/rules/ja/structural.json | 30 + .../compression/rules/pt-BR/context.json | 38 + .../compression/rules/pt-BR/filler.json | 70 + .../compression/rules/pt-BR/structural.json | 30 + open-sse/services/compression/stats.ts | 4 + .../services/compression/strategySelector.ts | 171 +- open-sse/services/compression/types.ts | 134 +- open-sse/services/compression/ultra.ts | 45 +- open-sse/services/compression/validation.ts | 132 ++ open-sse/services/responsesInputSanitizer.ts | 36 + open-sse/services/responsesToolCallState.ts | 32 +- open-sse/services/tokenRefresh.ts | 105 +- .../translator/request/claude-to-openai.ts | 17 +- .../translator/response/openai-responses.ts | 5 +- open-sse/utils/aiSdkCompat.ts | 4 +- open-sse/utils/stream.ts | 8 +- open-sse/utils/streamHelpers.ts | 1 + package-lock.json | 62 +- package.json | 5 +- pr_metadata.json | 2102 +++++++++++++++++ scripts/pack-artifact-policy.ts | 4 + scripts/prepublish.ts | 17 + scripts/scratch/pr_body.md | 46 + scripts/scratch/test-migrations.ts | 89 +- .../analytics/CompressionAnalyticsTab.tsx | 74 +- .../api-manager/ApiManagerPageClient.tsx | 125 +- .../dashboard/compression/page.tsx | 22 +- .../caveman/CavemanContextPageClient.tsx | 262 ++ .../dashboard/context/caveman/page.tsx | 5 + .../combos/CompressionCombosPageClient.tsx | 391 +++ .../dashboard/context/combos/page.tsx | 5 + .../context/rtk/RtkContextPageClient.tsx | 349 +++ .../dashboard/context/rtk/page.tsx | 5 + .../dashboard/providers/[id]/page.tsx | 79 +- .../settings/components/ComboDefaultsTab.tsx | 34 +- .../components/CompressionSettingsTab.tsx | 223 +- .../(dashboard)/dashboard/settings/page.tsx | 28 +- .../dashboard/system/proxy/page.tsx | 79 + .../api/compression/language-packs/route.ts | 16 + src/app/api/compression/preview/route.ts | 44 +- src/app/api/compression/rules/route.ts | 12 + src/app/api/context/analytics/route.ts | 1 + src/app/api/context/caveman/config/route.ts | 1 + .../context/combos/[id]/assignments/route.ts | 47 + src/app/api/context/combos/[id]/route.ts | 79 + src/app/api/context/combos/route.ts | 50 + src/app/api/context/rtk/config/route.ts | 34 + src/app/api/context/rtk/filters/route.ts | 15 + .../api/context/rtk/raw-output/[id]/route.ts | 26 + src/app/api/context/rtk/test/route.ts | 43 + src/app/api/models/test/route.ts | 86 +- src/app/api/monitoring/health/route.ts | 7 +- .../api/oauth/[provider]/[action]/route.ts | 13 + src/app/api/oauth/cursor/import/route.ts | 17 +- src/app/api/oauth/kiro/import/route.ts | 12 +- .../api/oauth/kiro/social-authorize/route.ts | 5 + .../api/oauth/kiro/social-exchange/route.ts | 5 + src/app/api/providers/[id]/models/route.ts | 33 +- src/app/api/settings/compression/route.ts | 64 +- .../api/settings/compression/rules/route.ts | 1 + src/app/api/usage/analytics/route.ts | 204 +- src/app/api/v1/embeddings/route.ts | 6 +- src/app/api/v1/images/generations/route.ts | 35 +- src/app/api/v1/models/catalog.ts | 50 +- src/i18n/messages/ar.json | 92 +- src/i18n/messages/bg.json | 92 +- src/i18n/messages/bn.json | 92 +- src/i18n/messages/cs.json | 92 +- src/i18n/messages/da.json | 92 +- src/i18n/messages/de.json | 92 +- src/i18n/messages/en.json | 102 +- src/i18n/messages/es.json | 92 +- src/i18n/messages/fa.json | 92 +- src/i18n/messages/fi.json | 92 +- src/i18n/messages/fr.json | 92 +- src/i18n/messages/gu.json | 92 +- src/i18n/messages/he.json | 92 +- src/i18n/messages/hi.json | 92 +- src/i18n/messages/hu.json | 92 +- src/i18n/messages/id.json | 92 +- src/i18n/messages/in.json | 92 +- src/i18n/messages/it.json | 92 +- src/i18n/messages/ja.json | 92 +- src/i18n/messages/ko.json | 92 +- src/i18n/messages/mr.json | 92 +- src/i18n/messages/ms.json | 92 +- src/i18n/messages/nl.json | 92 +- src/i18n/messages/no.json | 92 +- src/i18n/messages/phi.json | 92 +- src/i18n/messages/pl.json | 92 +- src/i18n/messages/pt-BR.json | 97 +- src/i18n/messages/pt.json | 92 +- src/i18n/messages/ro.json | 92 +- src/i18n/messages/ru.json | 92 +- src/i18n/messages/sk.json | 92 +- src/i18n/messages/sv.json | 92 +- src/i18n/messages/sw.json | 92 +- src/i18n/messages/ta.json | 92 +- src/i18n/messages/te.json | 92 +- src/i18n/messages/th.json | 92 +- src/i18n/messages/tr.json | 92 +- src/i18n/messages/uk-UA.json | 92 +- src/i18n/messages/ur.json | 92 +- src/i18n/messages/vi.json | 92 +- src/i18n/messages/zh-CN.json | 92 +- src/lib/db/compression.ts | 189 ++ src/lib/db/compressionAnalytics.ts | 304 ++- src/lib/db/compressionCombos.ts | 420 ++++ src/lib/db/core.ts | 5 +- src/lib/db/jsonMigration.ts | 14 +- src/lib/db/migrationRunner.ts | 137 +- .../migrations/041_compression_receipts.sql | 18 + .../db/migrations/042_compression_combos.sql | 43 + ...043_default_compression_combo_pipeline.sql | 12 + .../044_usage_history_api_key_backfill.sql | 27 + .../db/migrations/045_compression_tokens.sql | 4 + src/lib/db/models.ts | 5 + src/lib/localDb.ts | 4 +- src/lib/providerModels/managedModelImport.ts | 4 +- src/lib/providerModels/modelDiscovery.ts | 3 + src/lib/providers/validation.ts | 7 +- src/lib/usage/callLogArtifacts.ts | 3 +- src/lib/usage/callLogs.ts | 11 +- src/lib/usage/migrations.ts | 2 +- src/shared/components/RequestLoggerDetail.tsx | 10 + src/shared/components/RequestLoggerV2.tsx | 11 + src/shared/components/analytics/charts.tsx | 47 +- src/shared/constants/mcpScopes.ts | 3 + src/shared/constants/publicApiRoutes.ts | 6 +- src/shared/constants/routingStrategies.ts | 81 +- src/shared/constants/sidebarVisibility.ts | 34 +- src/shared/schemas/validation.ts | 13 +- src/shared/utils/formatting.ts | 49 + src/shared/utils/parseApiKeys.ts | 41 + .../validation/compressionConfigSchemas.ts | 165 ++ src/shared/validation/schemas.ts | 49 +- src/shared/validation/settingsSchemas.ts | 19 +- src/sse/services/tokenRefresh.ts | 33 +- src/types/settings.ts | 15 +- tests/e2e/api-keys-flow.spec.ts | 259 ++ .../golden-set/compression-caveman-v2.test.ts | 85 + .../compression-upstream-parity.test.ts | 151 ++ tests/integration/chat-pipeline.test.ts | 1 - .../chatcore-compression-integration.test.ts | 436 +++- tests/integration/security-hardening.test.ts | 18 + tests/unit/account-fallback-service.test.ts | 26 + tests/unit/api-auth.test.ts | 14 + .../api/compression/compression-api.test.ts | 12 +- tests/unit/autocombo-unification.test.ts | 31 +- tests/unit/bulk-paste-api-keys.test.ts | 103 + tests/unit/call-log-cap.test.ts | 2 +- tests/unit/combo-routing-engine.test.ts | 120 + tests/unit/combo-strategies.test.ts | 188 +- tests/unit/compression-tokens.test.ts | 98 + tests/unit/compression/caveman-v379.test.ts | 182 ++ .../compression-aggressive.test.ts | 20 + .../compression/compression-combos-db.test.ts | 132 ++ .../compression-preview-api.test.ts | 65 + .../compression-preview-auth.test.ts | 155 ++ .../compression/compressionAnalytics.test.ts | 110 +- .../compression/compressionMcpTools.test.ts | 79 +- .../context-compression-api.test.ts | 56 + tests/unit/compression/diffHelper.test.ts | 45 + .../unit/compression/engine-registry.test.ts | 85 + .../compression/fixtures/rtk/aws-sample.txt | 4 + .../compression/fixtures/rtk/biome-sample.txt | 4 + .../fixtures/rtk/build-vite-sample.txt | 3 + .../fixtures/rtk/build-webpack-sample.txt | 3 + .../fixtures/rtk/bundle-install-sample.txt | 4 + .../compression/fixtures/rtk/curl-sample.txt | 4 + .../compression/fixtures/rtk/df-sample.txt | 4 + .../fixtures/rtk/docker-logs-sample.txt | 4 + .../compression/fixtures/rtk/du-sample.txt | 4 + .../fixtures/rtk/error-stacktrace-sample.txt | 4 + .../fixtures/rtk/eslint-output-sample.txt | 5 + .../fixtures/rtk/gcloud-sample.txt | 3 + .../fixtures/rtk/git-branch-sample.txt | 3 + .../fixtures/rtk/git-diff-sample.txt | 8 + .../fixtures/rtk/git-status-sample.txt | 5 + .../fixtures/rtk/golangci-lint-sample.txt | 5 + .../fixtures/rtk/json-output-sample.txt | 7 + .../compression/fixtures/rtk/make-sample.txt | 6 + .../compression/fixtures/rtk/mypy-sample.txt | 2 + .../fixtures/rtk/npm-audit-sample.txt | 2 + .../compression/fixtures/rtk/nx-sample.txt | 6 + .../compression/fixtures/rtk/pip-sample.txt | 4 + .../fixtures/rtk/playwright-sample.txt | 6 + .../fixtures/rtk/poetry-install-sample.txt | 5 + .../fixtures/rtk/prettier-sample.txt | 4 + .../compression/fixtures/rtk/ps-sample.txt | 4 + .../compression/fixtures/rtk/rsync-sample.txt | 5 + .../fixtures/rtk/rubocop-sample.txt | 4 + .../compression/fixtures/rtk/ruff-sample.txt | 3 + .../fixtures/rtk/shell-find-sample.txt | 3 + .../fixtures/rtk/shell-grep-sample.txt | 3 + .../compression/fixtures/rtk/ssh-sample.txt | 3 + .../fixtures/rtk/systemctl-status-sample.txt | 4 + .../fixtures/rtk/terraform-plan-sample.txt | 8 + .../fixtures/rtk/test-cargo-sample.txt | 8 + .../fixtures/rtk/test-go-sample.txt | 4 + .../fixtures/rtk/tofu-plan-sample.txt | 8 + .../compression/fixtures/rtk/turbo-sample.txt | 5 + .../fixtures/rtk/typescript-errors-sample.txt | 2 + .../fixtures/rtk/uv-sync-sample.txt | 4 + .../fixtures/rtk/vitest-output-sample.txt | 7 + .../compression/fixtures/rtk/wget-sample.txt | 4 + tests/unit/compression/language-packs.test.ts | 60 + tests/unit/compression/lite.test.ts | 23 + .../mcp-description-compressor.test.ts | 132 ++ tests/unit/compression/outputMode.test.ts | 89 + .../compression/pipeline-integration.test.ts | 106 + .../compression/rtk-code-stripper.test.ts | 72 + .../compression/rtk-command-detector.test.ts | 107 + .../compression/rtk-custom-filters.test.ts | 104 + .../unit/compression/rtk-deduplicator.test.ts | 21 + .../unit/compression/rtk-dsl-pipeline.test.ts | 94 + tests/unit/compression/rtk-engine.test.ts | 115 + .../compression/rtk-filter-catalog.test.ts | 94 + .../unit/compression/rtk-line-filter.test.ts | 39 + .../compression/rtk-raw-output-route.test.ts | 138 ++ tests/unit/compression/rtk-raw-output.test.ts | 131 + .../compression/rtk-smart-truncate.test.ts | 43 + tests/unit/compression/rtk-verify.test.ts | 78 + tests/unit/compression/rule-loader.test.ts | 112 + .../unit/compression/strategySelector.test.ts | 35 + tests/unit/compression/types.test.ts | 12 +- tests/unit/compression/validation.test.ts | 136 ++ ...embedding-rerank-provider-registry.test.ts | 24 +- tests/unit/executor-codex.test.ts | 114 + tests/unit/issue-fixes-v3.7.9.test.ts | 131 + tests/unit/json-migration-combos.test.ts | 51 + tests/unit/models-catalog-route.test.ts | 97 + tests/unit/next-config.test.ts | 96 +- tests/unit/pack-artifact-policy.test.ts | 19 +- tests/unit/provider-models-route.test.ts | 19 +- tests/unit/public-api-routes.test.ts | 5 + tests/unit/response-sanitizer.test.ts | 14 + .../unit/responses-translation-fixes.test.ts | 23 + ...settings-schema-routing-strategies.test.ts | 23 +- tests/unit/sidebar-visibility.test.ts | 26 +- tests/unit/streamHelpers.test.ts | 5 + tests/unit/token-refresh-service.test.ts | 214 ++ .../unit/translator-claude-to-openai.test.ts | 2 +- tests/unit/usage-analytics-route.test.ts | 6 +- 380 files changed, 24942 insertions(+), 1476 deletions(-) create mode 100644 docs/COMPRESSION_ENGINES.md create mode 100644 docs/COMPRESSION_LANGUAGE_PACKS.md create mode 100644 docs/COMPRESSION_RULES_FORMAT.md create mode 100644 docs/RTK_COMPRESSION.md create mode 100644 open-sse/mcp-server/descriptionCompressor.ts create mode 100644 open-sse/services/compression/diffHelper.ts create mode 100644 open-sse/services/compression/engines/cavemanAdapter.ts create mode 100644 open-sse/services/compression/engines/index.ts create mode 100644 open-sse/services/compression/engines/registry.ts create mode 100644 open-sse/services/compression/engines/rtk/codeStripper.ts create mode 100644 open-sse/services/compression/engines/rtk/commandDetector.ts create mode 100644 open-sse/services/compression/engines/rtk/deduplicator.ts create mode 100644 open-sse/services/compression/engines/rtk/filterLoader.ts create mode 100644 open-sse/services/compression/engines/rtk/filterSchema.ts create mode 100644 open-sse/services/compression/engines/rtk/filters/aws.json create mode 100644 open-sse/services/compression/engines/rtk/filters/biome.json create mode 100644 open-sse/services/compression/engines/rtk/filters/build-eslint.json create mode 100644 open-sse/services/compression/engines/rtk/filters/build-typescript.json create mode 100644 open-sse/services/compression/engines/rtk/filters/build-vite.json create mode 100644 open-sse/services/compression/engines/rtk/filters/build-webpack.json create mode 100644 open-sse/services/compression/engines/rtk/filters/bundle-install.json create mode 100644 open-sse/services/compression/engines/rtk/filters/curl.json create mode 100644 open-sse/services/compression/engines/rtk/filters/df.json create mode 100644 open-sse/services/compression/engines/rtk/filters/docker-logs.json create mode 100644 open-sse/services/compression/engines/rtk/filters/docker-ps.json create mode 100644 open-sse/services/compression/engines/rtk/filters/du.json create mode 100644 open-sse/services/compression/engines/rtk/filters/error-stacktrace.json create mode 100644 open-sse/services/compression/engines/rtk/filters/gcloud.json create mode 100644 open-sse/services/compression/engines/rtk/filters/generic-output.json create mode 100644 open-sse/services/compression/engines/rtk/filters/git-branch.json create mode 100644 open-sse/services/compression/engines/rtk/filters/git-diff.json create mode 100644 open-sse/services/compression/engines/rtk/filters/git-log.json create mode 100644 open-sse/services/compression/engines/rtk/filters/git-status.json create mode 100644 open-sse/services/compression/engines/rtk/filters/golangci-lint.json create mode 100644 open-sse/services/compression/engines/rtk/filters/json-output.json create mode 100644 open-sse/services/compression/engines/rtk/filters/make.json create mode 100644 open-sse/services/compression/engines/rtk/filters/mypy.json create mode 100644 open-sse/services/compression/engines/rtk/filters/npm-audit.json create mode 100644 open-sse/services/compression/engines/rtk/filters/npm-install.json create mode 100644 open-sse/services/compression/engines/rtk/filters/nx.json create mode 100644 open-sse/services/compression/engines/rtk/filters/pip.json create mode 100644 open-sse/services/compression/engines/rtk/filters/playwright.json create mode 100644 open-sse/services/compression/engines/rtk/filters/poetry-install.json create mode 100644 open-sse/services/compression/engines/rtk/filters/prettier.json create mode 100644 open-sse/services/compression/engines/rtk/filters/ps.json create mode 100644 open-sse/services/compression/engines/rtk/filters/rsync.json create mode 100644 open-sse/services/compression/engines/rtk/filters/rubocop.json create mode 100644 open-sse/services/compression/engines/rtk/filters/ruff.json create mode 100644 open-sse/services/compression/engines/rtk/filters/shell-find.json create mode 100644 open-sse/services/compression/engines/rtk/filters/shell-grep.json create mode 100644 open-sse/services/compression/engines/rtk/filters/shell-ls.json create mode 100644 open-sse/services/compression/engines/rtk/filters/ssh.json create mode 100644 open-sse/services/compression/engines/rtk/filters/systemctl-status.json create mode 100644 open-sse/services/compression/engines/rtk/filters/terraform-plan.json create mode 100644 open-sse/services/compression/engines/rtk/filters/test-cargo.json create mode 100644 open-sse/services/compression/engines/rtk/filters/test-go.json create mode 100644 open-sse/services/compression/engines/rtk/filters/test-jest.json create mode 100644 open-sse/services/compression/engines/rtk/filters/test-pytest.json create mode 100644 open-sse/services/compression/engines/rtk/filters/test-vitest.json create mode 100644 open-sse/services/compression/engines/rtk/filters/tofu-plan.json create mode 100644 open-sse/services/compression/engines/rtk/filters/turbo.json create mode 100644 open-sse/services/compression/engines/rtk/filters/uv-sync.json create mode 100644 open-sse/services/compression/engines/rtk/filters/wget.json create mode 100644 open-sse/services/compression/engines/rtk/index.ts create mode 100644 open-sse/services/compression/engines/rtk/lineFilter.ts create mode 100644 open-sse/services/compression/engines/rtk/rawOutput.ts create mode 100644 open-sse/services/compression/engines/rtk/smartTruncate.ts create mode 100644 open-sse/services/compression/engines/rtk/verify.ts create mode 100644 open-sse/services/compression/engines/types.ts create mode 100644 open-sse/services/compression/languageDetector.ts create mode 100644 open-sse/services/compression/messageContent.ts create mode 100644 open-sse/services/compression/outputMode.ts create mode 100644 open-sse/services/compression/ruleLoader.ts create mode 100644 open-sse/services/compression/rules/_schema.json create mode 100644 open-sse/services/compression/rules/de/context.json create mode 100644 open-sse/services/compression/rules/de/filler.json create mode 100644 open-sse/services/compression/rules/de/structural.json create mode 100644 open-sse/services/compression/rules/en/context.json create mode 100644 open-sse/services/compression/rules/en/dedup.json create mode 100644 open-sse/services/compression/rules/en/filler.json create mode 100644 open-sse/services/compression/rules/en/structural.json create mode 100644 open-sse/services/compression/rules/en/ultra.json create mode 100644 open-sse/services/compression/rules/es/context.json create mode 100644 open-sse/services/compression/rules/es/filler.json create mode 100644 open-sse/services/compression/rules/es/structural.json create mode 100644 open-sse/services/compression/rules/fr/context.json create mode 100644 open-sse/services/compression/rules/fr/filler.json create mode 100644 open-sse/services/compression/rules/fr/structural.json create mode 100644 open-sse/services/compression/rules/ja/context.json create mode 100644 open-sse/services/compression/rules/ja/filler.json create mode 100644 open-sse/services/compression/rules/ja/structural.json create mode 100644 open-sse/services/compression/rules/pt-BR/context.json create mode 100644 open-sse/services/compression/rules/pt-BR/filler.json create mode 100644 open-sse/services/compression/rules/pt-BR/structural.json create mode 100644 open-sse/services/compression/validation.ts create mode 100644 open-sse/services/responsesInputSanitizer.ts create mode 100644 pr_metadata.json create mode 100644 scripts/scratch/pr_body.md create mode 100644 src/app/(dashboard)/dashboard/context/caveman/CavemanContextPageClient.tsx create mode 100644 src/app/(dashboard)/dashboard/context/caveman/page.tsx create mode 100644 src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient.tsx create mode 100644 src/app/(dashboard)/dashboard/context/combos/page.tsx create mode 100644 src/app/(dashboard)/dashboard/context/rtk/RtkContextPageClient.tsx create mode 100644 src/app/(dashboard)/dashboard/context/rtk/page.tsx create mode 100644 src/app/(dashboard)/dashboard/system/proxy/page.tsx create mode 100644 src/app/api/compression/language-packs/route.ts create mode 100644 src/app/api/compression/rules/route.ts create mode 100644 src/app/api/context/analytics/route.ts create mode 100644 src/app/api/context/caveman/config/route.ts create mode 100644 src/app/api/context/combos/[id]/assignments/route.ts create mode 100644 src/app/api/context/combos/[id]/route.ts create mode 100644 src/app/api/context/combos/route.ts create mode 100644 src/app/api/context/rtk/config/route.ts create mode 100644 src/app/api/context/rtk/filters/route.ts create mode 100644 src/app/api/context/rtk/raw-output/[id]/route.ts create mode 100644 src/app/api/context/rtk/test/route.ts create mode 100644 src/app/api/settings/compression/rules/route.ts create mode 100644 src/lib/db/compressionCombos.ts create mode 100644 src/lib/db/migrations/041_compression_receipts.sql create mode 100644 src/lib/db/migrations/042_compression_combos.sql create mode 100644 src/lib/db/migrations/043_default_compression_combo_pipeline.sql create mode 100644 src/lib/db/migrations/044_usage_history_api_key_backfill.sql create mode 100644 src/lib/db/migrations/045_compression_tokens.sql create mode 100644 src/shared/utils/parseApiKeys.ts create mode 100644 src/shared/validation/compressionConfigSchemas.ts create mode 100644 tests/golden-set/compression-caveman-v2.test.ts create mode 100644 tests/golden-set/compression-upstream-parity.test.ts create mode 100644 tests/unit/bulk-paste-api-keys.test.ts create mode 100644 tests/unit/compression-tokens.test.ts create mode 100644 tests/unit/compression/caveman-v379.test.ts create mode 100644 tests/unit/compression/compression-combos-db.test.ts create mode 100644 tests/unit/compression/compression-preview-api.test.ts create mode 100644 tests/unit/compression/compression-preview-auth.test.ts create mode 100644 tests/unit/compression/context-compression-api.test.ts create mode 100644 tests/unit/compression/diffHelper.test.ts create mode 100644 tests/unit/compression/engine-registry.test.ts create mode 100644 tests/unit/compression/fixtures/rtk/aws-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/biome-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/build-vite-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/build-webpack-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/bundle-install-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/curl-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/df-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/docker-logs-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/du-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/error-stacktrace-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/eslint-output-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/gcloud-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/git-branch-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/git-diff-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/git-status-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/golangci-lint-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/json-output-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/make-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/mypy-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/npm-audit-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/nx-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/pip-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/playwright-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/poetry-install-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/prettier-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/ps-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/rsync-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/rubocop-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/ruff-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/shell-find-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/shell-grep-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/ssh-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/systemctl-status-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/terraform-plan-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/test-cargo-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/test-go-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/tofu-plan-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/turbo-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/typescript-errors-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/uv-sync-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/vitest-output-sample.txt create mode 100644 tests/unit/compression/fixtures/rtk/wget-sample.txt create mode 100644 tests/unit/compression/language-packs.test.ts create mode 100644 tests/unit/compression/mcp-description-compressor.test.ts create mode 100644 tests/unit/compression/outputMode.test.ts create mode 100644 tests/unit/compression/pipeline-integration.test.ts create mode 100644 tests/unit/compression/rtk-code-stripper.test.ts create mode 100644 tests/unit/compression/rtk-command-detector.test.ts create mode 100644 tests/unit/compression/rtk-custom-filters.test.ts create mode 100644 tests/unit/compression/rtk-deduplicator.test.ts create mode 100644 tests/unit/compression/rtk-dsl-pipeline.test.ts create mode 100644 tests/unit/compression/rtk-engine.test.ts create mode 100644 tests/unit/compression/rtk-filter-catalog.test.ts create mode 100644 tests/unit/compression/rtk-line-filter.test.ts create mode 100644 tests/unit/compression/rtk-raw-output-route.test.ts create mode 100644 tests/unit/compression/rtk-raw-output.test.ts create mode 100644 tests/unit/compression/rtk-smart-truncate.test.ts create mode 100644 tests/unit/compression/rtk-verify.test.ts create mode 100644 tests/unit/compression/rule-loader.test.ts create mode 100644 tests/unit/compression/validation.test.ts create mode 100644 tests/unit/issue-fixes-v3.7.9.test.ts create mode 100644 tests/unit/json-migration-combos.test.ts diff --git a/.agents/workflows/deploy-vps-akamai.md b/.agents/workflows/deploy-vps-akamai.md index 50f92cab5f..b25ae3cd99 100644 --- a/.agents/workflows/deploy-vps-akamai.md +++ b/.agents/workflows/deploy-vps-akamai.md @@ -17,7 +17,7 @@ Deploy OmniRoute to the Akamai VPS using `npm pack + scp` + PM2. // turbo ```bash -cd /home/diegosouzapw/dev/proxys/9router && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts +cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts ``` ### 2. Copy to Akamai VPS and install diff --git a/.agents/workflows/deploy-vps-both.md b/.agents/workflows/deploy-vps-both.md index 161d197bd4..e1aa4d1def 100644 --- a/.agents/workflows/deploy-vps-both.md +++ b/.agents/workflows/deploy-vps-both.md @@ -22,7 +22,7 @@ Deploy OmniRoute to the production VPSs using `npm pack + scp` + PM2. // turbo ```bash -cd /home/diegosouzapw/dev/proxys/9router && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts +cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts ``` ### 2. Copy to both VPS and install diff --git a/.agents/workflows/deploy-vps-local.md b/.agents/workflows/deploy-vps-local.md index f348f63da5..549b1f0b2a 100644 --- a/.agents/workflows/deploy-vps-local.md +++ b/.agents/workflows/deploy-vps-local.md @@ -17,7 +17,7 @@ Deploy OmniRoute to the Local VPS using `npm pack + scp` + PM2. // turbo ```bash -cd /home/diegosouzapw/dev/proxys/9router && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts +cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts ``` ### 2. Copy to Local VPS and install diff --git a/.agents/workflows/generate-release.md b/.agents/workflows/generate-release.md index e127c832d4..399e12d214 100644 --- a/.agents/workflows/generate-release.md +++ b/.agents/workflows/generate-release.md @@ -226,7 +226,7 @@ git checkout main git pull origin main # Build and pack locally -cd /home/diegosouzapw/dev/proxys/9router && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts +cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts # Deploy to LOCAL VPS (192.168.0.15) scp omniroute-*.tgz root@192.168.0.15:/tmp/ diff --git a/.agents/workflows/version-bump.md b/.agents/workflows/version-bump.md index 4b3b77a921..0667e0eb1d 100644 --- a/.agents/workflows/version-bump.md +++ b/.agents/workflows/version-bump.md @@ -20,7 +20,7 @@ Automatically bump the project version, generate CHANGELOG entries from git hist // turbo ```bash -cd /home/diegosouzapw/dev/proxys/9router +cd /home/diegosouzapw/dev/proxys/OmniRoute CURRENT_VERSION=$(node -p "require('./package.json').version") LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") CURRENT_BRANCH=$(git branch --show-current) @@ -64,7 +64,7 @@ npm version "$VERSION" --no-git-tag-version // turbo ```bash -cd /home/diegosouzapw/dev/proxys/9router +cd /home/diegosouzapw/dev/proxys/OmniRoute LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null) echo "=== Commits since $LAST_TAG ===" git log "$LAST_TAG"..HEAD --pretty=format:"%h %s" --no-merges | head -100 @@ -133,7 +133,7 @@ The date must be today's date in `YYYY-MM-DD` format. // turbo ```bash -cd /home/diegosouzapw/dev/proxys/9router +cd /home/diegosouzapw/dev/proxys/OmniRoute VERSION=$(node -p "require('./package.json').version") # Update docs/openapi.yaml version @@ -156,7 +156,7 @@ echo "✓ All workspace packages synced to $VERSION" // turbo ```bash -cd /home/diegosouzapw/dev/proxys/9router +cd /home/diegosouzapw/dev/proxys/OmniRoute VERSION=$(node -p "require('./package.json').version") OLD_VERSION_PATTERN='[0-9]\+\.[0-9]\+\.[0-9]\+' @@ -174,7 +174,7 @@ echo "✓ llm.txt → $VERSION" // turbo ```bash -cd /home/diegosouzapw/dev/proxys/9router +cd /home/diegosouzapw/dev/proxys/OmniRoute npm install echo "✓ Lock file regenerated" ``` @@ -236,7 +236,7 @@ For each file in `docs/` (excluding `docs/i18n/`), review if CHANGELOG changes a // turbo ```bash -cd /home/diegosouzapw/dev/proxys/9router +cd /home/diegosouzapw/dev/proxys/OmniRoute npm run lint ``` @@ -245,7 +245,7 @@ npm run lint // turbo ```bash -cd /home/diegosouzapw/dev/proxys/9router +cd /home/diegosouzapw/dev/proxys/OmniRoute npm test ``` @@ -254,7 +254,7 @@ npm test // turbo ```bash -cd /home/diegosouzapw/dev/proxys/9router +cd /home/diegosouzapw/dev/proxys/OmniRoute VERSION=$(node -p "require('./package.json').version") echo "Expected version: $VERSION" echo "" @@ -299,7 +299,7 @@ grep "^## \[" CHANGELOG.md | head -2 // turbo-all ```bash -cd /home/diegosouzapw/dev/proxys/9router +cd /home/diegosouzapw/dev/proxys/OmniRoute git add -A VERSION=$(node -p "require('./package.json').version") git commit -m "chore(release): bump to v$VERSION — changelog, docs, version sync" diff --git a/AGENTS.md b/AGENTS.md index 3b05a3dd65..aadaf98b2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ Unified AI proxy/router — route any LLM through one endpoint. Multi-provider s with **160+ providers** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks, Cohere, NVIDIA, Cerebras, Pollinations, Puter, Cloudflare AI, HuggingFace, DeepInfra, SambaNova, Meta Llama API, Moonshot AI, AI21 Labs, Databricks, Snowflake, and many more) -with **MCP Server** (29 tools), **A2A v0.3 Protocol**, and **Electron desktop app**. +with **MCP Server** (37 tools), **A2A v0.3 Protocol**, and **Electron desktop app**. ## Stack @@ -292,17 +292,28 @@ compression pipeline), and more. Modular prompt compression that runs proactively before the existing reactive context manager. -- **`strategySelector.ts`**: Selects compression mode based on config, combo overrides, auto-trigger - thresholds. Priority: combo override > auto-trigger > default mode > off. +- **`strategySelector.ts`**: Selects compression mode based on config, compression combo assignments, + combo overrides, auto-trigger thresholds, and defaults. Priority: assigned compression combo > + combo override > auto-trigger > default mode > off. - **`lite.ts`**: 5 lite-mode techniques: `collapseWhitespace`, `dedupSystemPrompt`, `compressToolResults`, `removeRedundantContent`, `replaceImageUrls`. Target: 10-15% savings at <1ms latency. +- **`caveman.ts` / `cavemanRules.ts`**: Caveman-style semantic condensation backed by built-in + rules plus file-loaded language packs under `compression/rules/`. +- **`engines/rtk/`**: Rule-based terminal/tool-output compression inspired by RTK patterns. Detects + command output classes, applies JSON filter packs, deduplicates repeated lines, strips ANSI/code + noise, and preserves errors/actionable context. The RTK JSON DSL supports replace, + match-output short-circuit, strip/keep, per-line truncation, head/tail/max-line truncation, + inline tests, trust-gated project/global custom filters, and optional redacted raw-output + retention for authenticated recovery. +- **`engines/registry.ts`**: Registers engines (`caveman`, `rtk`) and powers stacked pipelines. - **`stats.ts`**: Per-request compression stats tracking (original tokens, compressed tokens, - savings %, techniques used). -- **`types.ts`**: `CompressionMode` (off/lite/standard/aggressive/ultra), `CompressionConfig`, - `CompressionStats`, `CompressionResult`. -- DB settings in `src/lib/db/compression.ts`, API route at `src/app/api/settings/compression/`. -- Phase 1 implements lite mode only; standard/aggressive/ultra are placeholders for Phase 2. + savings %, techniques used, engine breakdown, compression combo id). +- **`types.ts`**: `CompressionMode` (off/lite/standard/aggressive/ultra/rtk/stacked), + `CompressionConfig`, `CompressionStats`, `CompressionResult`. +- DB settings in `src/lib/db/compression.ts`, compression combos in + `src/lib/db/compressionCombos.ts`, API routes under `src/app/api/settings/compression/`, + `src/app/api/context/*`, and preview/language-pack routes under `src/app/api/compression/*`. #### Combo Routing Engine (`combo.ts`) @@ -323,7 +334,7 @@ Policy engine modules: `policyEngine.ts`, `comboResolver.ts`, `costRules.ts`, ### MCP Server (`open-sse/mcp-server/`) -29 tools, 3 transports (stdio / SSE / Streamable HTTP). Scoped auth (10 scopes), Zod schemas. +37 tools, 3 transports (stdio / SSE / Streamable HTTP). Scoped auth (10 scopes), Zod schemas. **Core tools** (20): get_health, list_combos, get_combo_metrics, switch_combo, check_quota, route_request, cost_report, list_models_catalog, web_search, simulate_route, set_budget_guard, @@ -332,6 +343,11 @@ best_combo_for_task, explain_route, get_session_snapshot, db_health_check, sync_ **Cache tools** (2): cache_stats, cache_flush. +**Compression tools** (5): compression_status, compression_configure, set_compression_engine, +list_compression_combos, compression_combo_stats. + +**1proxy tools** (3): oneproxy_fetch, oneproxy_rotate, oneproxy_stats. + **Memory tools** (3): memory_search, memory_add, memory_clear. **Skill tools** (4): skills_list, skills_enable, skills_execute, skills_executions. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e926f19c8..80b3a71240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,55 @@ ## [Unreleased] +## [3.7.9] — 2026-05-03 + +### ✨ New Features + +- **feat(compression):** major upgrade to Caveman and RTK compression pipelines (#1876, #1889): + - Add RTK tool-output compression, stacked Caveman + RTK pipelines, compression combo assignments, dashboard context pages, MCP management tools, and language-aware Caveman rule packs. + - Expand RTK parity with a 39-filter catalog, RTK-style JSON DSL stages, inline verify/benchmark coverage, trust-gated custom filters, expanded command detection, and redacted raw-output recovery. + - Expose rule intensities, track USD savings, unify config validation, and persist MCP savings. + - Expand Caveman parity and MCP metadata compression. +- **feat(provider):** update Jina AI model catalog to support Embeddings and Rerank natively (#1874 — thanks @backryun) +- **feat(provider):** add NanoGPT image generation provider (#1899 — thanks @Aculeasis) +- **feat(ui):** move proxy configuration to dedicated System → Proxy page (#1907 — thanks @oyi77) +- **feat(ui):** add K/M/B/T cost shortener utility (#1902 — thanks @oyi77) +- **feat(providers):** implement bulk paste for extra API keys (#1916 — thanks @0xtbug) +- **feat(analytics):** usage history API key backfill + dark mode pricing (#1896 — thanks @Gi99lin) +- **feat(logs):** show RTK and Caveman compression token savings accurately in request log UI (#1923 — thanks @emdash) + +### 🐛 Bug Fixes + +- **fix(providers):** allow local OpenAI-compatible endpoints (like Ollama) to be added without an API key (fixes #1893) +- **fix(providers):** bypass AgentRouter unauthorized_client_error by spoofing Claude CLI headers via Anthropic endpoints (fixes #1921) +- **fix(copilot):** emit compatible reasoning text deltas (#1919 — thanks @ivan-mezentsev) +- **fix(api-manager):** show validation errors inline in modals, not behind (#1920 — thanks @andrewmunsell) +- **fix(compression):** align seeded standard savings combo with stacked default, preserve stacked defaults, and secure metadata routes. +- **fix(gemini-cli):** separate Cloud Code transport from Antigravity (#1869 — thanks @dhaern) +- **fix(codex):** map prompt field to input array for Cursor compatibility (fixes #1872) +- **fix(core):** align stream parameter default to false per strict OpenAI spec (fixes #1873) +- **fix(ui):** restore Next.js CSP `unsafe-eval` in production `script-src` to fix unresponsive Onboarding button (fixes #1883) +- **fix(proxy):** globally strip `prompt_cache_retention` in `BaseExecutor` to prevent upstream 400 errors from strict endpoints like droid/gemini-2-pro (fixes #1884) +- **fix(ui):** include `isOpen` dependency in `EditConnectionModal` state sync to ensure `maxConcurrent` is properly hydrated when reopening the modal (fixes #1859) +- **fix(security):** remediate 4 polynomial-redos CodeQL alerts in compression regexes by bounding repetitions and removing overlapping quantifiers +- **fix(codex):** flatten Chat Completions tool format to Codex Responses format in `normalizeCodexTools` — prevents `Missing required parameter: tools[0].name` upstream errors (#1914 — thanks @tranduykhanh030) +- **fix(proxy):** add proxy-aware execution context to image generation route — proxy settings are now correctly applied for image providers behind restricted networks (#1904 — thanks @Aculeasis) +- **fix(translator):** inject `properties: {}` into zero-argument MCP tool schemas during Anthropic→OpenAI translation — prevents 400 errors from OpenAI strict schema validation (#1898 — thanks @bryceIT) +- **fix(codex):** sanitize raw responses input (#1895 — thanks @dhaern) +- **fix(combos):** align strategy contracts (#1892 — thanks @dhaern) +- **fix(combos):** fix combo provider breaker profile handling (#1891 — thanks @rdself) +- **fix(migrations):** duplicate-column no-op fix (#1886 — thanks @smartenok-ops) +- **fix(auth):** per-connection OAuth refresh mutex (#1885 — thanks @smartenok-ops) +- **fix(auth):** require dashboard management auth for compression preview + +### 📝 Documentation + +- **docs(compression):** document RTK+Caveman stacked savings ranges + +### 🏆 Release Attribution & Retroactive Credits + +- **@payne0420** (PR #1828 / #1839) — Implementation of the **Rate Limit Watchdog** and environment overrides. (This feature was manually backported to v3.7.8, causing the automatic GitHub Release notes to omit the author's credit). + --- ## [3.7.8] — 2026-05-01 diff --git a/README.md b/README.md index 49c38eb156..d794d02d81 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ # 🚀 OmniRoute — The Free AI Gateway -### Never stop coding. Save 15-75% tokens with prompt compression + auto-fallback to **FREE & low-cost AI models**. +### Never stop coding. Save 15-95% eligible tokens with RTK+Caveman compression + auto-fallback to **FREE & low-cost AI models**. -_The most complete open-source AI proxy — **one endpoint**, **160+ providers**, **13 routing strategies**, zero downtime. Multi-platform: **Web**, **Desktop (Electron)**, **Mobile (PWA + Termux)**. Fully extensible via **MCP Server (29 tools)**, **A2A Protocol**, and **Memory/Skills** systems. Available in **40+ languages**._ +_The most complete open-source AI proxy — **one endpoint**, **160+ providers**, **13 routing strategies**, zero downtime. Multi-platform: **Web**, **Desktop (Electron)**, **Mobile (PWA + Termux)**. Fully extensible via **MCP Server (37 tools)**, **A2A Protocol**, and **Memory/Skills** systems. Available in **40+ languages**._ **Chat Completions • Responses API • Embeddings • Image Generation • Video • Music • Audio Speech/Transcription • Reranking • Moderations • Web Search • MCP Server • A2A Protocol • 4,600+ Tests • 100% TypeScript** @@ -20,7 +20,7 @@ _The most complete open-source AI proxy — **one endpoint**, **160+ providers** diegosouzapw%2FOmniRoute | Trendshift -[🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [🗜️ Compression](#%EF%B8%8F-prompt-compression--save-15-75-tokens-automatically) • [💰 Pricing](#-pricing-at-a-glance) • [🎯 Use Cases](#-use-cases--ready-made-combo-playbooks) • [🌍 Proxy](#-bypass-geographic-blocks--use-ai-from-any-country) • [❓ FAQ](#-frequently-asked-questions) • [📖 Docs](#-documentation) • [💬 WhatsApp](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) +[🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [🗜️ Compression](#%EF%B8%8F-prompt-compression--save-15-95-eligible-tokens-automatically) • [💰 Pricing](#-pricing-at-a-glance) • [🎯 Use Cases](#-use-cases--ready-made-combo-playbooks) • [🌍 Proxy](#-bypass-geographic-blocks--use-ai-from-any-country) • [❓ FAQ](#-frequently-asked-questions) • [📖 Docs](#-documentation) • [💬 WhatsApp](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) @@ -219,7 +219,7 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f **OmniRoute solves all of this:** -✅ **Prompt Compression** — auto-compress prompts & tool outputs, save 15-75% tokens per request +✅ **Prompt Compression** — auto-compress prompts & tool outputs, save 15-95% eligible tokens per request with RTK+Caveman stacked mode ✅ **Maximize subscriptions** — track quota, use every bit before reset ✅ **Auto fallback** — Subscription → API Key → Cheap → Free, zero downtime ✅ **Multi-account** — round-robin between accounts per provider @@ -410,7 +410,7 @@ Alibaba · Amazon Q · AssemblyAI · Baidu Qianfan · Baseten · Black Forest La ↓ ┌──────────────────────────────────────────────────┐ │ OmniRoute (Smart Router) │ -│ • 🗜️ Prompt Compression (save 15-75% tokens) │ +│ • 🗜️ Prompt Compression (save 15-95% eligible) │ │ • Format translation (OpenAI ↔ Claude ↔ Gemini) │ │ • Quota tracking + Embeddings + Images │ │ • Auto token refresh + Rate limit management │ @@ -424,14 +424,14 @@ Alibaba · Amazon Q · AssemblyAI · Baidu Qianfan · Baseten · Black Forest La │ ↓ budget limit └─→ [Tier 4: FREE] Qoder, Qwen, Kiro (unlimited) -Result: Never stop coding, minimal cost + 15-75% token savings +Result: Never stop coding, minimal cost + 15-95% eligible token savings ``` --- -## 🗜️ Prompt Compression — Save 15-75% Tokens Automatically +## 🗜️ Prompt Compression — Save 15-95% Eligible Tokens Automatically -> **Why use many token when few token do trick?** OmniRoute's built-in compression pipeline reduces token usage on every request — before it even reaches the provider. Inspired by [Caveman](https://github.com/JuliusBrussee/caveman) (⭐ 51K+). +> **Why use many token when few token do trick?** OmniRoute's built-in compression pipeline reduces token usage before requests reach the provider. It combines ideas from [RTK - Rust Token Killer](https://github.com/rtk-ai/rtk) and [Caveman](https://github.com/JuliusBrussee/caveman) (⭐ 51K+). ### How It Works @@ -440,16 +440,18 @@ Every request passes through the compression pipeline **transparently** — no c ``` ┌──────────────────┐ ┌─────────────────────────────┐ ┌──────────────┐ │ Client sends │────▶│ OmniRoute Compression │────▶│ Provider │ -│ full prompt │ │ Pipeline (5 modes) │ │ receives │ +│ full prompt │ │ Pipeline (7 options) │ │ receives │ │ (10,000 tok) │ │ │ │ compressed │ -│ │ │ 🪶 Lite ........... ~15% │ │ (2,500 tok) │ +│ │ │ 🪶 Lite ........... ~15% │ │ (~1,080 tok)│ │ │ │ 🪨 Standard ....... ~30% │ │ │ -│ │ │ ⚡ Aggressive ..... ~50% │ │ 💰 75% saved │ +│ │ │ ⚡ Aggressive ..... ~50% │ │ 💰 up to 95%│ │ │ │ 🔥 Ultra .......... ~75% │ │ │ +│ │ │ 🧰 RTK ............ 60-90% │ │ │ +│ │ │ 🔗 Stacked ........ 78-95% │ │ │ └──────────────────┘ └─────────────────────────────┘ └──────────────┘ ``` -### 5 Compression Modes +### 7 Compression Options | Mode | Savings | Technique | Best For | | ------------------------- | ------- | ----------------------------------------------------------------------------------------------- | -------------------------------------- | @@ -458,6 +460,36 @@ Every request passes through the compression pipeline **transparently** — no c | **🪨 Standard (Caveman)** | ~30% | 30+ regex rules: filler removal, context condensation, structural compression, multi-turn dedup | Daily coding with Claude/Codex | | **⚡ Aggressive** | ~50% | All standard + progressive message aging + tool result summarization + LLM-based compression | Long sessions with many tool calls | | **🔥 Ultra** | ~75% | All aggressive + heuristic token pruning + stopword removal + score-based filtering | Maximum savings when tokens are scarce | +| **🧰 RTK** | 60-90% | 49 command-aware filters, RTK-style JSON DSL, verify gate, trust-gated custom filters | Shell/test/build/git output in agents | +| **🔗 Stacked** | 78-95% | RTK first, then Caveman input condensation; ~89% with upstream average math | Mixed prompts with tool logs + prose | + +### RTK + Caveman Savings Math + +These numbers are based on the upstream project READMEs under `_references/_outros`: + +| Source | Upstream claim used by OmniRoute docs | +| ------- | ------------------------------------------------------------------------------------------------------------------- | +| Caveman | `~75%` fewer output tokens; benchmark average `65%` output savings, range `22-87%`; `~46%` input compression tool | +| RTK | `60-90%` command-output token savings; sample session `~118,000 -> ~23,900` tokens, which is `79.7%` saved (`~80%`) | + +For the default stacked compression combo, OmniRoute runs: + +```txt +RTK -> Caveman +``` + +When both engines can act on the same tool/context payload, the savings compound: + +```txt +combined = 1 - (1 - RTK savings) * (1 - Caveman input savings) +average = 1 - (1 - 0.80) * (1 - 0.46) = 89.2% +range = 1 - (1 - 0.60..0.90) * (1 - 0.46) = 78.4-94.6% +``` + +Caveman output mode is separate from prompt compression. When enabled for responses, use Caveman's +own upstream output numbers: `65%` average, `~75%` headline, `22-87%` observed range. Total bill +savings depend on the prompt/output mix, but coding-agent sessions are often tool-context heavy, so +the `RTK -> Caveman` combo is the best default for maximum context savings. ### Before & After (Standard/Caveman Mode) @@ -481,6 +513,8 @@ Request Body ├─ lite.ts ─────────────── Whitespace, dedup, image URLs, redundant content ├─ caveman.ts ──────────── 30+ regex rules via cavemanRules.ts │ └─ preservation.ts ─── Protects code blocks, URLs, JSON from compression + ├─ engines/rtk/ ────────── Command detection + JSON DSL filters + raw-output recovery + ├─ engines/registry.ts ─── Shared engine registry for caveman, RTK, and stacked ├─ aggressive.ts ───────── Summarizer + tool result compressor + progressive aging │ ├─ summarizer.ts ───── Rule-based message summarization │ ├─ toolResultCompressor.ts ── file/grep/shell/JSON/error compression @@ -492,7 +526,7 @@ Request Body ### Configuration ``` -Dashboard → Settings → Compression → Pick your mode +Dashboard → Context & Cache → Caveman / RTK / Compression Combos ``` Or per-combo override: @@ -508,9 +542,11 @@ Or per-combo override: Auto-trigger: set `autoTriggerTokens` to automatically enable compression when a request exceeds a token threshold. -> 🪨 **Fun fact:** The standard/caveman mode is inspired by [Caveman](https://github.com/JuliusBrussee/caveman) — the viral project that proved "caveman speak" cuts 65% of tokens while keeping 100% technical accuracy. OmniRoute takes this further with a **5-mode pipeline** that goes from gentle whitespace cleanup all the way to aggressive heuristic pruning. +Compression combos can also assign a named compression pipeline to routing combos, so a coding combo can use RTK + Caveman while a paid subscription combo stays on lite mode. -📖 **Full compression documentation:** [`docs/COMPRESSION_GUIDE.md`](docs/COMPRESSION_GUIDE.md) +> 🪨 **Fun fact:** The standard/caveman mode is inspired by [Caveman](https://github.com/JuliusBrussee/caveman) — the viral project that reports 65% average output-token savings while keeping technical accuracy. OmniRoute takes this further with a **7-option pipeline** and a default `RTK -> Caveman` combo that can reach ~89% average savings on eligible tool/context payloads. + +📖 **Full compression documentation:** [`docs/COMPRESSION_GUIDE.md`](docs/COMPRESSION_GUIDE.md) • [`docs/RTK_COMPRESSION.md`](docs/RTK_COMPRESSION.md) • [`docs/COMPRESSION_ENGINES.md`](docs/COMPRESSION_ENGINES.md) • [`docs/COMPRESSION_RULES_FORMAT.md`](docs/COMPRESSION_RULES_FORMAT.md) • [`docs/COMPRESSION_LANGUAGE_PACKS.md`](docs/COMPRESSION_LANGUAGE_PACKS.md) --- @@ -933,8 +969,8 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video | ---------------------------------------------------------------------------------------------------- | -------------------------------- | | 🧠 **Smart 4-Tier Fallback** — Subscription → API → Cheap → Free | Never stop coding, zero downtime | | 🔄 **Format Translation** — OpenAI ↔ Claude ↔ Gemini ↔ Responses API | Works with ANY CLI tool | -| 🗜️ **Prompt Compression** — 5-mode pipeline (off → lite → standard → aggressive → ultra) | Save 15-75% tokens automatically | -| 🤖 **MCP Server** — 29 tools, 3 transports (stdio/SSE/HTTP), 10 scopes | IDE/agent tool integration | +| 🗜️ **Prompt Compression** — 7 options including Caveman, RTK, and stacked pipelines | Save 15-95% eligible tokens | +| 🤖 **MCP Server** — 37 tools, 3 transports (stdio/SSE/HTTP), 10 scopes | IDE/agent tool integration | | 🛡️ **Resilience Engine** — circuit breakers, cooldowns, TLS spoofing, anti-thundering herd | Auto-recovery from any failure | | 🎵 **10 Multi-Modal APIs** — chat, embed, images, video, music, TTS, STT, moderation, rerank, search | One endpoint for everything | | 🌍 **3-Level Proxy** — global, per-provider, per-key + 1proxy free marketplace | Access AI from any country | @@ -1142,7 +1178,7 @@ curl http://localhost:20128/.well-known/agent.json - [User Guide](docs/USER_GUIDE.md) — Providers, combos, CLI integration - [API Reference](docs/API_REFERENCE.md) — All endpoints with examples -- [MCP Server](open-sse/mcp-server/README.md) — 29 tools, IDE configs +- [MCP Server](open-sse/mcp-server/README.md) — 37 tools, IDE configs - [A2A Server](src/lib/a2a/README.md) — JSON-RPC, skills, streaming - [Environment Config](docs/ENVIRONMENT.md) — Complete `.env` reference - [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) — VM + nginx + Cloudflare @@ -1325,16 +1361,20 @@ See the [Proxy Guide](docs/PROXY_GUIDE.md) for setup instructions. ### 🧠 Features & Architecture -| Document | Description | -| -------------------------------------------------------- | ---------------------------------------------------------------------- | -| [Architecture](docs/ARCHITECTURE.md) | System architecture, data flow, and internals | -| [Compression Guide](docs/COMPRESSION_GUIDE.md) | 5-mode pipeline: off / lite / standard / aggressive / ultra | -| [Resilience Guide](docs/RESILIENCE_GUIDE.md) | Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing | -| [Auto-Combo Engine](docs/AUTO-COMBO.md) | 6-factor scoring, mode packs, self-healing | -| [Proxy Guide](docs/PROXY_GUIDE.md) | 3-level proxy system, 1proxy marketplace, registry CRUD | -| [Free Tiers](docs/FREE_TIERS.md) | 25+ free API providers consolidated directory | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| Document | Description | +| ---------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| [Architecture](docs/ARCHITECTURE.md) | System architecture, data flow, and internals | +| [Compression Guide](docs/COMPRESSION_GUIDE.md) | 7-option pipeline: off / lite / standard / aggressive / ultra / RTK / stacked | +| [RTK Compression](docs/RTK_COMPRESSION.md) | Command-output compression, filters, trust, verify, raw-output recovery | +| [Compression Engines](docs/COMPRESSION_ENGINES.md) | Caveman, RTK, stacked pipelines, dashboard/API/MCP surfaces | +| [Compression Rules Format](docs/COMPRESSION_RULES_FORMAT.md) | JSON rule-pack schemas for Caveman and RTK filters | +| [Compression Language Packs](docs/COMPRESSION_LANGUAGE_PACKS.md) | Language detection and Caveman rule-pack authoring | +| [Resilience Guide](docs/RESILIENCE_GUIDE.md) | Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing | +| [Auto-Combo Engine](docs/AUTO-COMBO.md) | 6-factor scoring, mode packs, self-healing | +| [Proxy Guide](docs/PROXY_GUIDE.md) | 3-level proxy system, 1proxy marketplace, registry CRUD | +| [Free Tiers](docs/FREE_TIERS.md) | 25+ free API providers consolidated directory | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | ### 🤖 Protocols & APIs @@ -1459,10 +1499,12 @@ gh release create v2.0.0 --title "v2.0.0" --generate-notes Special thanks to **[9router](https://github.com/decolua/9router)** by **[decolua](https://github.com/decolua)** — the original project that inspired this fork. OmniRoute builds upon that incredible foundation with additional features, multi-modal APIs, and a full TypeScript rewrite. -Special thanks to **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — the original Go implementation that inspired this JavaScript port. +Special thanks to **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** by **[router-for-me](https://github.com/router-for-me)** — the original Go implementation that inspired this JavaScript port. Special thanks to **[Caveman](https://github.com/JuliusBrussee/caveman)** by **[JuliusBrussee](https://github.com/JuliusBrussee)** (⭐ 51K+) — the viral "why use many token when few token do trick" project whose caveman-speak compression philosophy inspired OmniRoute's standard compression mode and 30+ filler/condensation regex rules. +Special thanks to **[RTK - Rust Token Killer](https://github.com/rtk-ai/rtk)** by **[RTK AI](https://github.com/rtk-ai)** — the high-performance command-output compression project whose terminal, build, test, git, and tool-output filtering model inspired OmniRoute's RTK engine, JSON filter DSL, raw-output recovery, and stacked RTK → Caveman compression pipeline. + --- ## 📄 License diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 3b4c38ec29..dfec7d1198 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -216,14 +216,32 @@ Response example: ### Settings -| Endpoint | Method | Description | -| ------------------------------- | ------------- | ---------------------- | -| `/api/settings` | GET/PUT/PATCH | General settings | -| `/api/settings/proxy` | GET/PUT | Network proxy config | -| `/api/settings/proxy/test` | POST | Test proxy connection | -| `/api/settings/ip-filter` | GET/PUT | IP allowlist/blocklist | -| `/api/settings/thinking-budget` | GET/PUT | Reasoning token budget | -| `/api/settings/system-prompt` | GET/PUT | Global system prompt | +| Endpoint | Method | Description | +| ------------------------------- | ------------- | ------------------------- | +| `/api/settings` | GET/PUT/PATCH | General settings | +| `/api/settings/proxy` | GET/PUT | Network proxy config | +| `/api/settings/proxy/test` | POST | Test proxy connection | +| `/api/settings/ip-filter` | GET/PUT | IP allowlist/blocklist | +| `/api/settings/thinking-budget` | GET/PUT | Reasoning token budget | +| `/api/settings/system-prompt` | GET/PUT | Global system prompt | +| `/api/settings/compression` | GET/PUT | Global compression config | + +### Context & Compression + +| Endpoint | Method | Description | +| -------------------------------------- | -------------- | ------------------------------------------------------------------------ | +| `/api/compression/preview` | POST | Preview off/lite/standard/aggressive/ultra/RTK/stacked compression | +| `/api/compression/language-packs` | GET | List available Caveman language packs | +| `/api/compression/rules` | GET | List Caveman rule metadata | +| `/api/context/caveman/config` | GET/PUT | Caveman-specific settings alias | +| `/api/context/rtk/config` | GET/PUT | RTK-specific settings, including custom filters and raw-output retention | +| `/api/context/rtk/filters` | GET | RTK filter catalog and custom-filter diagnostics | +| `/api/context/rtk/test` | POST | Run RTK preview/test against a text payload | +| `/api/context/rtk/raw-output/[id]` | GET | Read retained redacted raw output by pointer id | +| `/api/context/combos` | GET/POST | Compression combo list/create | +| `/api/context/combos/[id]` | GET/PUT/DELETE | Compression combo detail/update/delete | +| `/api/context/combos/[id]/assignments` | GET/PUT | Assign compression combos to routing combos | +| `/api/context/analytics` | GET | Compression analytics alias | ### Monitoring @@ -451,11 +469,12 @@ Content-Type: application/json 2. Route handler calls `handleChat`, `handleEmbedding`, `handleAudioTranscription`, or `handleImageGeneration` 3. Model is resolved (direct provider/model or alias/combo) 4. Credentials selected from local DB with account availability filtering -5. For chat: `handleChatCore` — format detection, translation, cache check, idempotency check -6. Provider executor sends upstream request -7. Response translated back to client format (chat) or returned as-is (embeddings/images/audio) -8. Usage/logging recorded -9. Fallback applies on errors according to combo rules +5. For chat: `handleChatCore` checks semantic/signature cache and resolves combo compression settings +6. Proactive compression runs before provider translation when enabled (`lite`, Caveman, RTK, or stacked) +7. Provider executor sends upstream request +8. Response translated back to client format (chat) or returned as-is (embeddings/images/audio) +9. Usage, compression analytics, and request logs are recorded +10. Fallback applies on errors according to combo rules Full architecture reference: [`ARCHITECTURE.md`](ARCHITECTURE.md) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 948004b42a..cd08c672d7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,7 +2,7 @@ 🌐 **Languages:** 🇺🇸 [English](ARCHITECTURE.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/ARCHITECTURE.md) | 🇪🇸 [Español](i18n/es/ARCHITECTURE.md) | 🇫🇷 [Français](i18n/fr/ARCHITECTURE.md) | 🇮🇹 [Italiano](i18n/it/ARCHITECTURE.md) | 🇷🇺 [Русский](i18n/ru/ARCHITECTURE.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/ARCHITECTURE.md) | 🇩🇪 [Deutsch](i18n/de/ARCHITECTURE.md) | 🇮🇳 [हिन्दी](i18n/in/ARCHITECTURE.md) | 🇹🇭 [ไทย](i18n/th/ARCHITECTURE.md) | 🇺🇦 [Українська](i18n/uk-UA/ARCHITECTURE.md) | 🇸🇦 [العربية](i18n/ar/ARCHITECTURE.md) | 🇯🇵 [日本語](i18n/ja/ARCHITECTURE.md) | 🇻🇳 [Tiếng Việt](i18n/vi/ARCHITECTURE.md) | 🇧🇬 [Български](i18n/bg/ARCHITECTURE.md) | 🇩🇰 [Dansk](i18n/da/ARCHITECTURE.md) | 🇫🇮 [Suomi](i18n/fi/ARCHITECTURE.md) | 🇮🇱 [עברית](i18n/he/ARCHITECTURE.md) | 🇭🇺 [Magyar](i18n/hu/ARCHITECTURE.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/ARCHITECTURE.md) | 🇰🇷 [한국어](i18n/ko/ARCHITECTURE.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/ARCHITECTURE.md) | 🇳🇱 [Nederlands](i18n/nl/ARCHITECTURE.md) | 🇳🇴 [Norsk](i18n/no/ARCHITECTURE.md) | 🇵🇹 [Português (Portugal)](i18n/pt/ARCHITECTURE.md) | 🇷🇴 [Română](i18n/ro/ARCHITECTURE.md) | 🇵🇱 [Polski](i18n/pl/ARCHITECTURE.md) | 🇸🇰 [Slovenčina](i18n/sk/ARCHITECTURE.md) | 🇸🇪 [Svenska](i18n/sv/ARCHITECTURE.md) | 🇵🇭 [Filipino](i18n/phi/ARCHITECTURE.md) | 🇨🇿 [Čeština](i18n/cs/ARCHITECTURE.md) -_Last updated: 2026-04-15_ +_Last updated: 2026-05-02_ ## Executive Summary @@ -52,12 +52,13 @@ Core capabilities: - Compliance audit logging with opt-out per API key - Eval framework for LLM quality assurance - Health dashboard with real-time provider circuit breaker status -- MCP Server (29 tools) with 3 transports (stdio/SSE/Streamable HTTP) +- MCP Server (37 tools) with 3 transports (stdio/SSE/Streamable HTTP) - A2A Server (JSON-RPC 2.0 + SSE) with skills and task lifecycle - Memory system (extraction, injection, retrieval, summarization) - Skills system (registry, executor, sandbox, built-in skills) - MITM proxy with certificate management and DNS handling - Prompt injection guard middleware +- Prompt compression pipeline with Caveman, RTK, stacked pipelines, compression combos, language packs, and analytics - ACP (Agent Communication Protocol) registry - Modular OAuth providers (13 individual modules under `src/lib/oauth/providers/`) - Uninstall/full-uninstall scripts @@ -112,6 +113,9 @@ Main pages under `src/app/(dashboard)/dashboard/`: - `/dashboard/health` — uptime, circuit breakers, rate limits, quota-monitored sessions - `/dashboard/logs` — request/proxy/audit/console logs - `/dashboard/settings` — system settings tabs (general, routing, combo defaults, etc.) +- `/dashboard/context/caveman` — Caveman compression rules, language packs, preview, and output mode +- `/dashboard/context/rtk` — RTK command-output filters, preview, and runtime safety settings +- `/dashboard/context/combos` — named compression pipelines assigned to routing combos - `/dashboard/api-manager` — API key lifecycle and model permissions ## High-Level System Context @@ -203,6 +207,8 @@ Management domains: - IP filter: `src/app/api/settings/ip-filter` (GET/PUT) - Thinking budget: `src/app/api/settings/thinking-budget` (GET/PUT) - System prompt: `src/app/api/settings/system-prompt` (GET/PUT) +- Compression: `src/app/api/settings/compression`, `src/app/api/compression/*`, and + `src/app/api/context/*` - Sessions: `src/app/api/sessions` (GET) - Rate limits: `src/app/api/rate-limits` (GET) - Resilience: `src/app/api/resilience` (GET/PATCH) — request queue, connection cooldown, provider breaker, wait-for-cooldown config @@ -252,6 +258,8 @@ Services (business logic): - Rate limit management: `open-sse/services/rateLimitManager.ts` - Circuit breaker: `open-sse/services/circuitBreaker.ts` - Context handoff: `open-sse/services/contextHandoff.ts` — handoff summary generation and injection for context-relay strategy +- Compression: `open-sse/services/compression/*` — proactive compression before provider translation; + includes Caveman rules, RTK filters, stacked pipelines, compression combos, stats, and validation - Codex quota fetcher: `open-sse/services/codexQuotaFetcher.ts` — fetches Codex quota for context-relay handoff decisions - Cooldown-aware retry: `src/sse/services/cooldownAwareRetry.ts` — per-model cooldown retries with configurable `requestRetry` / `maxRetryIntervalSec` - Safe outbound fetch: `src/shared/network/safeOutboundFetch.ts` — guarded provider/model fetch with SSRF guard, private-URL blocking, retry, and timeout @@ -630,6 +638,12 @@ flowchart LR - `src/app/api/settings/ip-filter`: IP allowlist/blocklist (GET/PUT) - `src/app/api/settings/thinking-budget`: thinking token budget config (GET/PUT) - `src/app/api/settings/system-prompt`: global system prompt (GET/PUT) +- `src/app/api/settings/compression`: global compression settings (GET/PUT) +- `src/app/api/compression/*`: compression preview, rule metadata, and language packs +- `src/app/api/context/caveman/config`: Caveman settings alias (GET/PUT) +- `src/app/api/context/rtk/*`: RTK config, filter catalog, test endpoint, and raw-output recovery +- `src/app/api/context/combos*`: compression combo CRUD and routing-combo assignments +- `src/app/api/context/analytics`: compression analytics alias - `src/app/api/sessions`: active session listing (GET) - `src/app/api/rate-limits`: per-account rate limit status (GET) - `src/app/api/sync/tokens`: sync token CRUD (GET/POST) diff --git a/docs/COMPRESSION_ENGINES.md b/docs/COMPRESSION_ENGINES.md new file mode 100644 index 0000000000..81dc01c1ba --- /dev/null +++ b/docs/COMPRESSION_ENGINES.md @@ -0,0 +1,143 @@ +# Compression Engines + +OmniRoute compression is built around engine contracts. A mode can run one engine directly +(`caveman` or `rtk`) or a deterministic stacked pipeline that executes multiple engines in order. + +## Modes + +| Mode | Engine path | Intended input | +| ------------ | ---------------------------------- | -------------------------------------------- | +| `off` | none | Exact prompt preservation | +| `lite` | Caveman lite helpers | Low-risk always-on cleanup | +| `standard` | Caveman | Natural-language prompt condensation | +| `aggressive` | Caveman + history/tool summarizers | Long chat sessions | +| `ultra` | Caveman + pruning helpers | Context-limit recovery | +| `rtk` | RTK | Terminal, shell, build, test, and git output | +| `stacked` | Pipeline, default `rtk -> caveman` | Mixed tool logs and prose, max savings | + +## Engine Registry + +The registry lives in `open-sse/services/compression/engines/registry.ts`. Engines expose a shared +contract: + +- `id`: stable engine id such as `caveman` or `rtk` +- `label`: dashboard-readable name +- `supports(mode)`: whether the engine can execute a compression mode +- `compress(input)`: transforms text/messages and returns stats + +`strategySelector.ts` registers the built-in engines before compression runs. This lets preview, +runtime compression, stacked mode, tests, and future engines use the same execution path. + +## Caveman + +Caveman mode focuses on semantic condensation of normal prose: + +- preserves code blocks, URLs, JSON, paths, and structured data +- removes filler, hedging, repeated context, and verbose connective phrasing +- supports language-aware file rule packs in `open-sse/services/compression/rules/` +- remains available through the legacy `standard`, `aggressive`, and `ultra` modes + +The dashboard surface is `Dashboard -> Context & Cache -> Caveman`. + +Caveman upstream reports `~75%` fewer output tokens, `65%` average output savings in benchmarks +with a `22-87%` range, and a `~46%` input-compression tool. OmniRoute uses the Caveman input-side +number when documenting stacked prompt/context savings; Caveman output mode remains a separate +response-behavior feature. + +## RTK + +RTK mode focuses on command and tool output: + +- detects output classes such as `git status`, `git branch`, `git diff`, Vitest/Jest/Pytest, + Cargo/Go tests, TypeScript/Vite/Webpack builds, ESLint, npm audit/installs, Docker logs, + shell `find`/`grep`, stack traces, and generic logs +- applies 49 JSON filters from `open-sse/services/compression/engines/rtk/filters/` +- supports the RTK-style declarative pipeline: ANSI stripping, replace, match-output short-circuit, + strip/keep lines, per-line truncation, head/tail/max-line truncation, and on-empty fallback +- supports trust-gated project filters in `.rtk/filters.json` and global filters in + `DATA_DIR/rtk/filters.json` +- strips ANSI sequences, progress noise, repeated lines, and unhelpful boilerplate +- preserves actionable failures, warnings, summaries, changed files, and tail context +- can optionally retain redacted raw output for recovery/debugging through authenticated management + routes + +The dashboard surface is `Dashboard -> Context & Cache -> RTK`. + +Operational details for custom filters, trust, verify, and raw-output recovery live in +[`RTK_COMPRESSION.md`](RTK_COMPRESSION.md). + +RTK upstream reports `60-90%` savings for command-output compression. Its README example shows a +30-minute Claude Code session going from `~118,000` tokens to `~23,900`, or `79.7%` saved. + +## Stacked Pipelines + +Stacked mode runs pipeline steps in order. The default is: + +```txt +rtk -> caveman +``` + +Use this for coding-agent sessions where a prompt combines command output with human or assistant +prose. RTK reduces noisy tool logs first, then Caveman compresses remaining natural language. + +Pipeline steps are configured with `stackedPipeline` in compression settings or through compression +combos. + +When both engines reduce the same eligible payload, savings compound: + +```txt +combined = 1 - (1 - RTK savings) * (1 - Caveman input savings) +average = 1 - (1 - 0.80) * (1 - 0.46) = 89.2% +range = 1 - (1 - 0.60..0.90) * (1 - 0.46) = 78.4-94.6% +``` + +## Compression Combos + +Compression combos are named compression profiles that can be assigned to routing combos: + +- `compression_combos`: stores mode, pipeline, RTK config, language config, and default marker +- `compression_combo_assignments`: maps a compression combo to a routing combo +- runtime integration resolves an assigned compression combo before generic combo overrides +- analytics include `compression_combo_id` and `engine` + +Dashboard surface: `Dashboard -> Context & Cache -> Compression Combos`. + +## API Surface + +| Route | Purpose | +| -------------------------------------- | ------------------------------------------ | +| `/api/settings/compression` | Global compression settings | +| `/api/compression/preview` | Preview any compression mode | +| `/api/compression/language-packs` | List available Caveman language packs | +| `/api/context/caveman/config` | Caveman settings alias | +| `/api/context/rtk/config` | RTK defaults and settings | +| `/api/context/rtk/filters` | RTK filter catalog | +| `/api/context/rtk/test` | RTK preview/test endpoint | +| `/api/context/rtk/raw-output/[id]` | Authenticated redacted raw-output recovery | +| `/api/context/combos` | Compression combo CRUD | +| `/api/context/combos/[id]/assignments` | Routing-combo assignment CRUD | +| `/api/context/analytics` | Compression analytics alias | + +Management routes require management authentication or API-key policy checks. + +## MCP Tools + +Compression exposes five MCP tools: + +| Tool | Scope | Purpose | +| ----------------------------------- | ------------------- | -------------------------------- | +| `omniroute_compression_status` | `read:compression` | Settings, analytics, cache stats | +| `omniroute_compression_configure` | `write:compression` | Update global settings | +| `omniroute_set_compression_engine` | `write:compression` | Set mode and optional pipeline | +| `omniroute_list_compression_combos` | `read:compression` | List compression combos | +| `omniroute_compression_combo_stats` | `read:compression` | Read combo/engine analytics | + +## Validation + +The focused gates for this area are: + +```bash +node --import tsx/esm --test tests/unit/compression/rtk-*.test.ts tests/unit/compression/pipeline-integration.test.ts tests/unit/compression/context-compression-api.test.ts +node --import tsx/esm --test tests/unit/compression/*.test.ts tests/golden-set/*.test.ts tests/integration/compression-pipeline.test.ts tests/unit/api/compression/compression-api.test.ts +npm run typecheck:core +``` diff --git a/docs/COMPRESSION_GUIDE.md b/docs/COMPRESSION_GUIDE.md index a6db971396..88213bfc63 100644 --- a/docs/COMPRESSION_GUIDE.md +++ b/docs/COMPRESSION_GUIDE.md @@ -1,6 +1,6 @@ # 🗜️ Prompt Compression Guide — OmniRoute -> Save 15-75% on token costs automatically. For a quick overview, see the [README Compression section](../README.md#%EF%B8%8F-prompt-compression--save-15-75-tokens-automatically). +> Save 15-95% on eligible context automatically. For a quick overview, see the [README Compression section](../README.md#%EF%B8%8F-prompt-compression--save-15-95-eligible-tokens-automatically). ## Overview @@ -19,6 +19,8 @@ Client Request → Standard: Caveman-speak filler removal (~30%) → Aggressive: History aging + summarization (~50%) → Ultra: Heuristic pruning + code-block thinning (~75%) + → RTK: Command-aware terminal/tool-output filtering (60-90% upstream range) + → Stacked: Ordered multi-engine pipeline, usually RTK then Caveman (78-95% eligible range) → Compressed Request → Provider ``` @@ -77,6 +79,65 @@ Maximum compression for token-critical scenarios: **Best for:** When you're hitting context limits repeatedly. +### RTK Mode (60-90% upstream range) + +RTK mode is optimized for verbose tool outputs that appear in coding-agent sessions: + +- Detects command/output classes such as `git status`, `git diff`, `git log`, test runners, + TypeScript/Vite/Webpack builds, ESLint/Biome/Prettier, npm audit/installs, Docker logs, infra + output, and generic shell output +- Applies JSON filter packs from `open-sse/services/compression/engines/rtk/filters/` +- Ships 49 built-in filters with inline verify samples +- Removes ANSI control sequences, progress bars, repeated lines, and non-actionable noise +- Preserves failures, errors, warnings, changed files, summaries, and the tail of long output +- Supports trust-gated project filters, global filters, and optional redacted raw-output recovery + +**Best for:** Agent sessions with shell, build, test, git, grep, and file-output transcripts. + +### Stacked Mode (78-95% eligible range) + +Stacked mode runs multiple compression engines in a deterministic order. The default pipeline is: + +```txt +RTK -> Caveman +``` + +That order keeps terminal/tool output compact first, then applies Caveman semantic condensation to +the remaining natural-language prompt. Stacked pipelines can be configured globally or through +compression combos assigned to routing combos. + +**Best for:** Mixed context with large tool logs plus human instructions or assistant summaries. + +--- + +## Upstream Savings Math + +OmniRoute documents compression savings from two sources: upstream project benchmarks and +OmniRoute's own engine composition. + +| Source | Upstream README number used here | +| ------- | --------------------------------------------------------------------------------------------------------------------- | +| Caveman | `~75%` fewer output tokens, `65%` benchmark average output savings, `22-87%` range, and `~46%` input compression tool | +| RTK | `60-90%` command-output savings; sample session `~118,000 -> ~23,900` tokens, or `79.7%` saved (`~80%`) | + +For overlapping tool/context payloads, the default OmniRoute combo stacks the engines: + +```txt +RTK -> Caveman +``` + +The combined savings are multiplicative, not additive: + +```txt +combined = 1 - (1 - RTK savings) * (1 - Caveman input savings) +average = 1 - (1 - 0.80) * (1 - 0.46) = 89.2% +range = 1 - (1 - 0.60..0.90) * (1 - 0.46) = 78.4-94.6% +``` + +That `78-95%` number applies when both RTK and Caveman can reduce the same input/context payload. +Caveman response output mode is separate: when enabled, use Caveman's own output savings (`65%` +average, `~75%` headline, `22-87%` range). Total billing savings depend on your prompt/output mix. + --- ## Token Savings Visualization @@ -87,6 +148,8 @@ With Lite: 40K tokens sent (15% saved — safe, always-on) With Standard: 33K tokens sent (30% saved — caveman-speak rules) With Aggressive: 24K tokens sent (50% saved — aging + summarization) With Ultra: 12K tokens sent (75% saved — heuristic pruning) +With RTK: 19K-5K tokens sent (60-90% saved on command/tool output) +With Stacked: 10K-2.5K tokens sent (78-95% eligible RTK+Caveman range) ``` --- @@ -95,25 +158,29 @@ With Ultra: 12K tokens sent (75% saved — heuristic pruning) ### Dashboard -Navigate to `Dashboard → Settings → Compression`: +Navigate to `Dashboard → Context & Cache`: -- **Default Mode** — sets the system-wide compression mode +- **Caveman** — mode selection, language packs, preview, and global defaults +- **RTK** — command-filter preview, RTK safety settings, and filter catalog +- **Compression Combos** — named engine pipelines assigned to routing combos - **Auto-Trigger Threshold** — automatically engage compression when token count exceeds threshold -- **Per-Combo Override** — each combo can have its own compression mode ### Per-Combo Override -In `Dashboard → Combos → [Your Combo] → Advanced`, set compression mode per combo: +In `Dashboard → Context & Cache → Compression Combos`, assign a compression combo to a routing +combo: ```txt Combo: "free-forever" - Mode: Standard + Compression Combo: "coding-agent-stack" + Pipeline: RTK -> Caveman Targets: 1. gc/gemini-3-flash 2. if/kimi-k2-thinking ``` -This lets you use aggressive compression on free providers while keeping lite mode on paid subscriptions. +This lets you use stacked compression on free/coding providers while keeping lite mode on paid +subscriptions. ### API @@ -124,7 +191,20 @@ curl http://localhost:20128/api/settings/compression # Update compression settings curl -X PUT http://localhost:20128/api/settings/compression \ -H "Content-Type: application/json" \ - -d '{"defaultMode":"lite","autoTriggerThreshold":32000}' + -d '{"defaultMode":"stacked","autoTriggerMode":"stacked","autoTriggerTokens":32000}' + +# Preview a specific RTK/stacked payload +curl -X POST http://localhost:20128/api/compression/preview \ + -H "Content-Type: application/json" \ + -d '{"mode":"rtk","messages":[{"role":"tool","content":"npm test output here"}]}' + +# List RTK filter packs +curl http://localhost:20128/api/context/rtk/filters + +# Test RTK directly with optional command metadata +curl -X POST http://localhost:20128/api/context/rtk/test \ + -H "Content-Type: application/json" \ + -d '{"command":"npm test","text":"FAIL tests/example.test.ts\nError: boom"}' ``` --- @@ -136,11 +216,14 @@ The compression engine **always preserves:** - ✅ Code blocks (fenced and inline) - ✅ URLs and file paths - ✅ JSON structures and structured data -- ✅ API keys, tokens, and identifiers +- ✅ Identifiers and protected technical tokens - ✅ Mathematical expressions - ✅ Tool/function call definitions - ✅ System prompts (in lite mode) +RTK raw-output recovery redacts common API keys, bearer tokens, Slack tokens, AWS access keys, +passwords, tokens, and secrets before anything is persisted. + --- ## Compression Stats @@ -154,7 +237,10 @@ Every compressed request includes stats in the server logs: "savingsPercent": 15.0, "techniquesUsed": ["collapseWhitespace", "dedupSystemPrompt"], "mode": "lite", - "latencyMs": 0.8 + "engine": "caveman", + "compressionComboId": "coding-agent-stack", + "durationMs": 0.8, + "rtkRawOutputPointers": [] } ``` @@ -166,13 +252,16 @@ Every compressed request includes stats in the server logs: | ------- | ------------------------------------ | ---------- | | Phase 1 | Off, Lite | ✅ Shipped | | Phase 2 | Standard, Aggressive, Ultra | ✅ Shipped | -| Phase 3 | Per-model adaptive, ML-based pruning | 🗓️ Planned | +| Phase 3 | RTK, Stacked, Compression Combos | ✅ Shipped | +| Phase 4 | Per-model adaptive, ML-based pruning | 🗓️ Planned | --- ## Acknowledgments -Standard mode compression rules are inspired by **[Caveman](https://github.com/JuliusBrussee/caveman)** by **[JuliusBrussee](https://github.com/JuliusBrussee)** (⭐ 51K+) — the viral "why use many token when few token do trick" project. +Standard mode compression rules are inspired by **[Caveman](https://github.com/JuliusBrussee/caveman)** by **[JuliusBrussee](https://github.com/JuliusBrussee)** (⭐ 51K+) — the viral "why use many token when few token do trick" project. Caveman reports `~75%` fewer output tokens, `65%` benchmark average output savings, a `22-87%` output range, and a `~46%` input-compression tool. + +RTK mode is inspired by **[RTK - Rust Token Killer](https://github.com/rtk-ai/rtk)** by **[RTK AI](https://github.com/rtk-ai)** — the high-performance command-output compression project for terminal, build, test, git, and tool-output filtering. RTK reports `60-90%` savings, with its README sample session showing `~80%` saved. --- @@ -181,3 +270,7 @@ Standard mode compression rules are inspired by **[Caveman](https://github.com/J - [Environment Config](ENVIRONMENT.md) — Compression environment variables - [Architecture Guide](ARCHITECTURE.md) — Compression pipeline internals - [User Guide](USER_GUIDE.md) — Getting started with compression +- [RTK Compression](RTK_COMPRESSION.md) — RTK filters, trust model, verify gate, raw-output recovery +- [Compression Engines](COMPRESSION_ENGINES.md) — Caveman, RTK, stacked, APIs, MCP, dashboard +- [Compression Rules Format](COMPRESSION_RULES_FORMAT.md) — JSON rule-pack format +- [Compression Language Packs](COMPRESSION_LANGUAGE_PACKS.md) — Language-specific Caveman rules diff --git a/docs/COMPRESSION_LANGUAGE_PACKS.md b/docs/COMPRESSION_LANGUAGE_PACKS.md new file mode 100644 index 0000000000..805a7db1fc --- /dev/null +++ b/docs/COMPRESSION_LANGUAGE_PACKS.md @@ -0,0 +1,96 @@ +# Compression Language Packs + +Caveman compression can load language-specific rule packs in addition to the built-in English rules. +This keeps the core engine stable while allowing Portuguese, Spanish, German, French, Japanese, and +future language packs to evolve independently. + +## Location + +Language packs live under: + +```txt +open-sse/services/compression/rules// +``` + +Current shipped packs include: + +| Language | Directory | +| ------------------- | -------------- | +| English | `rules/en/` | +| Portuguese (Brazil) | `rules/pt-BR/` | +| Spanish | `rules/es/` | +| German | `rules/de/` | +| French | `rules/fr/` | +| Japanese | `rules/ja/` | + +## Language Detection + +`languageDetector.ts` uses lightweight heuristics to infer the language from prompt text. The +configured default language is still respected, and detection can be disabled by config when exact +control is required. + +Detection output is used only to choose rule packs. It does not change provider routing, locale +selection, or UI language. + +## Config Shape + +Compression settings can include: + +```json +{ + "languageConfig": { + "enabled": true, + "defaultLanguage": "en", + "autoDetect": true, + "enabledPacks": ["en", "pt-BR", "es", "de", "fr", "ja"] + }, + "cavemanConfig": { + "language": "en", + "autoDetectLanguage": true, + "enabledLanguagePacks": ["en", "pt-BR", "es", "de", "fr", "ja"] + } +} +``` + +`languageConfig` controls dashboard/preview defaults. `cavemanConfig` is the runtime engine config +used when Caveman compresses message text. + +## Adding a Language Pack + +1. Create `open-sse/services/compression/rules//.json`. +2. Use the Caveman rule format from `docs/COMPRESSION_RULES_FORMAT.md`. +3. Keep replacements conservative and avoid changing code, identifiers, URLs, or JSON. +4. Add or update tests for language selection and replacement behavior. +5. Expose new dashboard/i18n labels if the language appears in UI selectors. + +## API + +Available packs can be queried with: + +```bash +curl http://localhost:20128/api/compression/language-packs +``` + +The preview endpoint accepts language config overrides: + +```bash +curl -X POST http://localhost:20128/api/compression/preview \ + -H "Content-Type: application/json" \ + -d '{ + "mode": "standard", + "text": "Por favor, eu gostaria que voce basicamente resumisse isso.", + "config": { + "languageConfig": { + "defaultLanguage": "pt-BR", + "autoDetect": true + } + } + }' +``` + +## Operational Notes + +- English built-in rules remain the fallback when a language pack is missing. +- Invalid built-in JSON packs fail validation so release assets do not silently degrade. +- Rule packs are data-only and should not import code or run arbitrary logic. +- The compression analytics layer records the selected mode and engine, not full prompt text. diff --git a/docs/COMPRESSION_RULES_FORMAT.md b/docs/COMPRESSION_RULES_FORMAT.md new file mode 100644 index 0000000000..e54343968b --- /dev/null +++ b/docs/COMPRESSION_RULES_FORMAT.md @@ -0,0 +1,186 @@ +# Compression Rules Format + +Compression rules are JSON files loaded at runtime. They are intentionally data-only so new +language packs and RTK command filters can be reviewed without changing engine code. + +## Caveman Rule Packs + +Caveman rule packs live under: + +```txt +open-sse/services/compression/rules//.json +``` + +Each pack contains replacements that apply to normal prose after protected regions are isolated. + +```json +{ + "language": "en", + "category": "filler", + "rules": [ + { + "name": "question_to_directive", + "pattern": "\\b(?:Can you explain why|Could you show me how)\\b\\s*", + "replacement": "Explain why ", + "replacementMap": { + "can you explain why": "Explain why ", + "could you show me how": "Show how " + }, + "flags": "gi", + "context": "all", + "category": "context", + "minIntensity": "lite", + "description": "Convert verbose questions into direct requests." + } + ] +} +``` + +### Caveman Fields + +| Field | Required | Description | +| ------------------------ | -------- | ---------------------------------------------------------------- | +| `language` | yes | BCP-47-like language key such as `en`, `pt-BR`, `es` | +| `category` | yes | Pack category filename/category, for example `filler` or `dedup` | +| `rules` | yes | Array of regex replacement rules | +| `rules[].name` | yes | Stable rule name | +| `rules[].pattern` | yes | JavaScript regex source | +| `rules[].flags` | no | JavaScript regex flags; default `gi` | +| `rules[].replacement` | no | Replacement string or fallback when `replacementMap` misses | +| `rules[].replacementMap` | no | Match-specific replacements keyed by normalized matched text | +| `rules[].context` | no | `all`, `user`, `assistant`, or `system`; default `all` | +| `rules[].category` | no | `filler`, `context`, `structural`, `dedup`, `terse`, or `ultra` | +| `rules[].minIntensity` | no | `lite`, `full`, or `ultra`; default `lite` | +| `rules[].description` | no | Human-readable rule summary | + +Use `flags` when case-sensitive matching matters, for example article removal before lowercase prose +without stripping `the OpenAI API`. Use `replacementMap` when one regex has multiple alternatives +that need different outputs; this keeps JSON rule packs data-only while preserving the behavior of +the richer built-in TypeScript replacement functions. + +## RTK Filter Packs + +RTK filters live under: + +```txt +open-sse/services/compression/engines/rtk/filters/.json +``` + +Each filter describes how to recognize and compress a command-output family. + +```json +{ + "id": "test-vitest", + "label": "Vitest output", + "category": "test", + "priority": 92, + "match": { + "outputTypes": ["test-vitest"], + "commands": ["vitest", "npm test", "npm run test"], + "patterns": ["\\bFAIL\\b", "\\bPASS\\b", "\\bTest Files\\b"] + }, + "rules": { + "stripAnsi": true, + "replace": [{ "pattern": "\\s+\\[[0-9]+ms\\]", "replacement": "" }], + "matchOutput": [ + { "pattern": "All tests passed", "message": "vitest: ok", "unless": "FAIL|Error:" } + ], + "includePatterns": ["FAIL", "Error:", "Test Files", "Tests"], + "dropPatterns": ["^\\s*$", "Duration\\s+\\d+"], + "collapsePatterns": ["^\\s+at "], + "deduplicate": true, + "truncateLineAt": 240, + "maxLines": 160, + "headLines": 24, + "tailLines": 40, + "onEmpty": "vitest: ok", + "filterStderr": false + }, + "preserve": { + "errorPatterns": ["FAIL", "Error:", "AssertionError"], + "summaryPatterns": ["Test Files", "Tests", "Snapshots"] + }, + "tests": [ + { + "name": "keeps failing tests", + "command": "vitest", + "input": "FAIL test/a.test.ts\\nError: boom\\nTest Files 1 failed", + "expected": "FAIL test/a.test.ts\\nError: boom\\nTest Files 1 failed" + } + ] +} +``` + +### RTK Fields + +| Field | Required | Description | +| -------------------------- | -------- | ------------------------------------------------------------------------------ | +| `id` | yes | Stable filter id | +| `label` | yes | Dashboard-readable name | +| `category` | yes | Filter family: git, test, build, shell, docker, package, infra, cloud, generic | +| `priority` | no | Higher priority wins when multiple filters match | +| `match.outputTypes` | no | Detector output ids that select this filter | +| `match.commands` | no | Command tokens that select this filter | +| `match.patterns` | no | Regex patterns that select this filter from output text | +| `rules.stripAnsi` | no | Remove ANSI escape sequences before regex stages | +| `rules.replace` | no | Ordered regex substitutions applied line by line | +| `rules.matchOutput` | no | Short-circuit output rules with optional `unless` guard | +| `rules.includePatterns` | no | Lines to prefer preserving | +| `rules.dropPatterns` | no | Lines to remove as noise | +| `rules.collapsePatterns` | no | Repeated matching lines that can be collapsed | +| `rules.deduplicate` | no | Collapse duplicate normalized lines | +| `rules.truncateLineAt` | no | Unicode-safe per-line character limit | +| `rules.maxLines` | no | Maximum retained lines before tail preservation | +| `rules.headLines` | no | Head lines retained during truncation | +| `rules.tailLines` | no | Tail lines retained for recent context | +| `rules.onEmpty` | no | Fallback message when filtering removes all content | +| `rules.filterStderr` | no | Normalize common stderr prefixes before later filtering stages | +| `preserve.errorPatterns` | no | Error lines that should survive truncation | +| `preserve.summaryPatterns` | no | Summary lines that should survive truncation | +| `tests[]` | no | Inline verification samples used by the RTK verify gate | + +RTK applies declarative stages in this order: `stripAnsi`, `filterStderr`, `replace`, +`matchOutput`, `dropPatterns`/`includePatterns`, `truncateLineAt`, `headLines`/`tailLines`, +`maxLines`, and `onEmpty`. + +Custom filters can be loaded from: + +1. Project `.rtk/filters.json` files only after a matching `.rtk/trust.json` hash is present or + `trustProjectFilters` is enabled. +2. Global `DATA_DIR/rtk/filters.json`. +3. Built-in filters. + +Project/global custom files may contain one filter object or an array of filter objects. Invalid +custom filters are skipped with diagnostics; invalid built-in filters fail validation. + +Project trust file: + +```json +{ + "filtersSha256": "0123456789abcdef..." +} +``` + +The environment override `OMNIROUTE_RTK_TRUST_PROJECT_FILTERS=1` trusts project filters without a +hash and should be limited to controlled local development. + +## Safety Rules + +- Keep rules idempotent: running the same filter twice should not corrupt output. +- Preserve exact error text, file paths, line numbers, and command summaries where possible. +- Avoid rules that modify code blocks, JSON payloads, URLs, or secrets. +- Add unit coverage for new command families in detector/filter tests. +- Add `tests[]` samples to every built-in filter and to shared custom filters. + +## Validation + +Rule packs are validated before use. Built-in Caveman packs and built-in RTK filters fail fast +during validation so broken release assets are caught before shipment. Custom RTK filters are +skipped with diagnostics when parsing or trust validation fails. + +Focused validation: + +```bash +node --import tsx/esm --test tests/unit/compression/rule-loader.test.ts tests/unit/compression/language-packs.test.ts +node --import tsx/esm --test tests/unit/compression/rtk-verify.test.ts tests/unit/compression/rtk-dsl-pipeline.test.ts +``` diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 02e7c2843b..066d800705 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -497,6 +497,12 @@ The logging system writes to both stdout and rotated log files. All configuratio | `CONTEXT_LENGTH_DEFAULT` | `128000` | Global fallback max context length for models without explicit config. | | `USAGE_TOKEN_BUFFER` | `100` | Extra token headroom reserved when tracking usage quotas. | +### Compression + +| Variable | Default | Description | +| ------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------- | +| `OMNIROUTE_RTK_TRUST_PROJECT_FILTERS` | unset | Trust project `.rtk/filters.json` without a `.rtk/trust.json` hash. Use only in controlled local development. | + ### Low-RAM Docker Example ```bash diff --git a/docs/FEATURES.md b/docs/FEATURES.md index d68c6a6db9..4c8e9e455f 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -1,6 +1,6 @@ # OmniRoute — Dashboard Features Gallery -🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md) | 🇨🇿 [Čeština](i18n/cs/FEATURES.md) +🌐 **Main README translations:** 🇺🇸 [English](../README.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/README.md) | 🇪🇸 [Español](i18n/es/README.md) | 🇫🇷 [Français](i18n/fr/README.md) | 🇮🇹 [Italiano](i18n/it/README.md) | 🇷🇺 [Русский](i18n/ru/README.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/README.md) | 🇩🇪 [Deutsch](i18n/de/README.md) | 🇮🇳 [हिन्दी](i18n/in/README.md) | 🇹🇭 [ไทย](i18n/th/README.md) | 🇺🇦 [Українська](i18n/uk-UA/README.md) | 🇸🇦 [العربية](i18n/ar/README.md) | 🇯🇵 [日本語](i18n/ja/README.md) | 🇻🇳 [Tiếng Việt](i18n/vi/README.md) | 🇧🇬 [Български](i18n/bg/README.md) | 🇩🇰 [Dansk](i18n/da/README.md) | 🇫🇮 [Suomi](i18n/fi/README.md) | 🇮🇱 [עברית](i18n/he/README.md) | 🇭🇺 [Magyar](i18n/hu/README.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/README.md) | 🇰🇷 [한국어](i18n/ko/README.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/README.md) | 🇳🇱 [Nederlands](i18n/nl/README.md) | 🇳🇴 [Norsk](i18n/no/README.md) | 🇵🇹 [Português (Portugal)](i18n/pt/README.md) | 🇷🇴 [Română](i18n/ro/README.md) | 🇵🇱 [Polski](i18n/pl/README.md) | 🇸🇰 [Slovenčina](i18n/sk/README.md) | 🇸🇪 [Svenska](i18n/sv/README.md) | 🇵🇭 [Filipino](i18n/phi/README.md) | 🇨🇿 [Čeština](i18n/cs/README.md) Visual guide to every section of the OmniRoute dashboard. @@ -109,7 +109,21 @@ Configurable via combo-level or global settings: - **Max Messages For Summary** — How much recent history to condense - **Summary Model** — Optional override model for generating the handoff summary -Currently supports Codex account rotation. See [Context Relay documentation](features/context-relay.md). +Currently supports Codex account rotation. See [Context Relay documentation](ARCHITECTURE.md). + +--- + +## 🗜️ Prompt Compression _(v3.7.9+)_ + +Context & Cache now exposes dedicated pages for Caveman, RTK, and Compression Combos: + +- **Caveman** — language-aware rule packs, preview, output-mode controls, and analytics +- **RTK** — command-aware compression for shell, git, test, build, package, Docker, infra, JSON, and stack-trace output +- **Compression Combos** — named pipelines such as `rtk -> caveman` assigned to routing combos; the default stacked math reaches `~89%` average and `78-95%` eligible-context savings when both engines apply +- **Raw-output recovery** — optional redacted RTK raw-output pointers for debugging compressed failures + +See [Compression Guide](COMPRESSION_GUIDE.md), [RTK Compression](RTK_COMPRESSION.md), and +[Compression Engines](COMPRESSION_ENGINES.md). --- diff --git a/docs/MCP-SERVER.md b/docs/MCP-SERVER.md index 2f8e6bab3f..4312fb0f12 100644 --- a/docs/MCP-SERVER.md +++ b/docs/MCP-SERVER.md @@ -1,6 +1,6 @@ # OmniRoute MCP Server Documentation -> Model Context Protocol server with 16 intelligent tools +> Model Context Protocol server with 37 tools across routing, cache, compression, memory, skills, and proxy operations ## Installation @@ -19,7 +19,8 @@ omniroute --dev # MCP auto-starts on /mcp endpoint ## IDE Configuration -See [IDE Configs](integrations/ide-configs.md) for Antigravity, Cursor, Copilot, and Claude Desktop setup. +See [MCP Client Configuration](SETUP_GUIDE.md#mcp-client-configuration) for Claude Desktop, +Cursor, Cline, and compatible MCP client setup. --- @@ -49,20 +50,55 @@ See [IDE Configs](integrations/ide-configs.md) for Antigravity, Cursor, Copilot, | `omniroute_explain_route` | Explain a past routing decision | | `omniroute_get_session_snapshot` | Full session state: costs, tokens, errors | +## Cache Tools (2) + +| Tool | Description | +| :---------------------- | :-------------------------------------------------- | +| `omniroute_cache_stats` | Semantic cache, prompt-cache, and idempotency stats | +| `omniroute_cache_flush` | Flush cache globally or by signature/model | + +## Compression Tools (5) + +| Tool | Description | +| :---------------------------------- | :------------------------------------------------------------- | +| `omniroute_compression_status` | Compression settings, analytics summary, and cache-aware stats | +| `omniroute_compression_configure` | Configure compression mode, threshold, and runtime options | +| `omniroute_set_compression_engine` | Set Caveman, RTK, or stacked compression mode and pipeline | +| `omniroute_list_compression_combos` | List named compression combos and routing assignments | +| `omniroute_compression_combo_stats` | Analytics grouped by compression combo and engine | + +`omniroute_compression_status` reports MCP description compression separately under +`analytics.mcpDescriptionCompression`. Those values are metadata-size estimates for MCP listable +descriptions (`tools`, `prompts`, `resources`, and `resourceTemplates`); they are not provider usage +receipts and are marked with `source: "mcp_metadata_estimate"`. + +See [Compression Engines](COMPRESSION_ENGINES.md) and [RTK Compression](RTK_COMPRESSION.md) for +the runtime compression model behind these tools. + +## Other Tool Groups + +The remaining MCP surface includes 1proxy tools, memory tools, and skill tools. The live source of +truth is `open-sse/mcp-server/tools/` and `open-sse/mcp-server/schemas/tools.ts`. + ## Authentication MCP tools are authenticated via API key scopes. Each tool requires specific scopes: -| Scope | Tools | -| :------------- | :----------------------------------------------- | -| `read:health` | get_health, get_provider_metrics | -| `read:combos` | list_combos, get_combo_metrics | -| `write:combos` | switch_combo | -| `read:quota` | check_quota | -| `write:route` | route_request, simulate_route, test_combo | -| `read:usage` | cost_report, get_session_snapshot, explain_route | -| `write:config` | set_budget_guard, set_resilience_profile | -| `read:models` | list_models_catalog, best_combo_for_task | +| Scope | Tools | +| :-------------------- | :------------------------------------------------------------------- | +| `read:health` | get_health, get_provider_metrics | +| `read:combos` | list_combos, get_combo_metrics | +| `write:combos` | switch_combo | +| `read:quota` | check_quota | +| `write:route` | route_request, simulate_route, test_combo | +| `read:usage` | cost_report, get_session_snapshot, explain_route | +| `write:config` | set_budget_guard, set_resilience_profile | +| `read:models` | list_models_catalog, best_combo_for_task | +| `read:cache` | cache_stats | +| `write:cache` | cache_flush | +| `read:compression` | compression_status, list_compression_combos, compression_combo_stats | +| `write:compression` | compression_configure, set_compression_engine | +| `execute:completions` | route_request, test_combo | ## Audit Logging @@ -74,10 +110,10 @@ Every tool call is logged to `mcp_tool_audit` with: ## Files -| File | Purpose | -| :------------------------------------------- | :------------------------------------------ | -| `open-sse/mcp-server/server.ts` | MCP server creation + 16 tool registrations | -| `open-sse/mcp-server/transport.ts` | Stdio + HTTP transport | -| `open-sse/mcp-server/auth.ts` | API key + scope validation | -| `open-sse/mcp-server/audit.ts` | Tool call audit logging | -| `open-sse/mcp-server/tools/advancedTools.ts` | 8 advanced tool handlers | +| File | Purpose | +| :------------------------------------------- | :------------------------------------------------ | +| `open-sse/mcp-server/server.ts` | MCP server creation and scoped tool registrations | +| `open-sse/mcp-server/transport.ts` | Stdio + HTTP transport | +| `open-sse/mcp-server/auth.ts` | API key + scope validation | +| `open-sse/mcp-server/audit.ts` | Tool call audit logging | +| `open-sse/mcp-server/tools/advancedTools.ts` | 8 advanced tool handlers | diff --git a/docs/RTK_COMPRESSION.md b/docs/RTK_COMPRESSION.md new file mode 100644 index 0000000000..f675c93d25 --- /dev/null +++ b/docs/RTK_COMPRESSION.md @@ -0,0 +1,234 @@ +# RTK Compression + +RTK compression is OmniRoute's command-aware compression engine for terminal and tool output. It is +designed for coding-agent sessions where most context growth comes from test logs, build output, +package manager noise, shell transcripts, Docker output, git output, and stack traces. + +RTK can run directly with `defaultMode: "rtk"` or as the first step in a stacked pipeline, usually: + +```txt +rtk -> caveman +``` + +That order compresses noisy machine output first, then lets Caveman condense remaining prose. + +Upstream RTK reports `60-90%` command-output savings. Its README sample session goes from +`~118,000` standard tokens to `~23,900` RTK tokens, which is `79.7%` saved (`~80%`). OmniRoute uses +that upstream average for the stacked savings calculation with Caveman input compression: + +```txt +RTK average: 80% saved +Caveman input: 46% saved +Stacked: 1 - (1 - 0.80) * (1 - 0.46) = 89.2% saved +Range: 1 - (1 - 0.60..0.90) * (1 - 0.46) = 78.4-94.6% +``` + +## What It Compresses + +The built-in catalog currently ships 49 filters across these categories: + +| Category | Examples | +| --------- | ------------------------------------------------------------- | +| `git` | `git status`, `git branch`, `git diff`, `git log` | +| `test` | Vitest, Jest, Pytest, Playwright, Go tests, Cargo tests | +| `build` | TypeScript, ESLint, Biome, Prettier, Vite, Webpack, Turbo, Nx | +| `package` | `npm install`, `npm audit`, `pip`, `uv sync`, Poetry, Bundler | +| `shell` | `ls`, `find`, `grep`, generic shell logs | +| `docker` | `docker ps`, Docker logs | +| `infra` | Terraform, OpenTofu, `systemctl status` | +| `generic` | JSON output, stack traces, generic output fallback | + +The detector in `open-sse/services/compression/engines/rtk/commandDetector.ts` classifies output +before filter selection. Filters can also match by command pattern or output regex when a command +class is not enough. + +## Filter Resolution + +RTK loads filters in this order: + +1. Project filters from `.rtk/filters.json`, only when trusted. +2. Global filters from `DATA_DIR/rtk/filters.json`. +3. Built-in filters from `open-sse/services/compression/engines/rtk/filters/`. + +Project filters are intentionally trust-gated because regex filters can change how tool output is +shown to agents. A project filter file is accepted when one of these is true: + +- `rtkConfig.trustProjectFilters` is `true`. +- `OMNIROUTE_RTK_TRUST_PROJECT_FILTERS=1` is set. +- `.rtk/trust.json` contains the SHA-256 hash of `.rtk/filters.json`. + +Trust file example: + +```json +{ + "filtersSha256": "0123456789abcdef..." +} +``` + +Custom filters can be one filter object or an array of filter objects. Invalid custom filters are +skipped and reported by `/api/context/rtk/filters` diagnostics. Invalid built-in filters fail fast. + +## Filter DSL + +Filters use the JSON schema described in [Compression Rules Format](COMPRESSION_RULES_FORMAT.md). +The runtime applies these stages in order: + +```txt +stripAnsi -> filterStderr -> replace -> matchOutput -> drop/include lines + -> truncateLineAt -> head/tail/maxLines -> onEmpty +``` + +Important fields: + +| Field | Purpose | +| ---------------------------- | -------------------------------------------------------------- | +| `rules.stripAnsi` | Remove terminal color/control sequences before matching | +| `rules.filterStderr` | Normalize common stderr prefixes before matching/filtering | +| `rules.replace` | Apply ordered regex replacements | +| `rules.matchOutput` | Return a compact summary when output matches a known condition | +| `rules.matchOutput[].unless` | Skip the shortcut when an error/failure pattern is present | +| `rules.dropPatterns` | Remove noisy lines | +| `rules.includePatterns` | Prefer actionable lines | +| `rules.collapsePatterns` | Collapse repeated matching lines | +| `rules.truncateLineAt` | Unicode-safe per-line truncation | +| `rules.onEmpty` | Fallback message if all lines are filtered out | +| `tests[]` | Inline samples used by the verify gate | + +Built-in filters are expected to include inline `tests[]` samples. Custom filters should include +them too, especially when they are shared across projects. + +## Configuration + +Global settings are available through `/api/settings/compression`. RTK-specific settings are also +available through `/api/context/rtk/config`. + +```json +{ + "defaultMode": "stacked", + "autoTriggerMode": "stacked", + "autoTriggerTokens": 32000, + "stackedPipeline": [ + { "engine": "rtk", "intensity": "standard" }, + { "engine": "caveman", "intensity": "full" } + ], + "rtkConfig": { + "enabled": true, + "intensity": "standard", + "applyToToolResults": true, + "applyToCodeBlocks": false, + "applyToAssistantMessages": false, + "enabledFilters": [], + "disabledFilters": [], + "maxLinesPerResult": 120, + "maxCharsPerResult": 12000, + "deduplicateThreshold": 3, + "customFiltersEnabled": true, + "trustProjectFilters": false, + "rawOutputRetention": "never", + "rawOutputMaxBytes": 1048576 + } +} +``` + +`enabledFilters` and `disabledFilters` use filter ids, for example `test-vitest` or `git-diff`. + +## API + +| Route | Method | Purpose | +| ---------------------------------- | ------ | -------------------------------------------- | +| `/api/context/rtk/config` | GET | Read RTK config | +| `/api/context/rtk/config` | PUT | Update RTK config | +| `/api/context/rtk/filters` | GET | List filter catalog and load diagnostics | +| `/api/context/rtk/test` | POST | Preview RTK compression for one text payload | +| `/api/context/rtk/raw-output/[id]` | GET | Read retained redacted raw output | +| `/api/compression/preview` | POST | Preview any compression mode | + +RTK test payload: + +```json +{ + "command": "npm test", + "text": "FAIL tests/example.test.ts\nAssertionError: expected true\nTest Files 1 failed", + "config": { + "intensity": "standard" + } +} +``` + +Compression preview payload: + +```json +{ + "mode": "stacked", + "messages": [ + { + "role": "tool", + "content": "FAIL tests/example.test.ts\nAssertionError: expected true\nTest Files 1 failed" + } + ], + "config": { + "rtkConfig": { + "rawOutputRetention": "failures" + } + } +} +``` + +Management routes require dashboard management auth or the matching API-key policy. + +## Raw Output Recovery + +RTK normally returns only compressed text. For debugging, `rawOutputRetention` can retain redacted +raw output: + +| Value | Behavior | +| ---------- | ------------------------------------------------------- | +| `never` | Do not retain raw output | +| `failures` | Retain only likely failure output | +| `always` | Retain every compressed RTK raw output, after redaction | + +Retained files are written under: + +```txt +DATA_DIR/rtk/raw-output/ +``` + +Secrets are redacted before persistence, including common bearer tokens, API keys, Slack tokens, +AWS access keys, and assignment-style `token=...`, `secret=...`, `password=...` values. Analytics +stores only the pointer id, size, and hash metadata. + +## Verify Gate + +The focused verify gate runs built-in inline filter tests without shelling out to external commands: + +```bash +node --import tsx/esm --test tests/unit/compression/rtk-verify.test.ts +``` + +The broader RTK gate is: + +```bash +node --import tsx/esm --test \ + tests/unit/compression/rtk-*.test.ts \ + tests/unit/compression/pipeline-integration.test.ts \ + tests/unit/compression/context-compression-api.test.ts +``` + +Run the broad compression gate before release: + +```bash +node --import tsx/esm --test \ + tests/unit/compression/*.test.ts \ + tests/golden-set/*.test.ts \ + tests/integration/compression-pipeline.test.ts \ + tests/unit/api/compression/compression-api.test.ts +``` + +## Extending RTK + +1. Add or update a filter JSON file. +2. Include at least one `tests[]` sample that proves the important behavior. +3. Add a fixture under `tests/unit/compression/fixtures/rtk/` for new command families. +4. Add command detection coverage when introducing a new output class. +5. Run the verify and broad RTK gates. +6. If the filter is project-local, commit `.rtk/filters.json` and refresh `.rtk/trust.json` only after review. diff --git a/docs/SETUP_GUIDE.md b/docs/SETUP_GUIDE.md index 9e78e203e9..26c332a5ab 100644 --- a/docs/SETUP_GUIDE.md +++ b/docs/SETUP_GUIDE.md @@ -138,7 +138,7 @@ Add to your MCP settings: } ``` -**Full MCP documentation:** [MCP Server README](../open-sse/mcp-server/README.md) — 29 tools, IDE configs, Python/TS/Go clients. +**Full MCP documentation:** [MCP Server README](../open-sse/mcp-server/README.md) — 37 tools, IDE configs, Python/TS/Go clients. ### A2A Setup (Agent-to-Agent Protocol) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 76b54c78e7..bea18f734e 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.7.8 + version: 3.7.9 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, @@ -51,6 +51,8 @@ tags: description: Routing combo management - name: Settings description: Application settings + - name: Compression + description: Prompt compression, RTK filters, Caveman rules, and compression combos - name: Usage description: Usage analytics and logs - name: Translator @@ -734,6 +736,202 @@ paths: "200": description: Updated settings + /api/settings/compression: + get: + tags: [Compression] + summary: Get global compression settings + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Current compression settings + put: + tags: [Compression] + summary: Update global compression settings + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + defaultMode: + type: string + enum: [off, lite, standard, aggressive, ultra, rtk, stacked] + autoTriggerMode: + type: string + enum: [off, lite, standard, aggressive, ultra, rtk, stacked] + autoTriggerTokens: + type: integer + minimum: 0 + rtkConfig: + type: object + additionalProperties: true + stackedPipeline: + type: array + items: + type: object + responses: + "200": + description: Updated compression settings + + /api/compression/preview: + post: + tags: [Compression] + summary: Preview compression for a message payload + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [messages, mode] + properties: + mode: + type: string + enum: [off, lite, standard, aggressive, ultra, rtk, stacked] + messages: + type: array + items: + type: object + required: [role, content] + properties: + role: + type: string + content: + oneOf: + - type: string + - type: array + items: {} + config: + type: object + additionalProperties: true + responses: + "200": + description: Compression preview with diff, validation, and stats + + /api/compression/language-packs: + get: + tags: [Compression] + summary: List Caveman compression language packs + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Available languages and rule-pack metadata + + /api/compression/rules: + get: + tags: [Compression] + summary: List Caveman compression rule metadata + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Caveman rule metadata + + /api/context/rtk/config: + get: + tags: [Compression] + summary: Get RTK compression settings + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Current RTK config + put: + tags: [Compression] + summary: Update RTK compression settings + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + intensity: + type: string + enum: [minimal, standard, aggressive] + customFiltersEnabled: + type: boolean + trustProjectFilters: + type: boolean + rawOutputRetention: + type: string + enum: [never, failures, always] + rawOutputMaxBytes: + type: integer + responses: + "200": + description: Updated RTK config + + /api/context/rtk/filters: + get: + tags: [Compression] + summary: List RTK filters and load diagnostics + security: + - ManagementSessionAuth: [] + responses: + "200": + description: RTK filter catalog and diagnostics + + /api/context/rtk/test: + post: + tags: [Compression] + summary: Run RTK compression preview for text + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [text] + properties: + text: + type: string + command: + type: string + config: + type: object + additionalProperties: true + responses: + "200": + description: Detection and RTK compression result + + /api/context/rtk/raw-output/{id}: + get: + tags: [Compression] + summary: Read retained redacted RTK raw output + security: + - ManagementSessionAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + pattern: "^[a-f0-9]{24}$" + responses: + "200": + description: Raw output text + "404": + description: Raw output not found + /api/settings/payload-rules: get: tags: [Settings] diff --git a/electron/package-lock.json b/electron/package-lock.json index fbcbba7997..abe3c9d549 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute-desktop", - "version": "3.7.8", + "version": "3.7.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute-desktop", - "version": "3.7.8", + "version": "3.7.9", "license": "MIT", "dependencies": { "better-sqlite3": "^12.8.0", diff --git a/electron/package.json b/electron/package.json index 8a182a53c8..f984a9d83f 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.7.8", + "version": "3.7.9", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/llm.txt b/llm.txt index 8b50978e3d..7702e3f58b 100644 --- a/llm.txt +++ b/llm.txt @@ -8,7 +8,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.7.7 +**Current version:** 3.7.9 ## Tech Stack @@ -279,7 +279,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.7.7) +## Key Features (v3.7.9) ### Core Proxy - **160+ AI providers** with automatic format translation diff --git a/next.config.mjs b/next.config.mjs index be2b7dd40c..eab10cb8e8 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -8,7 +8,7 @@ const projectRoot = dirname(fileURLToPath(import.meta.url)); const scriptSrc = process.env.NODE_ENV === "development" ? "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:" - : "script-src 'self' 'unsafe-inline' blob:"; + : "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:"; const contentSecurityPolicy = [ "default-src 'self'", "base-uri 'self'", @@ -79,9 +79,13 @@ const nextConfig = { }, outputFileTracingRoot: projectRoot, outputFileTracingIncludes: { - // Migration SQL files are read via fs.readFileSync at runtime and are NOT - // auto-traced by webpack/turbopack — include them explicitly. - "/*": ["./src/lib/db/migrations/**/*"], + // Migration SQL and compression rule/filter JSON files are read via fs at + // runtime and are NOT always auto-traced by webpack/turbopack. + "/*": [ + "./src/lib/db/migrations/**/*", + "./open-sse/services/compression/engines/rtk/filters/**/*.json", + "./open-sse/services/compression/rules/**/*.json", + ], }, outputFileTracingExcludes: { // Planning/task docs are not runtime assets and can break standalone copies @@ -136,97 +140,6 @@ const nextConfig = { images: { unoptimized: true, }, - webpack: (config, { isServer, webpack }) => { - if (isServer) { - // Webpack IgnorePlugin: skip thread-stream test files that contain - // intentionally broken syntax/imports (they cause Turbopack build errors) - config.plugins.push( - new webpack.IgnorePlugin({ - resourceRegExp: /\/test\//, - contextRegExp: /thread-stream/, - }) - ); - - // ── Turbopack / Next.js 16 module-hash patch (#394, #396, #398) ──────── - // - // Next.js 16 (with or without Turbopack) compiles the instrumentation hook - // into a separate chunk and emits hashed require() calls such as: - // require('better-sqlite3-90e2652d1716b047') - // require('zod-dcb22c6336e0bc69') - // require('pino-28069d5257187539') - // - // These hashed names don't exist in node_modules and cause a 500 at - // startup on all npm global installs (issues #394, #396, #398). - // - // We use two strategies: - // 1. Exact-name externals for all known server-side packages. - // 2. Hash-strip catch-all: any require('-<16hexchars>[/subpath]') - // strips the hash suffix and falls through to the real package name. - // - const HASH_PATTERN = /^(.+)-[0-9a-f]{16}(\/.*)?$/; - - const KNOWN_EXTERNALS = new Set([ - "better-sqlite3", - "keytar", - "@ngrok/ngrok", - "wreq-js", - "zod", - "pino", - "pino-pretty", - "pino-abstract-transport", - "child_process", - "fs", - "path", - "os", - "crypto", - "net", - "tls", - "http", - "https", - "stream", - "buffer", - "util", - "process", - ]); - - const prev = config.externals ?? []; - const prevArr = Array.isArray(prev) ? prev : [prev]; - config.externals = [ - ...prevArr, - ({ request }, callback) => { - // Case 1: Exact known package — treat as external - if (KNOWN_EXTERNALS.has(request)) { - return callback(null, `commonjs ${request}`); - } - // Case 2: Hash-suffixed name — strip hash, preserve subpath - // e.g. "better-sqlite3-90e2652d1716b047" → "better-sqlite3" - // "zod-dcb22c6336e0bc69" → "zod" - // "zod-dcb22c6336e0bc69/v3" → "zod/v3" - // "zod-dcb22c6336e0bc69/v4-mini" → "zod/v4-mini" - const hashMatch = request?.match?.(HASH_PATTERN); - if (hashMatch) { - const resolved = hashMatch[2] ? `${hashMatch[1]}${hashMatch[2]}` : hashMatch[1]; - return callback(null, `commonjs ${resolved}`); - } - callback(); - }, - ]; - } else { - // Ignore native Node.js modules in browser bundle - config.resolve.fallback = { - ...config.resolve.fallback, - fs: false, - path: false, - child_process: false, - net: false, - tls: false, - crypto: false, - process: false, - os: false, - }; - } - return config; - }, async headers() { return [ diff --git a/open-sse/config/embeddingRegistry.ts b/open-sse/config/embeddingRegistry.ts index 795c43c811..c2526af4a3 100644 --- a/open-sse/config/embeddingRegistry.ts +++ b/open-sse/config/embeddingRegistry.ts @@ -173,13 +173,59 @@ export const EMBEDDING_PROVIDERS: Record = { { id: "text-embedding-3-large", name: "Text Embedding 3 Large (GitHub)", dimensions: 3072 }, ], }, + + "jina-ai": { + id: "jina-ai", + baseUrl: "https://api.jina.ai/v1/embeddings", + authType: "apikey", + authHeader: "bearer", + models: [ + { + id: "jina-embeddings-v5-text-small", + name: "Jina Embeddings v5 Text Small", + dimensions: 1024, + }, + { id: "jina-embeddings-v5-text-nano", name: "Jina Embeddings v5 Text Nano", dimensions: 768 }, + { id: "jina-code-embeddings-1.5b", name: "Jina Code Embeddings 1.5B", dimensions: 1536 }, + { id: "jina-code-embeddings-0.5b", name: "Jina Code Embeddings 0.5B", dimensions: 896 }, + { id: "jina-embeddings-v4", name: "Jina Embeddings v4", dimensions: 2048 }, + { id: "jina-clip-v2", name: "Jina CLIP v2", dimensions: 1024 }, + { id: "jina-colbert-v2", name: "Jina ColBERT v2", dimensions: 128 }, + ], + }, }; +const EMBEDDING_PROVIDER_ALIASES: Record = { + jina: "jina-ai", + voyage: "voyage-ai", +}; + +function resolveEmbeddingProviderId(providerId: string): string { + return EMBEDDING_PROVIDER_ALIASES[providerId] || providerId; +} + +function normalizeProviderScopedModelId(providerId: string, modelId: string): string { + const resolvedProvider = resolveEmbeddingProviderId(providerId); + const provider = EMBEDDING_PROVIDERS[resolvedProvider]; + if (provider?.models.some((model) => model.id === modelId)) return modelId; + + const providerScopedModelId = `${resolvedProvider}/${modelId}`; + if (provider?.models.some((model) => model.id === providerScopedModelId)) { + return providerScopedModelId; + } + + return modelId.startsWith(`${providerId}/`) ? modelId.slice(providerId.length + 1) : modelId; +} + +function toProviderScopedModelId(providerId: string, modelId: string): string { + return modelId.startsWith(`${providerId}/`) ? modelId : `${providerId}/${modelId}`; +} + /** * Get embedding provider config by ID */ export function getEmbeddingProvider(providerId: string): EmbeddingProvider | null { - return EMBEDDING_PROVIDERS[providerId] || null; + return EMBEDDING_PROVIDERS[resolveEmbeddingProviderId(providerId)] || null; } /** @@ -195,10 +241,23 @@ export function parseEmbeddingModel( // Check for "provider/model" format const slashIdx = modelStr.indexOf("/"); if (slashIdx > 0) { + const rawProvider = modelStr.slice(0, slashIdx); + const resolvedProvider = resolveEmbeddingProviderId(rawProvider); + + if (EMBEDDING_PROVIDERS[resolvedProvider]) { + return { + provider: resolvedProvider, + model: normalizeProviderScopedModelId(resolvedProvider, modelStr.slice(slashIdx + 1)), + }; + } + // Phase 1: Try each hardcoded provider prefix for (const [providerId] of Object.entries(EMBEDDING_PROVIDERS)) { if (modelStr.startsWith(providerId + "/")) { - return { provider: providerId, model: modelStr.slice(providerId.length + 1) }; + return { + provider: providerId, + model: normalizeProviderScopedModelId(providerId, modelStr.slice(providerId.length + 1)), + }; } } // Phase 2: Try dynamic provider_nodes prefix @@ -233,7 +292,7 @@ export function getAllEmbeddingModels() { for (const [providerId, config] of Object.entries(EMBEDDING_PROVIDERS)) { for (const model of config.models) { models.push({ - id: `${providerId}/${model.id}`, + id: toProviderScopedModelId(providerId, model.id), name: model.name, provider: providerId, dimensions: model.dimensions, diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index a9fc20da0e..dc8ccfaf9b 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -437,6 +437,20 @@ export const IMAGE_PROVIDERS: Record = { models: [{ id: "topaz-enhance", name: "topaz-enhance", inputModalities: ["image"] }], supportedSizes: ["1024x1024"], }, + nanogpt: { + id: "nanogpt", + baseUrl: "https://nano-gpt.com/api/v1/images/generations", + authType: "apikey", + authHeader: "bearer", + format: "openai", + models: [ + { id: "qwen-image", name: "Qwen Image", inputModalities: ["text", "image"] }, + { id: "z-image-turbo", name: "Z Image Turbo" }, + { id: "chroma", name: "Chroma" }, + { id: "hidream", name: "Hidream I1 Full" }, + ], + supportedSizes: ["1024x1024", "1024x1280", "1280x1024"], + }, }; /** diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 8265f3376f..063d047396 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -672,17 +672,39 @@ export const REGISTRY: Record = { agentrouter: { id: "agentrouter", alias: "agentrouter", - format: "openai", + format: "claude", executor: "default", - baseUrl: "https://agentrouter.org/v1/chat/completions", + baseUrl: "https://agentrouter.org/v1/messages", authType: "apikey", - authHeader: "bearer", + authHeader: "x-api-key", defaultContextLength: 128000, headers: { - "HTTP-Referer": "https://endpoint-proxy.local", - "X-Title": "OmniRoute", + "Anthropic-Version": ANTHROPIC_VERSION_HEADER, + "Anthropic-Beta": ANTHROPIC_BETA_CLAUDE_OAUTH, + "Anthropic-Dangerous-Direct-Browser-Access": "true", + "User-Agent": CLAUDE_CLI_USER_AGENT, + "X-App": "cli", + "X-Stainless-Helper-Method": "stream", + "X-Stainless-Retry-Count": "0", + "X-Stainless-Runtime-Version": CLAUDE_CLI_STAINLESS_RUNTIME_VERSION, + "X-Stainless-Package-Version": CLAUDE_CLI_STAINLESS_PACKAGE_VERSION, + "X-Stainless-Runtime": "node", + "X-Stainless-Lang": "js", + "X-Stainless-Arch": mapStainlessArch(), + "X-Stainless-Os": mapStainlessOs(), + "X-Stainless-Timeout": "600", }, - models: [{ id: "auto", name: "Auto (Best Available)" }], + models: [ + { id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" }, + { id: "claude-opus-4-6", name: "Claude 4.6 Opus" }, + { id: "deepseek-r1-0528", name: "DeepSeek R1 0528" }, + { id: "deepseek-v3.1", name: "DeepSeek V3.1" }, + { id: "deepseek-v3.2", name: "DeepSeek V3.2" }, + { id: "glm-4.5", name: "GLM 4.5" }, + { id: "glm-4.6", name: "GLM 4.6" }, + { id: "glm-5.1", name: "GLM 5.1" }, + ], + passthroughModels: true, }, openrouter: { diff --git a/open-sse/config/rerankRegistry.ts b/open-sse/config/rerankRegistry.ts index 80f9bb5466..b075bf4357 100644 --- a/open-sse/config/rerankRegistry.ts +++ b/open-sse/config/rerankRegistry.ts @@ -69,20 +69,41 @@ export const RERANK_PROVIDERS = { models: [ { id: "jina-reranker-v3", name: "Jina Reranker v3" }, { id: "jina-reranker-m0", name: "Jina Reranker m0" }, - { - id: "jina-reranker-v2-base-multilingual", - name: "Jina Reranker v2 Base Multilingual", - }, - { id: "jina-colbert-v2", name: "Jina ColBERT v2" }, ], }, }; +const RERANK_PROVIDER_ALIASES = { + jina: "jina-ai", + voyage: "voyage-ai", +}; + +function resolveRerankProviderId(providerId) { + return RERANK_PROVIDER_ALIASES[providerId] || providerId; +} + +function normalizeProviderScopedModelId(providerId, modelId) { + const resolvedProvider = resolveRerankProviderId(providerId); + const provider = RERANK_PROVIDERS[resolvedProvider]; + if (provider?.models.some((model) => model.id === modelId)) return modelId; + + const providerScopedModelId = `${resolvedProvider}/${modelId}`; + if (provider?.models.some((model) => model.id === providerScopedModelId)) { + return providerScopedModelId; + } + + return modelId.startsWith(`${providerId}/`) ? modelId.slice(providerId.length + 1) : modelId; +} + +function toProviderScopedModelId(providerId, modelId) { + return modelId.startsWith(`${providerId}/`) ? modelId : `${providerId}/${modelId}`; +} + /** * Get rerank provider config by ID */ export function getRerankProvider(providerId) { - return RERANK_PROVIDERS[providerId] || null; + return RERANK_PROVIDERS[resolveRerankProviderId(providerId)] || null; } /** @@ -92,10 +113,25 @@ export function getRerankProvider(providerId) { export function parseRerankModel(modelStr) { if (!modelStr) return { provider: null, model: null }; + const slashIdx = modelStr.indexOf("/"); + if (slashIdx > 0) { + const rawProvider = modelStr.slice(0, slashIdx); + const resolvedProvider = resolveRerankProviderId(rawProvider); + if (RERANK_PROVIDERS[resolvedProvider]) { + return { + provider: resolvedProvider, + model: normalizeProviderScopedModelId(resolvedProvider, modelStr.slice(slashIdx + 1)), + }; + } + } + // Try each provider prefix for (const [providerId, config] of Object.entries(RERANK_PROVIDERS)) { if (modelStr.startsWith(providerId + "/")) { - return { provider: providerId, model: modelStr.slice(providerId.length + 1) }; + return { + provider: providerId, + model: normalizeProviderScopedModelId(providerId, modelStr.slice(providerId.length + 1)), + }; } } @@ -117,7 +153,7 @@ export function getAllRerankModels() { for (const [providerId, config] of Object.entries(RERANK_PROVIDERS)) { for (const model of config.models) { models.push({ - id: `${providerId}/${model.id}`, + id: toProviderScopedModelId(providerId, model.id), name: model.name, provider: providerId, }); diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 309e12aacd..00576b774c 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -293,6 +293,9 @@ export class BaseExecutor { }); } + // Fix #1884: Cursor sends prompt_cache_retention which breaks strict upstream endpoints + delete cloned.prompt_cache_retention; + // Also clean up top level optional fields that commonly cause issues when empty const optionalKeys = ["user", "stop", "seed", "response_format"]; for (const key of optionalKeys) { diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index e84be095b6..4f008623fc 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -26,6 +26,7 @@ import { getRememberedResponseConversationItems, getRememberedResponseFunctionCalls, } from "../services/responsesToolCallState.ts"; +import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts"; import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts"; import { CORS_HEADERS } from "../utils/cors.ts"; import { createRequire } from "module"; @@ -45,6 +46,11 @@ type WreqWebSocket = { onclose: (() => void) | null; }; type WebsocketFn = (url: string, opts?: Record) => Promise; +type ResponsesMessageInput = { + role?: unknown; + phase?: unknown; + content?: unknown; +}; let _websocketFn: WebsocketFn | null = null; let _wreqChecked = false; @@ -605,6 +611,40 @@ function normalizeCodexTools(body: Record): void { return false; } + // Codex Responses API requires function tools in flat Responses format: + // { type: "function", name, description, parameters } + // Some clients/translators send Chat Completions shape: + // { type: "function", function: { name, description, parameters } } + // which upstream rejects with "Missing required parameter: tools[0].name". + // Flatten the nested `function` wrapper into top-level fields (#1914). + const functionObject = + tool.function && typeof tool.function === "object" && !Array.isArray(tool.function) + ? (tool.function as Record) + : null; + const description = + typeof tool.description === "string" + ? tool.description + : typeof functionObject?.description === "string" + ? functionObject.description + : ""; + const parameters = + tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters) + ? tool.parameters + : functionObject?.parameters && + typeof functionObject.parameters === "object" && + !Array.isArray(functionObject.parameters) + ? functionObject.parameters + : { type: "object", properties: {} }; + + // Rewrite in-place to Responses format + for (const key of Object.keys(tool)) { + delete tool[key]; + } + tool.type = "function"; + tool.name = name; + if (description) tool.description = description; + tool.parameters = parameters; + validToolNames.add(name); return true; }); @@ -1185,19 +1225,49 @@ export class CodexExecutor extends BaseExecutor { // Issue #1832 & #1853: Map messages to input for clients like Cursor 5.5 that use responses/compact but send messages instead of input. // This MUST run before convertSystemToDeveloperRole and stripStoredItemReferences. if (!body.input && Array.isArray(body.messages)) { - body.input = body.messages.map((msg: any) => ({ + body.input = body.messages.map((msg: ResponsesMessageInput) => ({ type: "message", role: typeof msg.role === "string" ? msg.role : "user", + ...(typeof msg.phase === "string" ? { phase: msg.phase } : {}), content: typeof msg.content === "string" ? [{ type: "input_text", text: msg.content }] : Array.isArray(msg.content) - ? msg.content.map((c: any) => { - if (c && c.type === "text") return { type: "input_text", text: c.text }; - return c; + ? msg.content.map((contentPart: unknown) => { + if ( + contentPart && + typeof contentPart === "object" && + !Array.isArray(contentPart) && + (contentPart as Record).type === "text" + ) { + return { + type: "input_text", + text: (contentPart as Record).text, + }; + } + return contentPart; }) : [], })); + } else if (!body.input && typeof body.prompt === "string" && body.prompt.trim()) { + // Issue #1872: Cursor occasionally passes the request as `prompt` instead of `messages`. + body.input = [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: body.prompt }], + }, + ]; + } else if (!body.input && Array.isArray(body.prompt)) { + body.input = body.prompt.map((p: any) => ({ + type: "message", + role: "user", + content: [{ type: "input_text", text: typeof p === "string" ? p : JSON.stringify(p) }], + })); + } + + if (Array.isArray(body.input)) { + body.input = sanitizeResponsesInputItems(body.input, false); } // ── Cache-aware system prompt handling (both paths) ── @@ -1355,7 +1425,7 @@ export class CodexExecutor extends BaseExecutor { delete body.seed; // max_tokens and max_output_tokens already deleted above (before passthrough return) delete body.user; // Cursor sends this but Codex doesn't support it - delete body.prompt_cache_retention; // Cursor sends this but Codex doesn't support it + delete body.metadata; // Cursor sends this but Codex doesn't support it delete body.stream_options; // Cursor sends this but Codex doesn't support it delete body.safety_identifier; // Droid CLI sends this but Codex doesn't support it diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index dbf58ed0cf..10d2591149 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -922,6 +922,28 @@ function buildExecutorClientHeaders( return Object.keys(normalized).length > 0 ? normalized : null; } +function isCopilotClient( + headers: Headers | Record | null | undefined, + userAgent?: string | null +) { + const isMatch = (value: unknown) => + typeof value === "string" && value.toLowerCase().includes("copilot"); + + if (isMatch(userAgent)) return true; + + if (headers instanceof Headers) { + for (const [key, value] of headers) { + if (isMatch(key) || isMatch(value)) return true; + } + } else if (headers && typeof headers === "object") { + for (const [key, value] of Object.entries(headers)) { + if (isMatch(key) || isMatch(value)) return true; + } + } + + return false; +} + export async function handleChatCore({ body, modelInfo, @@ -954,6 +976,7 @@ export async function handleChatCore({ const suffix = extra ? ` ${JSON.stringify(extra)}` : ""; log?.info?.("STAGE_TRACE", `${traceId} ${label} t=${elapsed}ms${suffix}`); }; + let tokensCompressed: number | null = null; const persistFailureUsage = (statusCode: number, errorCode?: string | null) => { saveRequestUsage({ provider: provider || "unknown", @@ -1099,6 +1122,7 @@ export async function handleChatCore({ }); const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli"); + const copilotCompatibleReasoning = isCopilotClient(clientRawRequest?.headers, userAgent); const clientResponseFormat = sourceFormat === FORMATS.OPENAI_RESPONSES && !isResponsesEndpoint && !isDroidCLI ? FORMATS.OPENAI @@ -1178,6 +1202,23 @@ export async function handleChatCore({ const capturePipelineStreamChunks = detailedLoggingEnabled && getCallLogPipelineCaptureStreamChunks(); const skillRequestId = generateRequestId(); + let compressionAnalyticsWritePromise: Promise | null = null; + const attachCompressionUsageReceiptAfterAnalytics = ( + usage: Record, + source: "provider" | "estimated" | "stream" + ) => { + const pendingWrite = compressionAnalyticsWritePromise; + void (async () => { + try { + if (pendingWrite) await pendingWrite; + const { attachCompressionUsageReceipt } = + await import("../../src/lib/db/compressionAnalytics.ts"); + attachCompressionUsageReceipt(skillRequestId, usage, source); + } catch { + // Compression analytics are best-effort and must never affect responses. + } + })(); + }; const pipelineSessionId = (clientRawRequest?.headers && typeof clientRawRequest.headers.get === "function" ? clientRawRequest.headers.get("x-omniroute-session-id") @@ -1285,6 +1326,7 @@ export async function handleChatCore({ comboName, comboStepId, comboExecutionKey, + tokensCompressed, cacheSource: cacheSource === "semantic" ? "semantic" : "upstream", apiKeyId: apiKeyInfo?.id || null, apiKeyName: apiKeyInfo?.name || null, @@ -1560,6 +1602,8 @@ export async function handleChatCore({ // This prevents "prompt too long" errors for large-but-not-full contexts. const allMessages = body?.messages || body?.input || body?.contents || body?.request?.contents || []; + let cavemanOutputModeApplied = false; + let cavemanOutputModeIntensity: string | null = null; if (body && Array.isArray(allMessages) && allMessages.length > 0) { let estimatedTokens = estimateTokens(JSON.stringify(allMessages)); let promptCompressionEnabled = false; @@ -1594,6 +1638,91 @@ export async function handleChatCore({ log?.debug?.("COMPRESSION", "Prompt compression disabled or unavailable"); } let compressionComboKey = comboName ?? null; + let compressionComboApplied = false; + type RuntimeCompressionCombo = { + id: string; + pipeline: NonNullable; + languagePacks: string[]; + outputMode: boolean; + outputModeIntensity: string; + }; + const isBuiltinStackedPipeline = ( + pipeline: CompressionConfig["stackedPipeline"] | undefined + ): boolean => { + if (!Array.isArray(pipeline) || pipeline.length !== 2) return false; + const [first, second] = pipeline; + return ( + first?.engine === "rtk" && + (first.intensity === undefined || first.intensity === "standard") && + !first.config && + second?.engine === "caveman" && + (second.intensity === undefined || second.intensity === "full") && + !second.config + ); + }; + const applyCompressionComboConfig = ( + compressionCombo: RuntimeCompressionCombo | null, + routingOverrideIds: string[] = [] + ): boolean => { + if (!compressionCombo || compressionCombo.pipeline.length === 0) return false; + const comboLanguagePacks = [ + ...new Set( + compressionCombo.languagePacks + .map((pack) => pack.trim()) + .filter((pack) => pack.length > 0) + ), + ]; + const comboOutputIntensity = ( + ["lite", "full", "ultra"].includes(compressionCombo.outputModeIntensity) + ? compressionCombo.outputModeIntensity + : (config.cavemanOutputMode?.intensity ?? "full") + ) as "lite" | "full" | "ultra"; + const comboDefaultLanguage = + comboLanguagePacks.find((pack) => pack === config.languageConfig?.defaultLanguage) ?? + comboLanguagePacks[0] ?? + config.languageConfig?.defaultLanguage ?? + "en"; + const comboOverrides = { ...(config.comboOverrides ?? {}) }; + for (const id of routingOverrideIds) { + if (id) comboOverrides[id] = "stacked"; + } + config = { + ...config, + compressionComboId: compressionCombo.id, + stackedPipeline: compressionCombo.pipeline, + languageConfig: { + ...(config.languageConfig ?? { + enabled: false, + defaultLanguage: "en", + autoDetect: true, + enabledPacks: ["en"], + }), + enabled: true, + defaultLanguage: comboDefaultLanguage, + enabledPacks: + comboLanguagePacks.length > 0 + ? comboLanguagePacks + : (config.languageConfig?.enabledPacks ?? ["en"]), + }, + cavemanOutputMode: { + ...(config.cavemanOutputMode ?? { + enabled: false, + intensity: "full", + autoClarity: true, + }), + enabled: compressionCombo.outputMode, + intensity: comboOutputIntensity, + }, + comboOverrides, + }; + compressionComboApplied = true; + return true; + }; + const isStackedCompressionCombo = ( + compressionCombo: RuntimeCompressionCombo | null + ): compressionCombo is RuntimeCompressionCombo => { + return Boolean(compressionCombo && compressionCombo.pipeline.length > 1); + }; if (isCombo && comboName) { try { const { getComboByName } = await import("../../src/lib/localDb"); @@ -1616,7 +1745,9 @@ export async function handleChatCore({ comboMode === "lite" || comboMode === "standard" || comboMode === "aggressive" || - comboMode === "ultra" + comboMode === "ultra" || + comboMode === "rtk" || + comboMode === "stacked" ) { config = { ...config, @@ -1628,6 +1759,27 @@ export async function handleChatCore({ }; compressionComboKey = comboName; } + const routingComboIds = [ + comboConfig?.id, + comboName, + comboName.startsWith("combo/") ? comboName.substring(6) : null, + ].filter((id): id is string => typeof id === "string" && id.length > 0); + if (routingComboIds.length > 0) { + const { getCompressionComboForRoutingCombo } = + await import("../../src/lib/db/compressionCombos.ts"); + const assignedCompressionCombo = + routingComboIds + .map((id) => getCompressionComboForRoutingCombo(id)) + .find((combo) => combo !== null) ?? null; + if ( + applyCompressionComboConfig( + assignedCompressionCombo as RuntimeCompressionCombo | null, + routingComboIds + ) + ) { + compressionComboKey = comboName; + } + } } catch (err) { log?.debug?.( "COMPRESSION", @@ -1636,6 +1788,66 @@ export async function handleChatCore({ ); } } + const modeBeforeOutputTransform = selectCompressionStrategy( + config, + compressionComboKey, + estimatedTokens, + body as Record, + { provider, targetFormat, model: effectiveModel } + ); + if ( + modeBeforeOutputTransform === "stacked" && + !compressionComboApplied && + !config.compressionComboId && + isBuiltinStackedPipeline(config.stackedPipeline) + ) { + try { + const { getDefaultCompressionCombo } = + await import("../../src/lib/db/compressionCombos.ts"); + const defaultCompressionCombo = getDefaultCompressionCombo(); + if ( + isStackedCompressionCombo(defaultCompressionCombo as RuntimeCompressionCombo | null) && + applyCompressionComboConfig(defaultCompressionCombo as RuntimeCompressionCombo | null) + ) { + log?.debug?.( + "COMPRESSION", + `Default compression combo applied: ${defaultCompressionCombo?.id}` + ); + } + } catch (err) { + log?.debug?.( + "COMPRESSION", + "Default compression combo lookup skipped: " + + (err instanceof Error ? err.message : String(err)) + ); + } + } + if (config.cavemanOutputMode?.enabled) { + try { + const { applyCavemanOutputMode } = await import("../services/compression/outputMode.ts"); + const outputModeLanguage = + config.languageConfig?.enabled === true ? config.languageConfig.defaultLanguage : "en"; + const outputMode = applyCavemanOutputMode( + body as Parameters[0], + config.cavemanOutputMode, + outputModeLanguage + ); + if (outputMode.applied) { + body = outputMode.body as typeof body; + cavemanOutputModeApplied = true; + cavemanOutputModeIntensity = config.cavemanOutputMode.intensity; + estimatedTokens = estimateTokens(JSON.stringify(body?.messages ?? body?.input ?? [])); + log?.debug?.("COMPRESSION", "Caveman output mode instruction applied"); + } else if (outputMode.skippedReason && outputMode.skippedReason !== "disabled") { + log?.debug?.("COMPRESSION", `Caveman output mode skipped: ${outputMode.skippedReason}`); + } + } catch (err) { + log?.debug?.( + "COMPRESSION", + "Caveman output mode skipped: " + (err instanceof Error ? err.message : String(err)) + ); + } + } const compressionInputBody = body as Record; const mode = selectCompressionStrategy( config, @@ -1644,80 +1856,138 @@ export async function handleChatCore({ compressionInputBody, { provider, targetFormat, model: effectiveModel } ); + let compressionAnalyticsRecorded = false; if (mode !== "off") { const result = applyCompression(compressionInputBody, mode, { model: effectiveModel, config, }); - if (result.compressed && result.stats) { - body = result.body as typeof body; - estimatedTokens = result.stats.compressedTokens; - trackCompressionStats(result.stats); - void (async () => { - try { - const { insertCompressionAnalyticsRow } = - await import("../../src/lib/db/compressionAnalytics.ts"); - insertCompressionAnalyticsRow({ - timestamp: new Date().toISOString(), - combo_id: comboName ?? null, - provider: provider ?? null, - mode, - original_tokens: result.stats.originalTokens, - compressed_tokens: result.stats.compressedTokens, - tokens_saved: Math.max( + if (result.stats) { + if (result.compressed) { + body = result.body as typeof body; + estimatedTokens = result.stats.compressedTokens; + tokensCompressed = Math.max( + 0, + result.stats.originalTokens - result.stats.compressedTokens + ); + } + + if (result.compressed || result.stats.fallbackApplied || cavemanOutputModeApplied) { + trackCompressionStats(result.stats); + compressionAnalyticsRecorded = true; + compressionAnalyticsWritePromise = (async () => { + try { + const { insertCompressionAnalyticsRow } = + await import("../../src/lib/db/compressionAnalytics.ts"); + const { calculateCost } = await import("../../src/lib/usage/costCalculator.ts"); + const tokensSaved = Math.max( 0, result.stats.originalTokens - result.stats.compressedTokens - ), - duration_ms: result.stats.durationMs ?? null, - request_id: skillRequestId, - }); - } catch (err) { - log?.debug?.( - "COMPRESSION", - "Compression analytics write skipped: " + - (err instanceof Error ? err.message : String(err)) - ); - } - })(); - void (async () => { - try { - const { detectCachingContext } = - await import("../services/compression/cachingAware.ts"); - const { recordCacheStats } = - await import("../../src/lib/db/compressionCacheStats.ts"); - const cacheContext = detectCachingContext(compressionInputBody, { - provider, - targetFormat, - model: effectiveModel, - }); - const tokensSavedCompression = Math.max( - 0, - result.stats.originalTokens - result.stats.compressedTokens - ); - recordCacheStats({ - provider: cacheContext.provider ?? provider ?? "unknown", - model: effectiveModel ?? "", - compressionMode: mode, - cacheControlPresent: cacheContext.hasCacheControl, - estimatedCacheHit: cacheContext.hasCacheControl && cacheContext.isCachingProvider, - tokensSavedCompression, - tokensSavedCaching: 0, - netSavings: tokensSavedCompression, - }); - } catch (err) { - log?.debug?.( - "COMPRESSION", - "Compression cache stats write skipped: " + - (err instanceof Error ? err.message : String(err)) - ); - } - })(); - log?.info?.( - "COMPRESSION", - `Prompt compressed (${mode}): ${result.stats.originalTokens} -> ${result.stats.compressedTokens} tokens (${result.stats.savingsPercent}% saved, techniques: ${result.stats.techniquesUsed.join(",")})` - ); + ); + const estimatedUsdSaved = await calculateCost( + provider ?? "", + effectiveModel ?? "", + { + input: tokensSaved, + } + ); + insertCompressionAnalyticsRow({ + timestamp: new Date().toISOString(), + combo_id: comboName ?? null, + provider: provider ?? null, + mode, + engine: result.stats.engine ?? mode, + compression_combo_id: + result.stats.compressionComboId ?? config.compressionComboId ?? null, + original_tokens: result.stats.originalTokens, + compressed_tokens: result.stats.compressedTokens, + tokens_saved: tokensSaved, + duration_ms: result.stats.durationMs ?? null, + request_id: skillRequestId, + estimated_usd_saved: estimatedUsdSaved || null, + validation_fallback: result.stats.fallbackApplied ? 1 : 0, + output_mode: cavemanOutputModeApplied ? cavemanOutputModeIntensity : null, + rtk_raw_output_pointer: result.stats.rtkRawOutputPointers?.[0]?.id ?? null, + rtk_raw_output_bytes: result.stats.rtkRawOutputPointers?.[0]?.bytes ?? null, + }); + } catch (err) { + log?.debug?.( + "COMPRESSION", + "Compression analytics write skipped: " + + (err instanceof Error ? err.message : String(err)) + ); + } + })(); + } + + if (result.compressed) { + void (async () => { + try { + const { detectCachingContext } = + await import("../services/compression/cachingAware.ts"); + const { recordCacheStats } = + await import("../../src/lib/db/compressionCacheStats.ts"); + const cacheContext = detectCachingContext(compressionInputBody, { + provider, + targetFormat, + model: effectiveModel, + }); + const tokensSavedCompression = Math.max( + 0, + result.stats.originalTokens - result.stats.compressedTokens + ); + recordCacheStats({ + provider: cacheContext.provider ?? provider ?? "unknown", + model: effectiveModel ?? "", + compressionMode: mode, + cacheControlPresent: cacheContext.hasCacheControl, + estimatedCacheHit: cacheContext.hasCacheControl && cacheContext.isCachingProvider, + tokensSavedCompression, + tokensSavedCaching: 0, + netSavings: tokensSavedCompression, + }); + } catch (err) { + log?.debug?.( + "COMPRESSION", + "Compression cache stats write skipped: " + + (err instanceof Error ? err.message : String(err)) + ); + } + })(); + log?.info?.( + "COMPRESSION", + `Prompt compressed (${mode}): ${result.stats.originalTokens} -> ${result.stats.compressedTokens} tokens (${result.stats.savingsPercent}% saved, techniques: ${result.stats.techniquesUsed.join(",")})` + ); + } } } + if (cavemanOutputModeApplied && !compressionAnalyticsRecorded) { + compressionAnalyticsWritePromise = (async () => { + try { + const { insertCompressionAnalyticsRow } = + await import("../../src/lib/db/compressionAnalytics.ts"); + insertCompressionAnalyticsRow({ + timestamp: new Date().toISOString(), + combo_id: comboName ?? null, + provider: provider ?? null, + mode: "output-caveman", + engine: "caveman-output", + compression_combo_id: config.compressionComboId ?? null, + original_tokens: estimatedTokens, + compressed_tokens: estimatedTokens, + tokens_saved: 0, + request_id: skillRequestId, + output_mode: cavemanOutputModeIntensity, + }); + } catch (err) { + log?.debug?.( + "COMPRESSION", + "Caveman output analytics write skipped: " + + (err instanceof Error ? err.message : String(err)) + ); + } + })(); + } } catch (err) { log?.warn?.( "COMPRESSION", @@ -1793,6 +2063,7 @@ export async function handleChatCore({ if (compressionResult.compressed) { body = compressionResult.body; const stats = compressionResult.stats; + tokensCompressed = Math.max(0, (stats?.original ?? 0) - (stats?.final ?? 0)); const layersInfo = stats && "layers" in stats && Array.isArray(stats.layers) ? ` (layers: ${stats.layers.map((l: { name: string }) => l.name).join(", ")})` @@ -3264,6 +3535,9 @@ export async function handleChatCore({ // Log usage for non-streaming responses const usage = extractUsageFromResponse(responseBody, provider); + if (usage && typeof usage === "object") { + attachCompressionUsageReceiptAfterAnalytics(usage as Record, "provider"); + } appendRequestLog({ model, provider, connectionId, tokens: usage, status: "200 OK" }).catch( () => {} ); @@ -3636,6 +3910,7 @@ export async function handleChatCore({ // Track cache token metrics for streaming responses if (streamUsage && typeof streamUsage === "object") { + attachCompressionUsageReceiptAfterAnalytics(streamUsage as Record, "stream"); const inputTokens = streamUsage.prompt_tokens || 0; const cachedTokens = toPositiveNumber( streamUsage.cache_read_input_tokens ?? @@ -3777,7 +4052,8 @@ export async function handleChatCore({ streamStateBody, onStreamComplete, apiKeyInfo, - handleStreamFailure + handleStreamFailure, + copilotCompatibleReasoning ); } else if (needsTranslation(targetFormat, clientResponseFormat)) { // Standard translation for other providers @@ -3793,7 +4069,8 @@ export async function handleChatCore({ streamStateBody, onStreamComplete, apiKeyInfo, - handleStreamFailure + handleStreamFailure, + copilotCompatibleReasoning ); } else { log?.debug?.("STREAM", `Standard passthrough mode`); diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index f030375346..09777ece75 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -45,6 +45,7 @@ const OPENAI_IMAGE_TO_IMAGE_MODELS = new Set([ "flux-kontext-max", "flux-kontext", "flux-kontext-pro", + "qwen-image", ]); const BFL_MODEL_ENDPOINTS = { diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index aa2c0f1e9d..4b85cb0119 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -691,6 +691,9 @@ export function sanitizeStreamingChunk(parsed: unknown): unknown { } if (deltaRecord.reasoning_content !== undefined) { delta.reasoning_content = deltaRecord.reasoning_content; + } + if (deltaRecord.reasoning_text !== undefined) { + delta.reasoning_text = deltaRecord.reasoning_text; } else if (typeof deltaRecord.reasoning === "string" && deltaRecord.reasoning) { // Alias: some providers use 'reasoning' instead of 'reasoning_content' delta.reasoning_content = deltaRecord.reasoning; diff --git a/open-sse/mcp-server/README.md b/open-sse/mcp-server/README.md index 43b9852940..f70c86014f 100644 --- a/open-sse/mcp-server/README.md +++ b/open-sse/mcp-server/README.md @@ -145,12 +145,20 @@ omniroute --mcp ### Cache and Compression Tools -| # | Tool | Scopes | Description | -| --- | --------------------------------- | ------------------- | ---------------------------------------------------------------------------- | -| 21 | `omniroute_cache_stats` | `read:cache` | Semantic cache, prompt-cache, and idempotency statistics | -| 22 | `omniroute_cache_flush` | `write:cache` | Flush cache entries globally or by signature/model | -| 23 | `omniroute_compression_status` | `read:compression` | Compression settings, analytics summary, and provider-aware cache statistics | -| 24 | `omniroute_compression_configure` | `write:compression` | Configure compression mode and trigger thresholds at runtime | +| # | Tool | Scopes | Description | +| --- | ----------------------------------- | ------------------- | ---------------------------------------------------------------------------- | +| 21 | `omniroute_cache_stats` | `read:cache` | Semantic cache, prompt-cache, and idempotency statistics | +| 22 | `omniroute_cache_flush` | `write:cache` | Flush cache entries globally or by signature/model | +| 23 | `omniroute_compression_status` | `read:compression` | Compression settings, analytics summary, and provider-aware cache statistics | +| 24 | `omniroute_compression_configure` | `write:compression` | Configure compression mode and trigger thresholds at runtime | +| 25 | `omniroute_set_compression_engine` | `write:compression` | Set Caveman, RTK, or stacked compression mode and pipeline | +| 26 | `omniroute_list_compression_combos` | `read:compression` | List named compression combos and routing assignments | +| 27 | `omniroute_compression_combo_stats` | `read:compression` | Read analytics grouped by compression combo and engine | + +MCP listable metadata descriptions are compressed at registration/list time when description +compression is enabled. `omniroute_compression_status` exposes those savings separately as +`analytics.mcpDescriptionCompression` with `source: "mcp_metadata_estimate"`, so clients do not +mistake metadata shrink estimates for provider token receipts. --- @@ -540,12 +548,12 @@ The MCP server supports **fine-grained scope enforcement** for multi-tenant envi | `read:usage` | `cost_report`, `explain_route`, `get_session_snapshot` | | `read:models` | `list_models_catalog` | | `read:cache` | `cache_stats` | -| `read:compression` | `compression_status` | +| `read:compression` | `compression_status`, `list_compression_combos`, `compression_combo_stats` | | `write:combos` | `switch_combo` | | `write:budget` | `set_budget_guard` | | `write:resilience` | `set_resilience_profile` | | `write:cache` | `cache_flush` | -| `write:compression` | `compression_configure` | +| `write:compression` | `compression_configure`, `set_compression_engine` | | `execute:completions` | `route_request`, `test_combo` | **Wildcard scopes:** Use `read:*` to grant all read scopes, or `*` for full access. @@ -581,7 +589,7 @@ mcp-server/ ├── audit.ts # SQLite audit logger (SHA-256 input hashing) ├── scopeEnforcement.ts # Fine-grained scope enforcement ├── schemas/ -│ ├── tools.ts # Zod schemas for all 16 tools (input/output/scopes) +│ ├── tools.ts # Zod schemas for core, cache, compression, and proxy tools │ ├── a2a.ts # A2A protocol types (Agent Card, Task, JSON-RPC) │ ├── audit.ts # Audit & routing decision types + hash helpers │ └── index.ts # Schema barrel export diff --git a/open-sse/mcp-server/descriptionCompressor.ts b/open-sse/mcp-server/descriptionCompressor.ts new file mode 100644 index 0000000000..e69a86ee4d --- /dev/null +++ b/open-sse/mcp-server/descriptionCompressor.ts @@ -0,0 +1,243 @@ +import { applyRulesToText } from "../services/compression/caveman.ts"; +import { getRulesForContext } from "../services/compression/cavemanRules.ts"; +import { + extractPreservedBlocks, + restorePreservedBlocks, +} from "../services/compression/preservation.ts"; + +export interface DescriptionCompressionResult { + compressed: string; + before: number; + after: number; + changed: boolean; +} + +export interface DescriptionCompressionOptions { + enabled?: boolean; +} + +export interface McpDescriptionCompressionStats { + descriptionsCompressed: number; + charsBefore: number; + charsAfter: number; + charsSaved: number; + estimatedTokensSaved: number; +} + +const descriptionCompressionStats: McpDescriptionCompressionStats = { + descriptionsCompressed: 0, + charsBefore: 0, + charsAfter: 0, + charsSaved: 0, + estimatedTokensSaved: 0, +}; + +const persistedDescriptionCompressionStats: McpDescriptionCompressionStats = { + descriptionsCompressed: 0, + charsBefore: 0, + charsAfter: 0, + charsSaved: 0, + estimatedTokensSaved: 0, +}; + +const MCP_LIST_CONTAINER_KEYS = new Set(["tools", "prompts", "resources", "resourceTemplates"]); +const MCP_METADATA_DESCRIPTION_FIELDS = ["description"]; + +function isDisabledEnvValue(value: string | undefined): boolean { + return !!value && ["0", "false", "off", "no"].includes(value.trim().toLowerCase()); +} + +export function isMcpDescriptionCompressionEnabled( + options: DescriptionCompressionOptions = {} +): boolean { + if (isDisabledEnvValue(process.env.OMNIROUTE_MCP_COMPRESS_DESCRIPTIONS)) return false; + if (isDisabledEnvValue(process.env.OMNIROUTE_MCP_DESCRIPTION_COMPRESSION)) return false; + return options.enabled !== false; +} + +export function compressMcpDescription(description: string): DescriptionCompressionResult { + if (!description) { + return { compressed: description, before: 0, after: 0, changed: false }; + } + + const { text, blocks } = extractPreservedBlocks(description); + const rules = getRulesForContext("all", "full"); + const applied = applyRulesToText(text, rules).text; + const normalized = applied + .replace(/[ \t]{2,}/g, " ") + .replace(/[ \t]+([,.;:!?])/g, "$1") + .replace(/\n{3,}/g, "\n\n") + .replace(/(^|[.!?][ \t]+|\n+[ \t]*)([a-z])/g, (_match, prefix: string, char: string) => { + return `${prefix}${char.toUpperCase()}`; + }) + .trim(); + const compressed = restorePreservedBlocks(normalized, blocks); + + return { + compressed, + before: description.length, + after: compressed.length, + changed: compressed !== description, + }; +} + +export function maybeCompressMcpDescription( + description: string, + options: DescriptionCompressionOptions = {} +): string { + if (!isMcpDescriptionCompressionEnabled(options)) return description; + const result = compressMcpDescription(description); + if (result.changed && result.after < result.before) { + descriptionCompressionStats.descriptionsCompressed += 1; + descriptionCompressionStats.charsBefore += result.before; + descriptionCompressionStats.charsAfter += result.after; + descriptionCompressionStats.charsSaved += result.before - result.after; + descriptionCompressionStats.estimatedTokensSaved += Math.ceil( + (result.before - result.after) / 4 + ); + return result.compressed; + } + return description; +} + +export function compressDescriptionsInPlace( + value: unknown, + fieldNames: string[] = ["description"], + options: DescriptionCompressionOptions = {} +): void { + if (!value || typeof value !== "object") return; + const fields = new Set(fieldNames); + if (Array.isArray(value)) { + for (const item of value) compressDescriptionsInPlace(item, fieldNames, options); + return; + } + + for (const [key, nested] of Object.entries(value as Record)) { + if (fields.has(key) && typeof nested === "string") { + (value as Record)[key] = maybeCompressMcpDescription(nested, options); + } else if (nested && typeof nested === "object") { + compressDescriptionsInPlace(nested, fieldNames, options); + } + } +} + +function clonePlainMetadata(value: T): T { + if (!value || typeof value !== "object") return value; + return JSON.parse(JSON.stringify(value)) as T; +} + +function compressMcpListContainersInPlace( + value: unknown, + options: DescriptionCompressionOptions = {} +): void { + if (!value || typeof value !== "object") return; + if (Array.isArray(value)) { + for (const item of value) compressMcpListContainersInPlace(item, options); + return; + } + + for (const [key, nested] of Object.entries(value as Record)) { + if (MCP_LIST_CONTAINER_KEYS.has(key) && Array.isArray(nested)) { + compressDescriptionsInPlace(nested, MCP_METADATA_DESCRIPTION_FIELDS, options); + } else if (nested && typeof nested === "object") { + compressMcpListContainersInPlace(nested, options); + } + } +} + +export function compressMcpListMetadata( + value: T, + options: DescriptionCompressionOptions = {} +): T { + if (!isMcpDescriptionCompressionEnabled(options)) return value; + const clone = clonePlainMetadata(value); + compressMcpListContainersInPlace(clone, options); + return clone; +} + +export function compressMcpRegistryMetadata>( + metadata: T, + options: DescriptionCompressionOptions = {} +): T { + if (!isMcpDescriptionCompressionEnabled(options)) return metadata; + const clone: Record = { ...metadata }; + if (typeof clone.description === "string") { + clone.description = maybeCompressMcpDescription(clone.description, options); + } + return clone as T; +} + +export function getMcpDescriptionCompressionStats(): McpDescriptionCompressionStats { + return { ...descriptionCompressionStats }; +} + +function getUnpersistedMcpDescriptionCompressionStats(): McpDescriptionCompressionStats { + return { + descriptionsCompressed: + descriptionCompressionStats.descriptionsCompressed - + persistedDescriptionCompressionStats.descriptionsCompressed, + charsBefore: + descriptionCompressionStats.charsBefore - persistedDescriptionCompressionStats.charsBefore, + charsAfter: + descriptionCompressionStats.charsAfter - persistedDescriptionCompressionStats.charsAfter, + charsSaved: + descriptionCompressionStats.charsSaved - persistedDescriptionCompressionStats.charsSaved, + estimatedTokensSaved: + descriptionCompressionStats.estimatedTokensSaved - + persistedDescriptionCompressionStats.estimatedTokensSaved, + }; +} + +export async function snapshotMcpDescriptionCompressionStats(): Promise { + const delta = getUnpersistedMcpDescriptionCompressionStats(); + if ( + delta.descriptionsCompressed <= 0 || + delta.charsSaved <= 0 || + delta.estimatedTokensSaved <= 0 + ) { + return { + descriptionsCompressed: 0, + charsBefore: 0, + charsAfter: 0, + charsSaved: 0, + estimatedTokensSaved: 0, + }; + } + + const originalTokens = Math.max(delta.estimatedTokensSaved, Math.ceil(delta.charsBefore / 4)); + const compressedTokens = Math.max(0, originalTokens - delta.estimatedTokensSaved); + const { insertCompressionAnalyticsRow } = + await import("../../src/lib/db/compressionAnalytics.ts"); + insertCompressionAnalyticsRow({ + timestamp: new Date().toISOString(), + mode: "mcp-description", + engine: "mcp-description", + original_tokens: originalTokens, + compressed_tokens: compressedTokens, + tokens_saved: delta.estimatedTokensSaved, + mcp_description_tokens_saved: delta.estimatedTokensSaved, + }); + + persistedDescriptionCompressionStats.descriptionsCompressed = + descriptionCompressionStats.descriptionsCompressed; + persistedDescriptionCompressionStats.charsBefore = descriptionCompressionStats.charsBefore; + persistedDescriptionCompressionStats.charsAfter = descriptionCompressionStats.charsAfter; + persistedDescriptionCompressionStats.charsSaved = descriptionCompressionStats.charsSaved; + persistedDescriptionCompressionStats.estimatedTokensSaved = + descriptionCompressionStats.estimatedTokensSaved; + + return delta; +} + +export function resetMcpDescriptionCompressionStats(): void { + descriptionCompressionStats.descriptionsCompressed = 0; + descriptionCompressionStats.charsBefore = 0; + descriptionCompressionStats.charsAfter = 0; + descriptionCompressionStats.charsSaved = 0; + descriptionCompressionStats.estimatedTokensSaved = 0; + persistedDescriptionCompressionStats.descriptionsCompressed = 0; + persistedDescriptionCompressionStats.charsBefore = 0; + persistedDescriptionCompressionStats.charsAfter = 0; + persistedDescriptionCompressionStats.charsSaved = 0; + persistedDescriptionCompressionStats.estimatedTokensSaved = 0; +} diff --git a/open-sse/mcp-server/schemas/tools.ts b/open-sse/mcp-server/schemas/tools.ts index 401f39d3d5..7f829b1529 100644 --- a/open-sse/mcp-server/schemas/tools.ts +++ b/open-sse/mcp-server/schemas/tools.ts @@ -10,6 +10,10 @@ */ import { z } from "zod"; +import { + AUTO_ROUTING_STRATEGY_VALUES, + ROUTING_STRATEGY_VALUES, +} from "../../../src/shared/constants/routingStrategies.ts"; // ============ Shared Types ============ @@ -109,17 +113,7 @@ export const listCombosOutput = z.object({ priority: z.number(), }) ), - strategy: z.enum([ - "priority", - "weighted", - "round-robin", - "context-relay", - "strict-random", - "random", - "least-used", - "cost-optimized", - "auto", - ]), + strategy: z.enum(ROUTING_STRATEGY_VALUES), enabled: z.boolean(), metrics: z .object({ @@ -544,21 +538,9 @@ export const setBudgetGuardTool: McpToolDefinition< // --- Tool 11: omniroute_set_routing_strategy --- export const setRoutingStrategyInput = z.object({ comboId: z.string().describe("Combo ID or name to update"), - strategy: z - .enum([ - "priority", - "weighted", - "round-robin", - "context-relay", - "strict-random", - "random", - "least-used", - "cost-optimized", - "auto", - ]) - .describe("Routing strategy to apply"), + strategy: z.enum(ROUTING_STRATEGY_VALUES).describe("Routing strategy to apply"), autoRoutingStrategy: z - .enum(["rules", "cost", "eco", "latency", "fast"]) + .enum(AUTO_ROUTING_STRATEGY_VALUES) .optional() .describe("Optional strategy used by auto mode (only used when strategy='auto')"), }); @@ -1000,14 +982,41 @@ export const compressionStatusOutput = z.object({ strategy: z.string(), settings: z.object({ maxTokens: z.number(), + autoTriggerMode: z.string(), targetRatio: z.number(), - aggressiveness: z.string(), + preserveSystemPrompt: z.boolean(), + mcpDescriptionCompressionEnabled: z.boolean(), }), analytics: z.object({ totalRequests: z.number(), compressedRequests: z.number(), tokensSaved: z.number(), avgCompressionRatio: z.number(), + byMode: z.record( + z.string(), + z.object({ + count: z.number(), + tokensSaved: z.number(), + avgSavingsPct: z.number(), + }) + ), + validationFallbacks: z.number(), + requestsWithReceipts: z.number(), + realUsage: z.object({ + requestsWithReceipts: z.number(), + promptTokens: z.number(), + completionTokens: z.number(), + totalTokens: z.number(), + cacheReadTokens: z.number(), + cacheWriteTokens: z.number(), + estimatedUsdSaved: z.number(), + bySource: z.record(z.string(), z.number()), + }), + mcpDescriptionCompression: z.object({ + descriptionsCompressed: z.number(), + charsSaved: z.number(), + estimatedTokensSaved: z.number(), + }), }), cacheStats: z .object({ @@ -1037,12 +1046,21 @@ export const compressionStatusTool: McpToolDefinition< export const compressionConfigureInput = z.object({ enabled: z.boolean().optional(), strategy: z - .string() + .enum(["off", "lite", "standard", "aggressive", "ultra", "rtk", "stacked"]) .optional() - .describe("Compression strategy: 'none' | 'standard' | 'aggressive' | 'ultra'"), - maxTokens: z.number().optional().describe("Maximum tokens before compression triggers"), + .describe("Compression mode"), + autoTriggerMode: z + .enum(["off", "lite", "standard", "aggressive", "ultra", "rtk", "stacked"]) + .optional(), + maxTokens: z + .number() + .int() + .min(0) + .optional() + .describe("Maximum tokens before compression triggers"), targetRatio: z.number().optional().describe("Target compression ratio (0.0–1.0)"), - aggressiveness: z.string().optional().describe("Aggressiveness level: 'low' | 'medium' | 'high'"), + preserveSystemPrompt: z.boolean().optional(), + mcpDescriptionCompressionEnabled: z.boolean().optional(), }); export const compressionConfigureOutput = z.object({ @@ -1051,9 +1069,11 @@ export const compressionConfigureOutput = z.object({ settings: z.object({ enabled: z.boolean(), strategy: z.string(), + autoTriggerMode: z.string(), maxTokens: z.number(), targetRatio: z.number(), - aggressiveness: z.string(), + preserveSystemPrompt: z.boolean(), + mcpDescriptionCompressionEnabled: z.boolean(), }), }); @@ -1063,7 +1083,7 @@ export const compressionConfigureTool: McpToolDefinition< > = { name: "omniroute_compression_configure", description: - "Configure compression settings at runtime. Supports enabling/disabling compression, changing strategy (none/standard/aggressive/ultra), adjusting maxTokens threshold, targetRatio, and aggressiveness level.", + "Configure compression settings at runtime. Supports enabling/disabling compression, changing strategy (off/lite/standard/aggressive/ultra/rtk/stacked), adjusting maxTokens threshold, targetRatio, auto-trigger mode, system prompt preservation, and MCP description compression.", inputSchema: compressionConfigureInput, outputSchema: compressionConfigureOutput, scopes: ["write:compression"], @@ -1072,6 +1092,72 @@ export const compressionConfigureTool: McpToolDefinition< sourceEndpoints: ["/api/compression/configure"], }; +export const setCompressionEngineInput = z.object({ + engine: z.enum(["off", "caveman", "rtk", "stacked"]).optional(), + cavemanIntensity: z.enum(["lite", "full", "ultra"]).optional(), + rtkIntensity: z.enum(["minimal", "standard", "aggressive"]).optional(), + outputMode: z.boolean().optional(), +}); + +export const setCompressionEngineOutput = z.object({ + success: z.boolean(), + settings: z.record(z.string(), z.unknown()), +}); + +export const setCompressionEngineTool: McpToolDefinition< + typeof setCompressionEngineInput, + typeof setCompressionEngineOutput +> = { + name: "omniroute_set_compression_engine", + description: "Set the active compression engine and Caveman/RTK runtime options.", + inputSchema: setCompressionEngineInput, + outputSchema: setCompressionEngineOutput, + scopes: ["write:compression"], + auditLevel: "full", + phase: 2, + sourceEndpoints: ["/api/settings/compression", "/api/context/rtk/config"], +}; + +export const listCompressionCombosInput = z.object({}); +export const listCompressionCombosOutput = z.object({ + combos: z.array(z.record(z.string(), z.unknown())), +}); + +export const listCompressionCombosTool: McpToolDefinition< + typeof listCompressionCombosInput, + typeof listCompressionCombosOutput +> = { + name: "omniroute_list_compression_combos", + description: "List compression combos and their engine pipelines.", + inputSchema: listCompressionCombosInput, + outputSchema: listCompressionCombosOutput, + scopes: ["read:compression"], + auditLevel: "basic", + phase: 2, + sourceEndpoints: ["/api/context/combos"], +}; + +export const compressionComboStatsInput = z.object({ + comboId: z.string().optional(), + since: z.enum(["24h", "7d", "30d", "all"]).optional(), +}); + +export const compressionComboStatsOutput = z.record(z.string(), z.unknown()); + +export const compressionComboStatsTool: McpToolDefinition< + typeof compressionComboStatsInput, + typeof compressionComboStatsOutput +> = { + name: "omniroute_compression_combo_stats", + description: "Get compression analytics grouped by engine and compression combo.", + inputSchema: compressionComboStatsInput, + outputSchema: compressionComboStatsOutput, + scopes: ["read:compression"], + auditLevel: "basic", + phase: 2, + sourceEndpoints: ["/api/context/analytics"], +}; + // ============ 1proxy Tools ============ export const oneproxyFetchInput = z.object({ @@ -1209,6 +1295,9 @@ export const MCP_TOOLS = [ cacheFlushTool, compressionStatusTool, compressionConfigureTool, + setCompressionEngineTool, + listCompressionCombosTool, + compressionComboStatsTool, oneproxyFetchTool, oneproxyRotateTool, oneproxyStatsTool, diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index 7b7d7d9c7f..d2b5e58c95 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -76,6 +76,8 @@ import { import { memoryTools } from "./tools/memoryTools.ts"; import { skillTools } from "./tools/skillTools.ts"; import { compressionTools } from "./tools/compressionTools.ts"; +import { compressMcpRegistryMetadata } from "./descriptionCompressor.ts"; +import { getDbInstance } from "../../src/lib/db/core.ts"; import { normalizeQuotaResponse } from "../../src/shared/contracts/quota.ts"; import { resolveOmniRouteBaseUrl } from "../../src/shared/utils/resolveOmniRouteBaseUrl.ts"; @@ -95,6 +97,18 @@ const TOTAL_MCP_TOOL_COUNT = type JsonRecord = Record; +function readMcpDescriptionCompressionEnabled(): boolean { + try { + const row = getDbInstance() + .prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?") + .get("compression", "mcpDescriptionCompressionEnabled") as { value?: string } | undefined; + if (!row?.value) return true; + return JSON.parse(row.value) !== false; + } catch { + return true; + } +} + type TextToolResult = { content: Array<{ type: "text"; text: string }>; isError?: boolean; @@ -577,6 +591,33 @@ export function createMcpServer(): McpServer { name: "omniroute", version: process.env.npm_package_version || "1.8.1", }); + const mcpDescriptionCompressionEnabled = readMcpDescriptionCompressionEnabled(); + const registerTool = server.registerTool.bind(server); + server.registerTool = ((name: string, config: Record, handler: unknown) => { + const metadata = compressMcpRegistryMetadata(config, { + enabled: mcpDescriptionCompressionEnabled, + }); + return registerTool(name, metadata, handler as never); + }) as typeof server.registerTool; + const registerPrompt = server.registerPrompt.bind(server); + server.registerPrompt = ((name: string, config: Record, handler: unknown) => { + const metadata = compressMcpRegistryMetadata(config, { + enabled: mcpDescriptionCompressionEnabled, + }); + return registerPrompt(name, metadata as never, handler as never); + }) as typeof server.registerPrompt; + const registerResource = server.registerResource.bind(server); + server.registerResource = (( + name: string, + uriOrTemplate: unknown, + config: Record, + readCallback: unknown + ) => { + const metadata = compressMcpRegistryMetadata(config, { + enabled: mcpDescriptionCompressionEnabled, + }); + return registerResource(name, uriOrTemplate as never, metadata as never, readCallback as never); + }) as typeof server.registerResource; // Register essential tools server.registerTool( diff --git a/open-sse/mcp-server/tools/advancedTools.ts b/open-sse/mcp-server/tools/advancedTools.ts index 2275990db2..723a061925 100644 --- a/open-sse/mcp-server/tools/advancedTools.ts +++ b/open-sse/mcp-server/tools/advancedTools.ts @@ -26,6 +26,11 @@ import { getComboModelString, getComboStepTarget, } from "../../../src/lib/combos/steps.ts"; +import type { + AutoRoutingStrategyValue, + RoutingStrategyValue, +} from "../../../src/shared/constants/routingStrategies.ts"; +import { normalizeRoutingStrategy } from "../../../src/shared/constants/routingStrategies.ts"; const OMNIROUTE_BASE_URL = resolveOmniRouteBaseUrl(); const OMNIROUTE_API_KEY = process.env.OMNIROUTE_API_KEY || ""; @@ -372,17 +377,8 @@ export async function handleSetBudgetGuard(args: { export async function handleSetRoutingStrategy(args: { comboId: string; - strategy: - | "priority" - | "weighted" - | "round-robin" - | "context-relay" - | "strict-random" - | "random" - | "least-used" - | "cost-optimized" - | "auto"; - autoRoutingStrategy?: "rules" | "cost" | "eco" | "latency" | "fast"; + strategy: RoutingStrategyValue; + autoRoutingStrategy?: AutoRoutingStrategyValue; }) { const start = Date.now(); try { @@ -424,8 +420,9 @@ export async function handleSetRoutingStrategy(args: { Object.keys(toRecord(combo.config)).length > 0 ? combo.config : comboData.config ); + const normalizedStrategy = normalizeRoutingStrategy(args.strategy); let nextConfig: JsonRecord | undefined = undefined; - if (args.strategy === "auto" && args.autoRoutingStrategy) { + if (normalizedStrategy === "auto" && args.autoRoutingStrategy) { const currentAutoConfig = toRecord(currentConfig.auto); nextConfig = { ...currentConfig, @@ -436,7 +433,7 @@ export async function handleSetRoutingStrategy(args: { }; } - const payload: JsonRecord = { strategy: args.strategy }; + const payload: JsonRecord = { strategy: normalizedStrategy }; if (nextConfig && Object.keys(nextConfig).length > 0) { payload.config = nextConfig; } @@ -451,16 +448,18 @@ export async function handleSetRoutingStrategy(args: { const updatedConfig = toRecord(updatedCombo.config); const resolvedAutoStrategy = toString(toRecord(updatedConfig.auto).routingStrategy) || - (args.strategy === "auto" ? (args.autoRoutingStrategy ?? "rules") : ""); + (normalizedStrategy === "auto" ? (args.autoRoutingStrategy ?? "rules") : ""); const result = { success: true, combo: { id: toString(updatedCombo.id, comboId), name: toString(updatedCombo.name, toString(combo.name, comboId)), - strategy: toString(updatedCombo.strategy, args.strategy), + strategy: toString(updatedCombo.strategy, normalizedStrategy), autoRoutingStrategy: - toString(updatedCombo.strategy, args.strategy) === "auto" ? resolvedAutoStrategy : null, + toString(updatedCombo.strategy, normalizedStrategy) === "auto" + ? resolvedAutoStrategy + : null, }, }; diff --git a/open-sse/mcp-server/tools/compressionTools.ts b/open-sse/mcp-server/tools/compressionTools.ts index 795b053ef7..382a977634 100644 --- a/open-sse/mcp-server/tools/compressionTools.ts +++ b/open-sse/mcp-server/tools/compressionTools.ts @@ -13,7 +13,12 @@ import { } from "../../../src/lib/db/compression.ts"; import { getCompressionAnalyticsSummary } from "../../../src/lib/db/compressionAnalytics.ts"; import { getCacheStatsSummary } from "../../../src/lib/db/compressionCacheStats.ts"; +import { listCompressionCombos } from "../../../src/lib/db/compressionCombos.ts"; import type { McpToolExtraLike } from "../scopeEnforcement.ts"; +import { + getMcpDescriptionCompressionStats, + snapshotMcpDescriptionCompressionStats, +} from "../descriptionCompressor.ts"; /** * Handle compression_status tool: return current compression config, analytics, and cache stats @@ -26,14 +31,42 @@ export async function handleCompressionStatus( strategy: string; settings: { maxTokens: number; + autoTriggerMode: string; targetRatio: number; - aggressiveness: string; + preserveSystemPrompt: boolean; + mcpDescriptionCompressionEnabled: boolean; }; analytics: { totalRequests: number; compressedRequests: number; tokensSaved: number; avgCompressionRatio: number; + byMode: Record; + byEngine: Record; + byCompressionCombo: Record; + validationFallbacks: number; + requestsWithReceipts: number; + realUsage: { + requestsWithReceipts: number; + promptTokens: number; + completionTokens: number; + totalTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + estimatedUsdSaved: number; + bySource: Record; + }; + mcpDescriptionCompression: { + descriptionsCompressed: number; + charsBefore: number; + charsAfter: number; + charsSaved: number; + estimatedTokensSaved: number; + persistedEstimatedTokensSaved: number; + persistedSnapshots: number; + source: "mcp_metadata_estimate"; + notProviderUsage: true; + }; }; cacheStats: { hits: number; @@ -45,7 +78,9 @@ export async function handleCompressionStatus( const start = Date.now(); try { const settings = await getCompressionSettings(); + await snapshotMcpDescriptionCompressionStats(); const analyticsSummary = getCompressionAnalyticsSummary(); + const mcpDescriptionStats = getMcpDescriptionCompressionStats(); const cacheStats = getCacheStatsSummary(); const result = { @@ -53,14 +88,37 @@ export async function handleCompressionStatus( strategy: settings.defaultMode || "standard", settings: { maxTokens: settings.autoTriggerTokens, + autoTriggerMode: settings.autoTriggerMode ?? "lite", targetRatio: 0.7, // Default target ratio - aggressiveness: settings.defaultMode || "standard", + preserveSystemPrompt: settings.preserveSystemPrompt, + mcpDescriptionCompressionEnabled: settings.mcpDescriptionCompressionEnabled !== false, }, analytics: { totalRequests: analyticsSummary.totalRequests, - compressedRequests: analyticsSummary.byMode?.standard?.count || 0, + compressedRequests: Object.values(analyticsSummary.byMode ?? {}).reduce( + (sum, mode) => sum + mode.count, + 0 + ), tokensSaved: analyticsSummary.totalTokensSaved, - avgCompressionRatio: analyticsSummary.byMode?.standard?.avgSavingsPct || 0, + avgCompressionRatio: analyticsSummary.avgSavingsPct, + byMode: analyticsSummary.byMode ?? {}, + byEngine: analyticsSummary.byEngine ?? {}, + byCompressionCombo: analyticsSummary.byCompressionCombo ?? {}, + validationFallbacks: analyticsSummary.validationFallbacks, + requestsWithReceipts: analyticsSummary.realUsage.requestsWithReceipts, + realUsage: analyticsSummary.realUsage, + mcpDescriptionCompression: { + descriptionsCompressed: mcpDescriptionStats.descriptionsCompressed, + charsBefore: mcpDescriptionStats.charsBefore, + charsAfter: mcpDescriptionStats.charsAfter, + charsSaved: mcpDescriptionStats.charsSaved, + estimatedTokensSaved: mcpDescriptionStats.estimatedTokensSaved, + persistedEstimatedTokensSaved: + analyticsSummary.mcpDescriptionCompression.estimatedTokensSaved, + persistedSnapshots: analyticsSummary.mcpDescriptionCompression.snapshots, + source: "mcp_metadata_estimate" as const, + notProviderUsage: true as const, + }, }, cacheStats: cacheStats ? { @@ -98,9 +156,11 @@ export async function handleCompressionConfigure( args: { enabled?: boolean; strategy?: string; + autoTriggerMode?: string; maxTokens?: number; targetRatio?: number; - aggressiveness?: string; + preserveSystemPrompt?: boolean; + mcpDescriptionCompressionEnabled?: boolean; }, extra?: McpToolExtraLike ): Promise<{ @@ -109,9 +169,11 @@ export async function handleCompressionConfigure( settings: { enabled: boolean; strategy: string; + autoTriggerMode: string; maxTokens: number; targetRatio: number; - aggressiveness: string; + preserveSystemPrompt: boolean; + mcpDescriptionCompressionEnabled: boolean; }; }> { const start = Date.now(); @@ -124,11 +186,17 @@ export async function handleCompressionConfigure( if (args.strategy !== undefined) { updates.defaultMode = args.strategy; } + if (args.autoTriggerMode !== undefined) { + updates.autoTriggerMode = args.autoTriggerMode; + } if (args.maxTokens !== undefined) { updates.autoTriggerTokens = args.maxTokens; } - if (args.aggressiveness !== undefined) { - updates.defaultMode = args.aggressiveness; + if (args.preserveSystemPrompt !== undefined) { + updates.preserveSystemPrompt = args.preserveSystemPrompt; + } + if (args.mcpDescriptionCompressionEnabled !== undefined) { + updates.mcpDescriptionCompressionEnabled = args.mcpDescriptionCompressionEnabled; } const settings = await updateCompressionSettings(updates); @@ -139,9 +207,11 @@ export async function handleCompressionConfigure( settings: { enabled: settings.enabled, strategy: settings.defaultMode || "standard", + autoTriggerMode: settings.autoTriggerMode ?? "lite", maxTokens: settings.autoTriggerTokens, targetRatio: 0.7, // Default target ratio - aggressiveness: settings.defaultMode || "standard", + preserveSystemPrompt: settings.preserveSystemPrompt, + mcpDescriptionCompressionEnabled: settings.mcpDescriptionCompressionEnabled !== false, }, }; @@ -165,7 +235,64 @@ export async function handleCompressionConfigure( } import { z } from "zod"; -import { compressionStatusInput, compressionConfigureInput } from "../schemas/tools.ts"; +import { + compressionStatusInput, + compressionConfigureInput, + setCompressionEngineInput, + listCompressionCombosInput, + compressionComboStatsInput, +} from "../schemas/tools.ts"; + +export async function handleSetCompressionEngine( + args: z.infer +): Promise<{ success: boolean; settings: Record }> { + const updates: Record = { enabled: true }; + if (args.engine) { + updates.defaultMode = args.engine === "caveman" ? "standard" : args.engine; + if (args.engine === "off") updates.enabled = false; + } + if (args.cavemanIntensity) { + const current = await getCompressionSettings(); + updates.cavemanConfig = { + ...(current.cavemanConfig ?? {}), + intensity: args.cavemanIntensity, + }; + } + if (args.rtkIntensity) { + const current = await getCompressionSettings(); + updates.rtkConfig = { + ...(current.rtkConfig ?? {}), + intensity: args.rtkIntensity, + }; + } + if (args.outputMode !== undefined) { + const current = await getCompressionSettings(); + updates.cavemanOutputMode = { + ...(current.cavemanOutputMode ?? {}), + enabled: args.outputMode, + }; + } + const settings = await updateCompressionSettings(updates); + return { success: true, settings: settings as unknown as Record }; +} + +export async function handleListCompressionCombos(): Promise<{ + combos: ReturnType; +}> { + return { combos: listCompressionCombos() }; +} + +export async function handleCompressionComboStats( + args: z.infer +): Promise> { + const summary = getCompressionAnalyticsSummary(args.since === "all" ? undefined : args.since); + if (!args.comboId) return summary as unknown as Record; + return { + comboId: args.comboId, + summary, + combo: summary.byCompressionCombo[args.comboId] ?? { count: 0, tokensSaved: 0 }, + }; +} export const compressionTools = { omniroute_compression_status: { @@ -178,8 +305,27 @@ export const compressionTools = { omniroute_compression_configure: { name: "omniroute_compression_configure", description: - "Configure compression settings at runtime. Supports enabling/disabling compression, changing strategy (none/standard/aggressive/ultra), adjusting maxTokens threshold, targetRatio, and aggressiveness level.", + "Configure compression settings at runtime. Supports enabling/disabling compression, changing strategy (off/lite/standard/aggressive/ultra/rtk/stacked), adjusting maxTokens threshold, targetRatio, auto-trigger mode, system prompt preservation, and MCP description compression.", inputSchema: compressionConfigureInput, handler: (args: z.infer) => handleCompressionConfigure(args), }, + omniroute_set_compression_engine: { + name: "omniroute_set_compression_engine", + description: "Set the active compression engine and Caveman/RTK runtime options.", + inputSchema: setCompressionEngineInput, + handler: (args: z.infer) => handleSetCompressionEngine(args), + }, + omniroute_list_compression_combos: { + name: "omniroute_list_compression_combos", + description: "List compression combos and their engine pipelines.", + inputSchema: listCompressionCombosInput, + handler: (_args: z.infer) => handleListCompressionCombos(), + }, + omniroute_compression_combo_stats: { + name: "omniroute_compression_combo_stats", + description: "Get compression analytics grouped by engine and compression combo.", + inputSchema: compressionComboStatsInput, + handler: (args: z.infer) => + handleCompressionComboStats(args), + }, }; diff --git a/open-sse/package.json b/open-sse/package.json index 8654cf7640..2d62ae8fa9 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.7.8", + "version": "3.7.9", "description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration", "type": "module", "main": "index.js", diff --git a/open-sse/services/AGENTS.md b/open-sse/services/AGENTS.md index b7b99308d0..d13f4b1ea4 100644 --- a/open-sse/services/AGENTS.md +++ b/open-sse/services/AGENTS.md @@ -51,12 +51,15 @@ ### Prompt Compression Pipeline - **`compression/`** — Modular prompt compression running proactively before `contextManager.ts`. - - `strategySelector.ts` — Selects mode (off/lite/standard/aggressive/ultra) with combo overrides and auto-trigger. + - `strategySelector.ts` — Selects mode (off/lite/standard/aggressive/ultra/rtk/stacked) with compression combo assignments, combo overrides, and auto-trigger. - `lite.ts` — 5 lite techniques: whitespace collapse, system prompt dedup, tool result truncation, redundant removal, image URL placeholder. + - `caveman.ts` / `cavemanRules.ts` — Caveman-style semantic condensation with file-loaded rule packs and language-aware rule selection. + - `engines/registry.ts` — Engine registry used by standalone RTK/Caveman execution and stacked pipelines. + - `engines/rtk/` — RTK tool-output compression: command detection, JSON filter packs, deduplication, smart truncation, ANSI/code noise stripping. - `stats.ts` — Per-request compression stats (original/compressed tokens, savings %, techniques). - `types.ts` — Shared types (`CompressionMode`, `CompressionConfig`, `CompressionStats`, `CompressionResult`). - `index.ts` — Barrel re-exports. - - Phase 1: lite mode only. Standard/aggressive/ultra = Phase 2. + - Dashboard/API surface: `/dashboard/context/caveman`, `/dashboard/context/rtk`, `/dashboard/context/combos`, `/api/context/*`, and `/api/compression/preview`. ### Auto-Routing & Adaptive diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 6b75933e44..3b1eb5a871 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -511,12 +511,27 @@ export function getAllModelLockouts() { // ─── Provider Breaker Compatibility Wrappers ──────────────────────────────── // Legacy helpers now delegate to the shared provider circuit breaker. +type ProviderBreakerProfile = Partial< + Pick< + ProviderProfile, + "failureThreshold" | "resetTimeoutMs" | "circuitBreakerThreshold" | "circuitBreakerReset" + > +>; + function getProviderBreaker(provider: string | null | undefined) { + return provider ? getCircuitBreaker(provider) : null; +} + +function configureProviderBreaker( + provider: string | null | undefined, + profile?: ProviderBreakerProfile | null +) { if (!provider) return null; - const profile = getProviderProfile(provider); + + const resolvedProfile = { ...getProviderProfile(provider), ...(profile ?? {}) }; return getCircuitBreaker(provider, { - failureThreshold: profile.failureThreshold ?? profile.circuitBreakerThreshold, - resetTimeout: profile.resetTimeoutMs ?? profile.circuitBreakerReset, + failureThreshold: resolvedProfile.failureThreshold ?? resolvedProfile.circuitBreakerThreshold, + resetTimeout: resolvedProfile.resetTimeoutMs ?? resolvedProfile.circuitBreakerReset, }); } @@ -551,7 +566,8 @@ export function getProviderCooldownRemainingMs(provider: string | null | undefin export function recordProviderFailure( provider: string | null | undefined, log?: { warn?: (...args: unknown[]) => void }, - connectionId?: string | null + connectionId?: string | null, + profile?: ProviderBreakerProfile | null ): void { if (!provider) return; @@ -570,7 +586,7 @@ export function recordProviderFailure( lastConnectionFailure.set(dedupKey, now); } - const breaker = getProviderBreaker(provider); + const breaker = configureProviderBreaker(provider, profile); if (!breaker) return; if (!breaker.canExecute()) return; diff --git a/open-sse/services/autoCombo/index.ts b/open-sse/services/autoCombo/index.ts index 045ffaaf8d..062dbccacf 100644 --- a/open-sse/services/autoCombo/index.ts +++ b/open-sse/services/autoCombo/index.ts @@ -15,13 +15,4 @@ export { export { getTaskFitness, getTaskTypes } from "./taskFitness"; export { SelfHealingManager, getSelfHealingManager } from "./selfHealing"; export { MODE_PACKS, getModePack, getModePackNames } from "./modePacks"; -export { - selectProvider, - createAutoCombo, - getAutoCombo, - updateAutoCombo, - deleteAutoCombo, - listAutoCombos, - type AutoComboConfig, - type SelectionResult, -} from "./engine"; +export { selectProvider, type AutoComboConfig, type SelectionResult } from "./engine"; diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 93a84f4741..8bd9efa5e9 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -48,6 +48,7 @@ import { resolveRequestRoutingTags, type RoutingTagMatchMode, } from "../../src/domain/tagRouter.ts"; +import { normalizeRoutingStrategy } from "../../src/shared/constants/routingStrategies.ts"; // Status codes that should mark round-robin target semaphores as cooling down. const TRANSIENT_FOR_SEMAPHORE = [429, 502, 503, 504]; @@ -630,17 +631,25 @@ function sortTargetsByUsage(targets: ResolvedComboTarget[], comboName: string) { */ function sortModelsByContextSize(models) { const withContext = models.map((modelStr) => { - const parsed = parseModel(modelStr); - const provider = parsed.provider || parsed.providerAlias || "unknown"; - const model = parsed.model || modelStr; - const limit = getModelContextLimit(provider, model); - return { modelStr, context: limit ?? 0 }; + return { modelStr, context: getModelContextLimitForModelString(modelStr) ?? 0 }; }); withContext.sort((a, b) => b.context - a.context); return withContext.map((e) => e.modelStr); } +function getModelContextLimitForModelString(modelStr: string) { + const parsed = parseModel(modelStr); + const provider = parsed.provider || parsed.providerAlias || "unknown"; + const model = parsed.model || modelStr; + return getModelContextLimit(provider, model); +} + function sortTargetsByContextSize(targets: ResolvedComboTarget[]) { + const hasKnownContext = targets.some( + (target) => getModelContextLimitForModelString(target.modelStr) != null + ); + if (!hasKnownContext) return targets; + const orderedModels = sortModelsByContextSize(targets.map((target) => target.modelStr)); const byModel = new Map(); for (const target of targets) { @@ -656,6 +665,38 @@ function sortTargetsByContextSize(targets: ResolvedComboTarget[]) { .filter((target): target is ResolvedComboTarget => target !== null); } +function getP2CTargetScore( + target: ResolvedComboTarget, + metrics: ReturnType +): number { + const breakerState = getCircuitBreaker(target.provider)?.getStatus?.()?.state; + if (breakerState === "OPEN") return -Infinity; + const modelMetric = metrics?.byModel?.[target.modelStr] || null; + const successRate = Number(modelMetric?.successRate); + const avgLatency = Number(modelMetric?.avgLatencyMs); + const successScore = Number.isFinite(successRate) ? successRate / 100 : 0.5; + const latencyScore = + Number.isFinite(avgLatency) && avgLatency > 0 ? 1 / Math.log10(avgLatency + 10) : 0.25; + const breakerPenalty = breakerState === "HALF_OPEN" ? 0.25 : 0; + return successScore + latencyScore - breakerPenalty; +} + +function orderTargetsByPowerOfTwoChoices(targets: ResolvedComboTarget[], comboName: string) { + if (targets.length <= 1) return targets; + const metrics = getComboMetrics(comboName); + const firstIndex = Math.floor(Math.random() * targets.length); + let secondIndex = Math.floor(Math.random() * (targets.length - 1)); + if (secondIndex >= firstIndex) secondIndex++; + + const first = targets[firstIndex]; + const second = targets[secondIndex]; + const selectedIndex = + getP2CTargetScore(second, metrics) > getP2CTargetScore(first, metrics) + ? secondIndex + : firstIndex; + return [targets[selectedIndex], ...targets.filter((_, index) => index !== selectedIndex)]; +} + function toTextContent(content) { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; @@ -1034,7 +1075,7 @@ export async function handleComboChat({ relayOptions, signal, }) { - const strategy = combo.strategy || "priority"; + const strategy = normalizeRoutingStrategy(combo.strategy || "priority"); const relayConfig = strategy === "context-relay" ? resolveContextRelayConfig(relayOptions?.config || null) : null; @@ -1427,6 +1468,14 @@ export async function handleComboChat({ } else if (strategy === "random") { orderedTargets = fisherYatesShuffle([...orderedTargets]); log.info("COMBO", `Random shuffle: ${orderedTargets.length} targets`); + } else if (strategy === "fill-first") { + log.info( + "COMBO", + `Fill-first ordering: preserving priority order (${orderedTargets.length} targets)` + ); + } else if (strategy === "p2c") { + orderedTargets = orderTargetsByPowerOfTwoChoices(orderedTargets, combo.name); + log.info("COMBO", `Power-of-two-choices ordering: selected ${orderedTargets[0]?.modelStr}`); } else if (strategy === "least-used") { orderedTargets = sortTargetsByUsage(orderedTargets, combo.name); log.info("COMBO", `Least-used ordering: ${orderedTargets[0]?.modelStr} has fewest requests`); @@ -1678,7 +1727,7 @@ export async function handleComboChat({ // Trigger shared provider circuit breaker for 5xx errors and connection failures if (isProviderFailureCode(result.status)) { - recordProviderFailure(provider, log, target.connectionId); + recordProviderFailure(provider, log, target.connectionId, profile); } // Check if this is a transient error worth retrying on same model diff --git a/open-sse/services/compression/aggressive.ts b/open-sse/services/compression/aggressive.ts index 8e2d3e85c4..a1edf3536a 100644 --- a/open-sse/services/compression/aggressive.ts +++ b/open-sse/services/compression/aggressive.ts @@ -5,39 +5,19 @@ import { applyAging } from "./progressiveAging.ts"; import { RuleBasedSummarizer } from "./summarizer.ts"; import { cavemanCompress } from "./caveman.ts"; import { applyLiteCompression } from "./lite.ts"; +import { extractTextContent, replaceTextContent, type ChatMessageLike } from "./messageContent.ts"; const COMPRESSED_MARKER_RE = /^\[COMPRESSED:/; -interface ChatMessage { - role: string; - content?: string | Array<{ type: string; text?: string }>; - [key: string]: unknown; -} +type ChatMessage = ChatMessageLike; interface AggressiveCompressionResult { messages: ChatMessage[]; stats: CompressionStats; } -function extractText(content?: string | Array<{ type: string; text?: string }>): string { - if (typeof content === "string") return content; - if (Array.isArray(content)) { - return content - .filter( - (p): p is { type: string; text?: string } => - typeof p === "object" && p !== null && "text" in p - ) - .map((p) => p.text ?? "") - .join("\n"); - } - return ""; -} - function setContent(msg: ChatMessage, newContent: string): ChatMessage { - if (typeof msg.content === "string") { - return { ...msg, content: newContent }; - } - return { ...msg, content: [{ type: "text", text: newContent }] }; + return replaceTextContent(msg, newContent) as ChatMessage; } function estimateTokens(text: string): number { @@ -71,7 +51,7 @@ export function compressAggressive( }; const originalTokens = messages.reduce( - (sum, m) => sum + estimateTokens(extractText(m.content)), + (sum, m) => sum + estimateTokens(extractTextContent(m.content)), 0 ); resultStats.originalTokens = originalTokens; @@ -84,8 +64,9 @@ export function compressAggressive( // Step 1: Tool-result compression try { const afterToolResult = currentMessages.map((msg) => { + if (cfg.preserveSystemPrompt !== false && msg.role === "system") return msg; if (msg.role !== "tool" && msg.role !== "function") return msg; - const text = extractText(msg.content); + const text = extractTextContent(msg.content); if (!text || COMPRESSED_MARKER_RE.test(text)) return msg; const result = compressToolResult(text, cfg.toolStrategies); @@ -101,7 +82,12 @@ export function compressAggressive( // Step 2: Progressive aging try { - const agingResult = applyAging(currentMessages, cfg.thresholds, summarizer); + const agingResult = applyAging( + currentMessages, + cfg.thresholds, + summarizer, + cfg.preserveSystemPrompt !== false + ); agingSavings = agingResult.saved; currentMessages = agingResult.messages as ChatMessage[]; } catch (err) { @@ -112,7 +98,8 @@ export function compressAggressive( if (cfg.summarizerEnabled) { try { currentMessages = currentMessages.map((msg) => { - const text = extractText(msg.content); + if (cfg.preserveSystemPrompt !== false && msg.role === "system") return msg; + const text = extractTextContent(msg.content); if (!text || COMPRESSED_MARKER_RE.test(text)) return msg; if (text.length <= cfg.maxTokensPerMessage * 4) return msg; @@ -133,7 +120,7 @@ export function compressAggressive( // Downgrade chain: if total savings < threshold, try caveman then lite const compressedTokens = currentMessages.reduce( - (sum, m) => sum + estimateTokens(extractText(m.content)), + (sum, m) => sum + estimateTokens(extractTextContent(m.content)), 0 ); resultStats.compressedTokens = compressedTokens; @@ -157,7 +144,10 @@ export function compressAggressive( } try { - const liteResult = applyLiteCompression({ messages: currentMessages }); + const liteResult = applyLiteCompression( + { messages: currentMessages }, + { preserveSystemPrompt: cfg.preserveSystemPrompt !== false } + ); if (liteResult?.compressed && liteResult.stats) { const liteSavings = liteResult.stats.savingsPercent ?? 0; if (liteSavings > resultStats.savingsPercent) { diff --git a/open-sse/services/compression/caveman.ts b/open-sse/services/compression/caveman.ts index e0c40ecdfa..476be8d41e 100644 --- a/open-sse/services/compression/caveman.ts +++ b/open-sse/services/compression/caveman.ts @@ -1,12 +1,22 @@ -import type { CavemanConfig, CavemanRule, CompressionResult, CompressionMode } from "./types.ts"; +import type { + CavemanConfig, + CavemanRule, + CompressionResult, + CompressionMode, + CompressionStats, +} from "./types.ts"; import { DEFAULT_CAVEMAN_CONFIG } from "./types.ts"; import { CAVEMAN_RULES, getRulesForContext } from "./cavemanRules.ts"; import { extractPreservedBlocks, restorePreservedBlocks } from "./preservation.ts"; import { createCompressionStats, estimateCompressionTokens } from "./stats.ts"; +import { validateCompression } from "./validation.ts"; +import { mapTextContent } from "./messageContent.ts"; +import { detectCompressionLanguage } from "./languageDetector.ts"; interface ChatMessage { role: string; content?: string | Array<{ type: string; text?: string }>; + [key: string]: unknown; } interface ChatRequestBody { @@ -14,14 +24,165 @@ interface ChatRequestBody { [key: string]: unknown; } +const RULE_KEYWORDS: Record = { + redundant_phrasing: ["make sure", "be sure"], + redundant_because: ["due to the fact", "the reason is because"], + redundant_directive: ["it is important", "you should", "remember to"], + pleasantries: [ + "sure", + "certainly", + "of course", + "happy to", + "thanks", + "thank you", + "glad to help", + "glad to", + "no problem", + "you're welcome", + "youre welcome", + "absolutely", + ], + polite_framing: [ + "please", + "kindly", + "could you please", + "would you please", + "can you please", + "i would like you", + "i want you", + "i need you", + ], + hedging: [ + "it seems like", + "it appears that", + "i think that", + "i believe that", + "probably", + "possibly", + "maybe it", + ], + verbose_instructions: [ + "provide a detailed", + "give me a comprehensive", + "write an in-depth", + "create a thorough", + "explain in detail", + ], + filler_adverbs: ["basically", "essentially", "actually", "literally", "simply", "currently"], + filler_phrases: ["i want to", "i need to", "i'd like to", "i'm looking for"], + redundant_openers: ["hi there", "hello", "good morning", "hey"], + verbose_requests: ["i was wondering", "would it be possible"], + leader_phrases: [ + "i'll", + "i will", + "i can", + "i'd", + "let me", + "you can", + "we will", + "we can", + "let's", + ], + self_reference: ["i am trying to", "i am working on", "i have been"], + excessive_gratitude: ["thank you so much", "thanks in advance", "i really appreciate"], + qualifier_removal: ["a bit", "a little", "somewhat", "kind of", "sort of"], + softeners: ["if possible", "when you get a chance", "at your convenience", "just wondering"], + uncertainty_fillers: ["i guess", "i suppose", "more or less", "in a way"], + assistant_fillers: ["here's", "below is", "this is"], + compound_collapse: ["and any potential"], + explanatory_prefix: [ + "the function appears to be handling", + "the code seems to", + "the class is", + "this module is", + ], + question_to_directive: [ + "can you explain why", + "could you show me how", + "would you tell me", + "can you tell me", + ], + context_setup: ["i have the following code", "here is my code", "below is the code"], + intent_clarification: [ + "what i'm trying to do", + "my objective is to", + "what i need is", + "i'm aiming to", + ], + background_removal: ["as you may know", "as we discussed earlier"], + meta_commentary: ["note that", "keep in mind", "remember that"], + purpose_statement: ["for the purpose of", "with the goal of", "in an effort to", "for every"], + list_conjunction: ["and also", "as well as"], + purpose_phrases: ["in order to", "so as to"], + redundant_quantifiers: ["each and every", "any and all"], + all_quantifier: ["any and all"], + verbose_connectors: ["furthermore", "additionally", "moreover", "in addition"], + transition_removal: ["on the other hand", "in contrast", "however"], + emphasis_removal: ["very", "really", "extremely", "highly", "quite"], + passive_voice: [ + "is being used", + "is being called", + "is being generated", + "was created", + "was generated", + "was implemented", + ], + repeated_context: [ + "as we discussed earlier", + "as mentioned before", + "as previously stated", + "as i said before", + ], + repeated_question: [ + "same question as before", + "i asked this earlier", + "this is the same question", + ], + reestablished_context: ["going back to the code above", "referring back to", "returning to"], + summary_replacement: ["to summarize", "in summary of our conversation", "to recap"], + ultra_abbreviations: ["database"], + ultra_config_abbreviation: ["configuration"], + ultra_function_abbreviation: ["function"], + ultra_request_abbreviation: ["request"], + ultra_response_abbreviation: ["response"], + ultra_implementation_abbreviation: ["implementation"], + ultra_authentication_abbreviation: ["authentication"], + ultra_authorization_abbreviation: ["authorization"], + ultra_application_abbreviation: ["application"], + ultra_dependency_abbreviation: ["dependency", "dependencies"], + ultra_common_abbreviations: [ + "implementation", + "authentication", + "authorization", + "application", + "dependency", + "dependencies", + ], +}; + +const ARTICLE_HINT_RE = /\b(?:a|an|the)\b/; + +function shouldAttemptRule(ruleName: string, lowerText: string): boolean { + if (ruleName === "articles") { + ARTICLE_HINT_RE.lastIndex = 0; + return ARTICLE_HINT_RE.test(lowerText); + } + + const keywords = RULE_KEYWORDS[ruleName]; + return !keywords || keywords.some((keyword) => lowerText.includes(keyword)); +} + export function applyRulesToText( text: string, rules: CavemanRule[] ): { text: string; appliedRules: string[] } { let result = text; + const lowerResult = text.toLowerCase(); const appliedRules: string[] = []; for (const rule of rules) { + if (!shouldAttemptRule(rule.name, lowerResult)) continue; + const before = result; const { pattern, replacement } = rule; if (typeof replacement === "function") { @@ -43,14 +204,76 @@ export function applyRulesToText( function cleanupArtifacts(text: string): string { let result = text; - result = result.replace(/ +/g, " "); - result = result.replace(/ +$/gm, ""); - result = result.replace(/\n{3,}/g, "\n\n"); - result = result.replace(/^\n+/, ""); - result = result.replace(/\n+$/, ""); + if (result.includes(" ")) result = result.replace(/[ \t]{2,}/g, " "); + if (/[\t ]+[,.;:!?]/.test(result)) result = result.replace(/[ \t]+([,.;:!?])/g, "$1"); + if (/[.!?]{2,}/.test(result)) result = result.replace(/([.!?]){2,}/g, "$1"); + if (/[ \t]\n/.test(result)) result = result.replace(/[ \t]+$/gm, ""); + if (result.endsWith(" ") || result.endsWith("\t")) result = result.trimEnd(); + if (result.includes("\n\n\n")) result = result.replace(/\n{3,}/g, "\n\n"); + if (result.startsWith("\n")) result = result.replace(/^\n+/, ""); + if (result.endsWith("\n")) result = result.replace(/\n+$/, ""); return result; } +function recapitalizeSentences(text: string): string { + return text.replace( + /(^|[.!?][ \t]+|\n+[ \t]*)([a-z])/g, + (_match, prefix: string, char: string) => { + return `${prefix}${char.toUpperCase()}`; + } + ); +} + +function createCavemanStats( + originalTokens: number, + compressedTokens: number, + techniquesUsed: string[], + rulesApplied: string[] | undefined, + durationMs: number +): CompressionStats { + const savingsPercent = + originalTokens > 0 + ? Math.round(((originalTokens - compressedTokens) / originalTokens) * 10000) / 100 + : 0; + return { + originalTokens, + compressedTokens, + savingsPercent, + techniquesUsed, + mode: "standard", + timestamp: Date.now(), + ...(rulesApplied && rulesApplied.length > 0 ? { rulesApplied } : {}), + durationMs, + }; +} + +function compileUserPreservePatterns(patterns: string[]): { + patterns: RegExp[]; + warnings: string[]; +} { + const compiled: RegExp[] = []; + const warnings: string[] = []; + for (const pattern of patterns) { + try { + compiled.push(new RegExp(pattern, "g")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + warnings.push(`Invalid preservePatterns regex ignored: ${pattern} (${message})`); + } + } + return { patterns: compiled, warnings }; +} + +const PROTECTED_STRUCTURE_RE = + /```|~~~|`|https?:\/\/|\[[^\]\n]{1,1000}\]\([^)[ \t\n]{1,2000}(?:[ \t]+"[^"]{0,1000}")?\)|^#{1,6}\s+|^[ \t]*\|(?:[^|\n]{0,1000}\|){1,100}[ \t]*$|\$\$|\\\[|\\begin\{|^\s*#(?:set|show|let|import|include)\b|\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b|\bprocess\.env\.[A-Za-z_][A-Za-z0-9_]*\b|\$[A-Z_][A-Z0-9_]*\b|\b\d+(?:\.\d+){1,3}(?:[-+][A-Za-z0-9.-]+)?\b|\b[a-zA-Z_$][\w$]*(?:\.[a-zA-Z_$][\w$]*)+\(\)?|\b[A-Za-z_$][\w$]*[ \t]*\([^()\n]{0,1000}\)|(?:^|\s)(?:\.{0,2}\/[A-Za-z0-9_@./-]+|[A-Za-z]:\\[A-Za-z0-9_.\\/-]+)|\b(?:TypeError|ReferenceError|SyntaxError|RangeError|URIError|EvalError|Error|Exception):[^\n]{0,1000}/im; +const PROTECTED_STRUCTURE_PREFILTER_RE = /[`~\[\]\|$#\\/:_()0-9]/; + +function hasProtectedStructure(text: string): boolean { + if (!PROTECTED_STRUCTURE_PREFILTER_RE.test(text)) return false; + PROTECTED_STRUCTURE_RE.lastIndex = 0; + return PROTECTED_STRUCTURE_RE.test(text); +} + export function cavemanCompress( body: ChatRequestBody, options?: Partial @@ -80,23 +303,29 @@ export function cavemanCompress( let totalOriginalTokens = 0; let totalCompressedTokens = 0; const allAppliedRules: string[] = []; + const validationWarnings: string[] = []; + const validationErrors: string[] = []; + let fallbackApplied = false; + let preservedBlockCount = 0; + const customPreservation = compileUserPreservePatterns(config.preservePatterns ?? []); + validationWarnings.push(...customPreservation.warnings); const compressedMessages = body.messages.map((msg): ChatMessage => { - // Only compress simple string content — multi-part messages (arrays) - // would duplicate compressed text across all text parts if naively handled - if (typeof msg.content !== "string") { - const contentStr = Array.isArray(msg.content) - ? msg.content - .map((part) => (part.type === "text" && part.text ? part.text : "")) - .filter(Boolean) - .join("\n") - : ""; - totalOriginalTokens += estimateCompressionTokens(contentStr); - totalCompressedTokens += estimateCompressionTokens(contentStr); + if (typeof msg.content !== "string" && !Array.isArray(msg.content)) { return msg; } - const contentStr = msg.content; + const contentStr = + typeof msg.content === "string" + ? msg.content + : msg.content + .map((part) => + part && typeof part === "object" && "text" in part && typeof part.text === "string" + ? part.text + : "" + ) + .filter(Boolean) + .join("\n"); totalOriginalTokens += estimateCompressionTokens(contentStr); if (!contentStr || contentStr.length < config.minMessageLength) { @@ -109,33 +338,83 @@ export function cavemanCompress( return msg; } - const { text: extractedText, blocks } = extractPreservedBlocks(contentStr); + const compressTextPart = (textPart: string): string => { + if (!textPart || textPart.length < config.minMessageLength) return textPart; - const rules = getRulesForContext(msg.role).filter( - (rule) => !config.skipRules.includes(rule.name) - ); - const { text: rulesApplied, appliedRules } = applyRulesToText(extractedText, rules); - allAppliedRules.push(...appliedRules); + const shouldPreserve = + customPreservation.patterns.length > 0 || hasProtectedStructure(textPart); + const { text: extractedText, blocks } = shouldPreserve + ? extractPreservedBlocks(textPart, { + preservePatterns: customPreservation.patterns, + }) + : { text: textPart, blocks: [] }; + preservedBlockCount += blocks.length; - const restored = restorePreservedBlocks(rulesApplied, blocks); + const detectedLanguage = config.autoDetectLanguage + ? detectCompressionLanguage(textPart) + : (config.language ?? "en"); + const enabledPacks = config.enabledLanguagePacks ?? ["en", detectedLanguage]; + const language = enabledPacks.includes(detectedLanguage) + ? detectedLanguage + : enabledPacks.includes("en") + ? "en" + : detectedLanguage; + const rules = getRulesForContext(msg.role, config.intensity, language).filter( + (rule) => !config.skipRules.includes(rule.name) + ); + const { text: rulesApplied, appliedRules } = applyRulesToText(extractedText, rules); + allAppliedRules.push(...appliedRules); - const cleaned = cleanupArtifacts(restored); + const normalized = cleanupArtifacts(recapitalizeSentences(rulesApplied)); + const cleaned = + blocks.length > 0 + ? cleanupArtifacts(restorePreservedBlocks(normalized, blocks)) + : normalized; + if (shouldPreserve || blocks.length > 0) { + const validation = validateCompression(textPart, cleaned); + validationWarnings.push(...validation.warnings); + if (!validation.valid) { + validationErrors.push(...validation.errors); + fallbackApplied = true; + return textPart; + } + } + return cleaned; + }; + + const compressedMessage = mapTextContent(msg, compressTextPart) as ChatMessage; + const cleaned = + typeof compressedMessage.content === "string" + ? compressedMessage.content + : Array.isArray(compressedMessage.content) + ? compressedMessage.content + .map((part) => + part && typeof part === "object" && "text" in part && typeof part.text === "string" + ? part.text + : "" + ) + .filter(Boolean) + .join("\n") + : contentStr; totalCompressedTokens += estimateCompressionTokens(cleaned); - return { ...msg, content: cleaned }; + return compressedMessage; }); const durationMs = performance.now() - startMs; const uniqueRules = [...new Set(allAppliedRules)]; - const stats = createCompressionStats( - body as unknown as Record, - { ...body, messages: compressedMessages } as unknown as Record, - "standard" as CompressionMode, + const stats = createCavemanStats( + totalOriginalTokens, + totalCompressedTokens, uniqueRules.length > 0 ? ["caveman-rules"] : [], uniqueRules.length > 0 ? uniqueRules : undefined, Math.round(durationMs * 100) / 100 ); + if (validationWarnings.length > 0) stats.validationWarnings = [...new Set(validationWarnings)]; + if (validationErrors.length > 0) stats.validationErrors = [...new Set(validationErrors)]; + if (fallbackApplied) stats.fallbackApplied = true; + if (preservedBlockCount > 0) stats.preservedBlockCount = preservedBlockCount; const compressed = totalCompressedTokens < totalOriginalTokens; diff --git a/open-sse/services/compression/cavemanRules.ts b/open-sse/services/compression/cavemanRules.ts index 51e9958d04..c94ad6419e 100644 --- a/open-sse/services/compression/cavemanRules.ts +++ b/open-sse/services/compression/cavemanRules.ts @@ -1,14 +1,48 @@ import type { CavemanRule } from "./types.ts"; +import { loadAllRulesForLanguage } from "./ruleLoader.ts"; const CAVEMAN_RULES: CavemanRule[] = [ // ── Category 1: Filler Removal (10+ rules) ────────────────────────── + { + name: "redundant_phrasing", + pattern: + /\b(?:make sure to|be sure to|due to the fact that|the reason is because|it is important to|you should|remember to)\b\s*/gi, + replacement: (match: string): string => { + const map: Record = { + "make sure to": "ensure ", + "be sure to": "ensure ", + "due to the fact that": "because ", + "the reason is because": "because ", + "it is important to": "", + "you should": "", + "remember to": "", + }; + return map[match.trim().toLowerCase()] ?? ""; + }, + context: "all", + category: "structural", + minIntensity: "full", + description: "Replace verbose stock phrases with shorter equivalents.", + }, + { + name: "pleasantries", + pattern: + /(? { const map: Record = { - "provide a detailed": "provide", - "give me a comprehensive": "give", - "write an in-depth": "write", - "create a thorough": "create", - "explain in detail": "explain", + "provide a detailed explanation of": "explain ", + "give me a comprehensive explanation of": "explain ", + "write an in-depth explanation of": "explain ", + "create a thorough explanation of": "explain ", + "provide a detailed": "provide ", + "give me a comprehensive": "give ", + "write an in-depth": "write ", + "create a thorough": "create ", + "explain in detail": "explain ", }; const lower = match.toLowerCase(); return map[lower] ?? match; }, context: "all", + category: "filler", + minIntensity: "lite", }, { name: "filler_adverbs", - pattern: /(? { const trimmed = match.trimEnd().toLowerCase(); const map: Record = { - "can you explain why": "Explain why", - "could you show me how": "Show how", - "would you tell me": "Tell me", - "can you tell me": "Tell me", + "can you explain why": "Explain why ", + "could you show me how": "Show how ", + "would you tell me": "Tell me ", + "can you tell me": "Tell me ", }; return map[trimmed] ?? match; }, context: "user", + category: "context", + minIntensity: "lite", }, { name: "context_setup", pattern: /\b(?:I have the following code|Here is my code|Below is the code)\b\s*[:.]?\s*/gi, replacement: "Code:", context: "user", + category: "context", + minIntensity: "lite", }, { name: "intent_clarification", @@ -128,31 +210,40 @@ const CAVEMAN_RULES: CavemanRule[] = [ /\b(?:What I'm trying to do is|My objective is to|What I need is|I'm aiming to)\b\s*/gi, replacement: "Goal:", context: "user", + category: "context", + minIntensity: "lite", }, { name: "background_removal", pattern: /\b(?:As you may know,?\s*|As we discussed earlier,?\s*)/gi, replacement: "", context: "all", + category: "context", + minIntensity: "lite", }, { name: "meta_commentary", pattern: /^(?:Note that|Keep in mind that|Remember that)\b\s*/gim, replacement: "", context: "all", + category: "context", + minIntensity: "lite", }, { name: "purpose_statement", - pattern: /\b(?:for the purpose of|with the goal of|in an effort to)\b/gi, + pattern: /\b(?:for the purpose of|with the goal of|in an effort to|for every)\b/gi, replacement: (match: string): string => { const map: Record = { "for the purpose of": "for", "with the goal of": "to", "in an effort to": "to", + "for every": "per", }; return map[match.toLowerCase()] ?? match; }, context: "all", + category: "context", + minIntensity: "lite", }, // ── Category 3: Structural Compression (7+ rules) ──────────────────── @@ -162,12 +253,16 @@ const CAVEMAN_RULES: CavemanRule[] = [ pattern: /,\s*and also\s+|,\s*as well as\s+/gi, replacement: ", ", context: "all", + category: "structural", + minIntensity: "full", }, { name: "purpose_phrases", pattern: /\b(?:in order to|so as to)\b\s*/gi, replacement: "to ", context: "all", + category: "structural", + minIntensity: "lite", }, { name: "redundant_quantifiers", @@ -181,32 +276,42 @@ const CAVEMAN_RULES: CavemanRule[] = [ return map[match.toLowerCase()] ?? match; }, context: "all", + category: "structural", + minIntensity: "full", }, { name: "verbose_connectors", pattern: /\b(?:furthermore|additionally|moreover|in addition)\b\s*/gi, replacement: "also ", context: "all", + category: "structural", + minIntensity: "lite", }, { name: "transition_removal", pattern: /^(?:On the other hand,?\s*|In contrast,?\s*|However,?\s*)/gim, replacement: "", context: "all", + category: "structural", + minIntensity: "lite", }, { name: "emphasis_removal", pattern: /\b(?:very|really|extremely|highly|quite)\s+(?=[a-z])/gi, replacement: "", context: "all", + category: "structural", + minIntensity: "lite", }, { name: "passive_voice", - pattern: /\b(?:is being used|is being called|was created|was generated|was implemented)\b/gi, + pattern: + /\b(?:is being used|is being called|is being generated|was created|was generated|was implemented)\b/gi, replacement: (match: string): string => { const map: Record = { "is being used": "uses", "is being called": "calls", + "is being generated": "generated", "was created": "created", "was generated": "generated", "was implemented": "implemented", @@ -214,6 +319,8 @@ const CAVEMAN_RULES: CavemanRule[] = [ return map[match.toLowerCase()] ?? match; }, context: "all", + category: "structural", + minIntensity: "full", }, // ── Category 4: Multi-Turn Dedup (5+ rules) ───────────────────────── @@ -224,6 +331,8 @@ const CAVEMAN_RULES: CavemanRule[] = [ /\b(?:As we discussed earlier|As mentioned before|As previously stated|As I said before)\b[,.]?\s*/gi, replacement: "See above. ", context: "all", + category: "dedup", + minIntensity: "lite", }, { name: "repeated_question", @@ -231,12 +340,16 @@ const CAVEMAN_RULES: CavemanRule[] = [ /\b(?:Same question as before|I asked this earlier|This is the same question)\b[,.]?\s*/gi, replacement: "[same question] ", context: "user", + category: "dedup", + minIntensity: "lite", }, { name: "reestablished_context", pattern: /\b(?:Going back to the code above|Referring back to|Returning to)\b\s*/gi, replacement: "Re: ", context: "all", + category: "dedup", + minIntensity: "lite", }, { name: "summary_replacement", @@ -244,15 +357,76 @@ const CAVEMAN_RULES: CavemanRule[] = [ /\b(?:To summarize what we've discussed|In summary of our conversation|To recap)\b[,.]?\s*/gi, replacement: "Summary: ", context: "assistant", + category: "dedup", + minIntensity: "lite", + }, + + // ── Category 5: Ultra Abbreviations ───────────────────────────────── + + { + name: "ultra_abbreviations", + pattern: + /\b(?:database|configuration|function|request|response|implementation|authentication|authorization|application|dependency|dependencies)\b/gi, + replacement: (match: string): string => { + const map: Record = { + database: "DB", + configuration: "config", + function: "fn", + request: "req", + response: "res", + implementation: "impl", + authentication: "auth", + authorization: "authz", + application: "app", + dependency: "dep", + dependencies: "deps", + }; + return map[match.toLowerCase()] ?? match; + }, + context: "all", + category: "ultra", + minIntensity: "ultra", }, ]; -export function getRulesForContext(context: string): CavemanRule[] { - return CAVEMAN_RULES.filter((rule) => rule.context === "all" || rule.context === context); +const INTENSITY_RANK = { lite: 0, full: 1, ultra: 2 } as const; + +export function getRulesForContext( + context: string, + intensity: "lite" | "full" | "ultra" = "full", + language = "en" +): CavemanRule[] { + const rank = INTENSITY_RANK[intensity] ?? INTENSITY_RANK.full; + const fileRules = language ? loadAllRulesForLanguage(language) : []; + const rules = fileRules.length > 0 ? fileRules : CAVEMAN_RULES; + const selected = rules.filter((rule) => { + const minRank = INTENSITY_RANK[rule.minIntensity ?? "lite"]; + return (rule.context === "all" || rule.context === context) && minRank <= rank; + }); + return selected.length > 0 + ? selected + : CAVEMAN_RULES.filter((rule) => { + const minRank = INTENSITY_RANK[rule.minIntensity ?? "lite"]; + return (rule.context === "all" || rule.context === context) && minRank <= rank; + }); } export function getRuleByName(name: string): CavemanRule | undefined { return CAVEMAN_RULES.find((rule) => rule.name === name); } +export function getCavemanRuleMetadata() { + const intensities = ["lite", "full", "ultra"] as const; + return CAVEMAN_RULES.map((rule) => ({ + name: rule.name, + context: rule.context, + category: rule.category ?? "terse", + minIntensity: rule.minIntensity ?? "lite", + intensities: intensities.filter( + (intensity) => INTENSITY_RANK[intensity] >= INTENSITY_RANK[rule.minIntensity ?? "lite"] + ), + description: rule.description ?? rule.name.replace(/_/g, " "), + })); +} + export { CAVEMAN_RULES }; diff --git a/open-sse/services/compression/diffHelper.ts b/open-sse/services/compression/diffHelper.ts new file mode 100644 index 0000000000..5dbebd7b1d --- /dev/null +++ b/open-sse/services/compression/diffHelper.ts @@ -0,0 +1,117 @@ +import type { CompressionStats } from "./types.ts"; +import { extractPreservedBlocks } from "./preservation.ts"; +import { validateCompression } from "./validation.ts"; + +export interface CompressionDiffSegment { + type: "same" | "removed" | "added"; + text: string; +} + +export interface CompressionPreviewDiff { + segments: CompressionDiffSegment[]; + preservedBlocks: Array<{ kind: string; preview: string }>; + ruleRemovals: string[]; + validationWarnings: string[]; + validationErrors: string[]; + fallbackApplied: boolean; +} + +export interface CompressionPreviewDiffOptions { + maxTokenProduct?: number; +} + +export const DEFAULT_MAX_PREVIEW_DIFF_TOKEN_PRODUCT = 1_000_000; + +function tokenize(text: string): string[] { + return text.match(/\s+|[^\s]+/g) ?? []; +} + +function getDiffSkipWarning( + original: string, + compressed: string, + options: CompressionPreviewDiffOptions = {} +): string | null { + const maxTokenProduct = options.maxTokenProduct ?? DEFAULT_MAX_PREVIEW_DIFF_TOKEN_PRODUCT; + if (maxTokenProduct <= 0) return null; + + const originalTokens = tokenize(original).length; + const compressedTokens = tokenize(compressed).length; + if (originalTokens * compressedTokens <= maxTokenProduct) return null; + + return `Preview diff omitted because token product ${originalTokens}x${compressedTokens} exceeds safe limit ${maxTokenProduct}.`; +} + +export function buildCompressionDiff( + original: string, + compressed: string +): CompressionDiffSegment[] { + const a = tokenize(original); + const b = tokenize(compressed); + const dp = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0)); + + for (let i = a.length - 1; i >= 0; i--) { + for (let j = b.length - 1; j >= 0; j--) { + dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + + const segments: CompressionDiffSegment[] = []; + const push = (type: CompressionDiffSegment["type"], text: string) => { + if (!text) return; + const last = segments[segments.length - 1]; + if (last?.type === type) { + last.text += text; + } else { + segments.push({ type, text }); + } + }; + + let i = 0; + let j = 0; + while (i < a.length && j < b.length) { + if (a[i] === b[j]) { + push("same", a[i]); + i++; + j++; + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + push("removed", a[i]); + i++; + } else { + push("added", b[j]); + j++; + } + } + while (i < a.length) push("removed", a[i++]); + while (j < b.length) push("added", b[j++]); + + return segments; +} + +export function buildCompressionPreviewDiff( + original: string, + compressed: string, + stats: CompressionStats | null | undefined, + options: CompressionPreviewDiffOptions = {} +): CompressionPreviewDiff { + const validation = validateCompression(original, compressed); + const preserved = extractPreservedBlocks(original).blocks.map((block) => ({ + kind: block.kind, + preview: block.content.replace(/\s+/g, " ").slice(0, 120), + })); + const diffSkipWarning = getDiffSkipWarning(original, compressed, options); + + return { + segments: diffSkipWarning + ? [{ type: "same", text: "[diff omitted: input too large]" }] + : buildCompressionDiff(original, compressed), + preservedBlocks: preserved, + ruleRemovals: stats?.rulesApplied ?? [], + validationWarnings: [ + ...(stats?.validationWarnings ?? []), + ...validation.warnings, + ...(diffSkipWarning ? [diffSkipWarning] : []), + ], + validationErrors: [...(stats?.validationErrors ?? []), ...validation.errors], + fallbackApplied: Boolean(stats?.fallbackApplied || validation.fallbackApplied), + }; +} diff --git a/open-sse/services/compression/engines/cavemanAdapter.ts b/open-sse/services/compression/engines/cavemanAdapter.ts new file mode 100644 index 0000000000..51ae2b6548 --- /dev/null +++ b/open-sse/services/compression/engines/cavemanAdapter.ts @@ -0,0 +1,408 @@ +import { applyLiteCompression } from "../lite.ts"; +import { cavemanCompress } from "../caveman.ts"; +import { compressAggressive } from "../aggressive.ts"; +import { ultraCompress } from "../ultra.ts"; +import { createCompressionStats } from "../stats.ts"; +import { + DEFAULT_AGGRESSIVE_CONFIG, + DEFAULT_ULTRA_CONFIG, + type CavemanIntensity, +} from "../types.ts"; +import type { CompressionEngine, EngineConfigField, EngineValidationResult } from "./types.ts"; + +const CAVEMAN_INTENSITIES: CavemanIntensity[] = ["lite", "full", "ultra"]; + +const CAVEMAN_SCHEMA: EngineConfigField[] = [ + { + key: "intensity", + type: "select", + label: "Intensity", + defaultValue: "full", + options: CAVEMAN_INTENSITIES.map((value) => ({ value, label: value })), + }, + { + key: "minMessageLength", + type: "number", + label: "Minimum message length", + defaultValue: 50, + min: 0, + max: 10000, + }, + { + key: "enabled", + type: "boolean", + label: "Enabled", + defaultValue: true, + }, +]; + +const AGGRESSIVE_SCHEMA: EngineConfigField[] = [ + { + key: "summarizerEnabled", + type: "boolean", + label: "Summarizer enabled", + defaultValue: DEFAULT_AGGRESSIVE_CONFIG.summarizerEnabled, + }, + { + key: "maxTokensPerMessage", + type: "number", + label: "Max tokens per message", + defaultValue: DEFAULT_AGGRESSIVE_CONFIG.maxTokensPerMessage, + min: 256, + max: 32768, + }, + { + key: "minSavingsThreshold", + type: "number", + label: "Minimum savings threshold", + defaultValue: DEFAULT_AGGRESSIVE_CONFIG.minSavingsThreshold, + min: 0, + max: 1, + }, + { + key: "preserveSystemPrompt", + type: "boolean", + label: "Preserve system prompt", + defaultValue: true, + }, +]; + +const ULTRA_SCHEMA: EngineConfigField[] = [ + { + key: "enabled", + type: "boolean", + label: "Enabled", + defaultValue: DEFAULT_ULTRA_CONFIG.enabled, + }, + { + key: "compressionRate", + type: "number", + label: "Compression rate", + defaultValue: DEFAULT_ULTRA_CONFIG.compressionRate, + min: 0, + max: 1, + }, + { + key: "minScoreThreshold", + type: "number", + label: "Minimum score threshold", + defaultValue: DEFAULT_ULTRA_CONFIG.minScoreThreshold, + min: 0, + max: 1, + }, + { + key: "slmFallbackToAggressive", + type: "boolean", + label: "Fallback to aggressive", + defaultValue: DEFAULT_ULTRA_CONFIG.slmFallbackToAggressive, + }, + { + key: "modelPath", + type: "string", + label: "Model path", + defaultValue: "", + }, + { + key: "maxTokensPerMessage", + type: "number", + label: "Max tokens per message", + defaultValue: DEFAULT_ULTRA_CONFIG.maxTokensPerMessage, + min: 0, + max: 32768, + }, + { + key: "preserveSystemPrompt", + type: "boolean", + label: "Preserve system prompt", + defaultValue: true, + }, +]; + +function ok(): EngineValidationResult { + return { valid: true, errors: [] }; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function validateBoolean(config: Record, key: string, errors: string[]): void { + if (config[key] !== undefined && typeof config[key] !== "boolean") { + errors.push(`${key} must be a boolean`); + } +} + +function validateNumberRange( + config: Record, + key: string, + min: number, + max: number, + errors: string[] +): void { + const value = config[key]; + if (value === undefined) return; + if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) { + errors.push(`${key} must be a number between ${min} and ${max}`); + } +} + +function validateCavemanLikeConfig(config: Record): EngineValidationResult { + const errors: string[] = []; + if ( + config.intensity !== undefined && + !CAVEMAN_INTENSITIES.includes(config.intensity as CavemanIntensity) + ) { + errors.push("intensity must be lite, full, or ultra"); + } + if ( + config.minMessageLength !== undefined && + (typeof config.minMessageLength !== "number" || config.minMessageLength < 0) + ) { + errors.push("minMessageLength must be a non-negative number"); + } + if (config.enabled !== undefined && typeof config.enabled !== "boolean") { + errors.push("enabled must be a boolean"); + } + return { valid: errors.length === 0, errors }; +} + +function validateAggressiveConfig(config: Record): EngineValidationResult { + const errors: string[] = []; + validateBoolean(config, "summarizerEnabled", errors); + validateBoolean(config, "preserveSystemPrompt", errors); + validateNumberRange(config, "maxTokensPerMessage", 256, 32768, errors); + validateNumberRange(config, "minSavingsThreshold", 0, 1, errors); + + if (config.thresholds !== undefined) { + if (!isRecord(config.thresholds)) { + errors.push("thresholds must be an object"); + } else { + for (const key of ["fullSummary", "moderate", "light", "verbatim"]) { + validateNumberRange(config.thresholds, key, 1, 100, errors); + } + } + } + + if (config.toolStrategies !== undefined) { + if (!isRecord(config.toolStrategies)) { + errors.push("toolStrategies must be an object"); + } else { + for (const key of ["fileContent", "grepSearch", "shellOutput", "json", "errorMessage"]) { + validateBoolean(config.toolStrategies, key, errors); + } + } + } + + return { valid: errors.length === 0, errors }; +} + +function validateUltraConfig(config: Record): EngineValidationResult { + const errors: string[] = []; + validateBoolean(config, "enabled", errors); + validateBoolean(config, "slmFallbackToAggressive", errors); + validateBoolean(config, "preserveSystemPrompt", errors); + validateNumberRange(config, "compressionRate", 0, 1, errors); + validateNumberRange(config, "minScoreThreshold", 0, 1, errors); + validateNumberRange(config, "maxTokensPerMessage", 0, 32768, errors); + if (config.modelPath !== undefined && typeof config.modelPath !== "string") { + errors.push("modelPath must be a string"); + } + return { valid: errors.length === 0, errors }; +} + +export const liteEngine: CompressionEngine = { + id: "lite", + name: "Lite", + description: "Fast whitespace, tool-result and image URL reduction.", + icon: "compress", + targets: ["messages", "tool_results"], + stackable: true, + stackPriority: 5, + metadata: { + id: "lite", + name: "Lite", + description: "Fast whitespace, tool-result and image URL reduction.", + inputScope: "messages", + targetLatencyMs: 1, + supportsPreview: true, + stable: true, + }, + apply(body, options) { + return applyLiteCompression(body, { + ...options, + preserveSystemPrompt: options?.config?.preserveSystemPrompt !== false, + }); + }, + compress(body, config) { + return this.apply(body, { stepConfig: config }); + }, + getConfigSchema() { + return AGGRESSIVE_SCHEMA; + }, + validateConfig(config) { + return validateAggressiveConfig(config); + }, +}; + +export const cavemanEngine: CompressionEngine = { + id: "caveman", + name: "Caveman", + description: "Rule-based message compression with preservation and validation.", + icon: "compress", + targets: ["messages"], + stackable: true, + stackPriority: 20, + metadata: { + id: "caveman", + name: "Caveman", + description: "Rule-based message compression with preservation and validation.", + inputScope: "messages", + targetLatencyMs: 1, + supportsPreview: true, + stable: true, + }, + apply(body, options) { + const cavemanConfig = { + ...(options?.config?.cavemanConfig ?? {}), + ...(options?.stepConfig ?? {}), + ...(options?.config?.languageConfig?.enabled + ? { + language: options.config.languageConfig.defaultLanguage, + autoDetectLanguage: options.config.languageConfig.autoDetect, + enabledLanguagePacks: options.config.languageConfig.enabledPacks, + } + : {}), + ...(options?.config?.preserveSystemPrompt !== false + ? { + compressRoles: (options?.config?.cavemanConfig?.compressRoles ?? ["user"]).filter( + (role) => role !== "system" + ), + } + : {}), + }; + return cavemanCompress(body as Parameters[0], cavemanConfig); + }, + compress(body, config) { + return this.apply(body, { stepConfig: config }); + }, + getConfigSchema() { + return CAVEMAN_SCHEMA; + }, + validateConfig(config) { + return validateCavemanLikeConfig(config); + }, +}; + +export const aggressiveEngine: CompressionEngine = { + id: "aggressive", + name: "Aggressive", + description: "Summarization, tool result compression and progressive aging.", + icon: "speed", + targets: ["messages", "tool_results"], + stackable: true, + stackPriority: 30, + metadata: { + id: "aggressive", + name: "Aggressive", + description: "Summarization, tool result compression and progressive aging.", + inputScope: "messages", + targetLatencyMs: 5, + supportsPreview: true, + stable: true, + }, + apply(body, options) { + const messages = (body.messages ?? []) as Array<{ + role: string; + content?: string | Array<{ type: string; text?: string }>; + [key: string]: unknown; + }>; + if (!Array.isArray(messages) || messages.length === 0) { + return { body, compressed: false, stats: null }; + } + const aggressiveConfig = { + ...(options?.config?.aggressive ?? {}), + ...(options?.stepConfig ?? {}), + preserveSystemPrompt: options?.config?.preserveSystemPrompt !== false, + }; + const result = compressAggressive(messages, aggressiveConfig); + const compressedBody = { ...body, messages: result.messages }; + return { + body: compressedBody, + compressed: result.stats.savingsPercent > 0, + stats: createCompressionStats( + body, + compressedBody, + "aggressive", + ["aggressive"], + result.stats.rulesApplied, + result.stats.durationMs + ), + }; + }, + compress(body, config) { + return this.apply(body, { stepConfig: config }); + }, + getConfigSchema() { + return AGGRESSIVE_SCHEMA; + }, + validateConfig(config) { + return validateAggressiveConfig(config); + }, +}; + +export const ultraEngine: CompressionEngine = { + id: "ultra", + name: "Ultra", + description: "Heuristic token pruning with optional local SLM fallback.", + icon: "bolt", + targets: ["messages"], + stackable: true, + stackPriority: 40, + metadata: { + id: "ultra", + name: "Ultra", + description: "Heuristic token pruning with optional local SLM fallback.", + inputScope: "messages", + targetLatencyMs: 5, + supportsPreview: true, + stable: true, + }, + apply(body, options) { + const messages = (body.messages ?? []) as Array<{ + role: string; + content?: string | unknown[]; + [key: string]: unknown; + }>; + if (!Array.isArray(messages) || messages.length === 0) { + return { body, compressed: false, stats: null }; + } + const ultraConfig = { + ...(options?.config?.ultra ?? {}), + ...(options?.stepConfig ?? {}), + preserveSystemPrompt: options?.config?.preserveSystemPrompt !== false, + }; + const result = ultraCompress(messages, ultraConfig); + const compressedBody = { ...body, messages: result.messages }; + return { + body: compressedBody, + compressed: result.stats.savingsPercent > 0, + stats: createCompressionStats( + body, + compressedBody, + "ultra", + ["ultra"], + result.stats.rulesApplied, + result.stats.durationMs + ), + }; + }, + compress(body, config) { + return this.apply(body, { stepConfig: config }); + }, + getConfigSchema() { + return ULTRA_SCHEMA; + }, + validateConfig(config) { + return validateUltraConfig(config); + }, +}; diff --git a/open-sse/services/compression/engines/index.ts b/open-sse/services/compression/engines/index.ts new file mode 100644 index 0000000000..07094ee9ac --- /dev/null +++ b/open-sse/services/compression/engines/index.ts @@ -0,0 +1,14 @@ +import { registerCompressionEngine, getCompressionEngine } from "./registry.ts"; +import { aggressiveEngine, cavemanEngine, liteEngine, ultraEngine } from "./cavemanAdapter.ts"; +import { rtkEngine } from "./rtk/index.ts"; + +let registered = false; + +export function registerBuiltinCompressionEngines(): void { + const engines = [liteEngine, cavemanEngine, aggressiveEngine, ultraEngine, rtkEngine]; + if (registered && engines.every((engine) => getCompressionEngine(engine.id))) return; + for (const engine of engines) { + if (!getCompressionEngine(engine.id)) registerCompressionEngine(engine); + } + registered = true; +} diff --git a/open-sse/services/compression/engines/registry.ts b/open-sse/services/compression/engines/registry.ts new file mode 100644 index 0000000000..85d6a50cdd --- /dev/null +++ b/open-sse/services/compression/engines/registry.ts @@ -0,0 +1,87 @@ +import type { CompressionEngine, EngineRegistryEntry, EngineValidationResult } from "./types.ts"; + +const ENGINES = new Map(); + +function assertValidEngine(engine: CompressionEngine): void { + if ( + !engine?.id || + typeof engine.apply !== "function" || + typeof engine.compress !== "function" || + typeof engine.getConfigSchema !== "function" || + typeof engine.validateConfig !== "function" + ) { + throw new Error("Invalid compression engine registration"); + } +} + +export function registerEngine( + engine: CompressionEngine, + defaultConfig: Record = {} +): void { + assertValidEngine(engine); + const validation = engine.validateConfig(defaultConfig); + if (!validation.valid) { + throw new Error(`Invalid default config for ${engine.id}: ${validation.errors.join("; ")}`); + } + ENGINES.set(engine.id, { + engine, + enabled: true, + config: { ...defaultConfig }, + }); +} + +export function registerCompressionEngine(engine: CompressionEngine): void { + registerEngine(engine); +} + +export function unregisterCompressionEngine(id: string): boolean { + return ENGINES.delete(id); +} + +export function getEngine(id: string): CompressionEngine | null { + return ENGINES.get(id)?.engine ?? null; +} + +export function getCompressionEngine(id: string): CompressionEngine | null { + return getEngine(id); +} + +export function getEngineEntry(id: string): EngineRegistryEntry | null { + return ENGINES.get(id) ?? null; +} + +export function listEngines(): EngineRegistryEntry[] { + return Array.from(ENGINES.values()); +} + +export function listCompressionEngines(): CompressionEngine[] { + return listEngines().map((entry) => entry.engine); +} + +export function listEnabledEngines(): EngineRegistryEntry[] { + return listEngines().filter((entry) => entry.enabled); +} + +export function setEngineEnabled(id: string, enabled: boolean): boolean { + const entry = ENGINES.get(id); + if (!entry) return false; + entry.enabled = enabled; + return true; +} + +export function updateEngineConfig( + id: string, + config: Record +): EngineValidationResult { + const entry = ENGINES.get(id); + if (!entry) return { valid: false, errors: [`Unknown compression engine: ${id}`] }; + const nextConfig = { ...entry.config, ...config }; + const validation = entry.engine.validateConfig(nextConfig); + if (!validation.valid) return validation; + entry.config = nextConfig; + return { valid: true, errors: [] }; +} + +export function clearCompressionEngineRegistry(): void { + ENGINES.clear(); +} diff --git a/open-sse/services/compression/engines/rtk/codeStripper.ts b/open-sse/services/compression/engines/rtk/codeStripper.ts new file mode 100644 index 0000000000..27c3512910 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/codeStripper.ts @@ -0,0 +1,128 @@ +export type CodeLanguage = + | "javascript" + | "typescript" + | "python" + | "rust" + | "go" + | "ruby" + | "java" + | "unknown"; + +export interface CodeStripperOptions { + removeComments?: boolean; + removeEmptyLines?: boolean; + collapseWhitespace?: boolean; + preserveDocstrings?: boolean; +} + +const LANGUAGE_ALIASES: Record = { + js: "javascript", + jsx: "javascript", + javascript: "javascript", + ts: "typescript", + tsx: "typescript", + typescript: "typescript", + py: "python", + python: "python", + rs: "rust", + rust: "rust", + go: "go", + rb: "ruby", + ruby: "ruby", + java: "java", +}; + +export function normalizeCodeLanguage(language?: string | null): CodeLanguage { + if (!language) return "unknown"; + return LANGUAGE_ALIASES[language.trim().toLowerCase()] ?? "unknown"; +} + +export function detectCodeLanguage(text: string): CodeLanguage { + if (/\b(?:interface|type)\s+\w+\s*=|:\s*(?:string|number|boolean)\b/.test(text)) { + return "typescript"; + } + if (/\b(?:const|let|function|import|export)\b|=>/.test(text)) return "javascript"; + if (/\bdef\s+\w+\(|\bimport\s+\w+|print\(/.test(text)) return "python"; + if (/\bfn\s+\w+\(|\blet\s+mut\b|println!\(/.test(text)) return "rust"; + if (/\bfunc\s+\w+\(|package\s+\w+/.test(text)) return "go"; + if (/\bclass\s+\w+|System\.out\.println/.test(text)) return "java"; + if (/\bdef\s+\w+|puts\s+|end\s*$/.test(text)) return "ruby"; + return "unknown"; +} + +function removeSlashComments(text: string): string { + return text + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^\s*\/\/.*$/gm, "") + .replace(/\s+\/\/\s.*$/gm, ""); +} + +function removeHashComments(text: string): string { + return text.replace(/^\s*#.*$/gm, "").replace(/\s+#\s.*$/gm, ""); +} + +function stripByLanguage( + text: string, + language: CodeLanguage, + options: Required +): string { + if (!options.removeComments) return text; + + if (language === "javascript" || language === "typescript" || language === "rust") { + return removeSlashComments(text); + } + if (language === "go" || language === "java") return removeSlashComments(text); + if (language === "python") { + const withoutDocstrings = options.preserveDocstrings + ? text + : text.replace(/("""[\s\S]*?"""|'''[\s\S]*?''')/g, ""); + return removeHashComments(withoutDocstrings); + } + if (language === "ruby") { + return removeHashComments(text.replace(/^=begin[\s\S]*?^=end/gm, "")); + } + return text; +} + +export function stripCode( + text: string, + language: CodeLanguage = "unknown", + options: CodeStripperOptions = {} +): { + text: string; + strippedLines: number; + language: CodeLanguage; +} { + const resolvedLanguage = language === "unknown" ? detectCodeLanguage(text) : language; + const opts: Required = { + removeComments: options.removeComments !== false, + removeEmptyLines: options.removeEmptyLines !== false, + collapseWhitespace: options.collapseWhitespace !== false, + preserveDocstrings: options.preserveDocstrings === true, + }; + const originalLines = text.split(/\r?\n/).length; + let result = stripByLanguage(text, resolvedLanguage, opts); + + if (opts.removeEmptyLines) result = result.replace(/^\s*$(?:\r?\n)?/gm, ""); + if (opts.collapseWhitespace) { + result = result + .split(/\r?\n/) + .map((line) => line.replace(/[ \t]+$/g, "")) + .join("\n"); + } + + result = result.trim(); + const strippedLines = Math.max(0, originalLines - (result ? result.split(/\r?\n/).length : 0)); + return { text: result, strippedLines, language: resolvedLanguage }; +} + +export function stripCodeComments( + text: string, + language = "typescript" +): { + text: string; + stripped: boolean; +} { + const result = stripCode(text, normalizeCodeLanguage(language)); + return { text: result.text, stripped: result.strippedLines > 0 || result.text !== text }; +} diff --git a/open-sse/services/compression/engines/rtk/commandDetector.ts b/open-sse/services/compression/engines/rtk/commandDetector.ts new file mode 100644 index 0000000000..31806c65b7 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/commandDetector.ts @@ -0,0 +1,465 @@ +export interface CommandDetectionResult { + type: string; + command: string | null; + confidence: number; + category: + | "git" + | "test" + | "build" + | "shell" + | "docker" + | "package" + | "infra" + | "cloud" + | "generic"; + matchedPatterns: string[]; +} + +type Detector = { + type: string; + category: CommandDetectionResult["category"]; + commandPatterns: RegExp[]; + contentPatterns: RegExp[]; +}; + +const COMMAND_PREFIXES = [ + "git", + "make", + "terraform", + "tofu", + "opentofu", + "systemctl", + "npm", + "pnpm", + "yarn", + "vitest", + "jest", + "pytest", + "python", + "go", + "cargo", + "tsc", + "eslint", + "webpack", + "vite", + "biome", + "prettier", + "turbo", + "nx", + "playwright", + "ruff", + "mypy", + "pip", + "uv", + "poetry", + "golangci-lint", + "bundle", + "rubocop", + "docker", + "aws", + "gcloud", + "ssh", + "rsync", + "curl", + "wget", + "ls", + "find", + "grep", + "rg", + "ag", + "ps", + "df", + "du", +]; + +const COMMAND_PREFIX_PATTERN = new RegExp(`^(?:${COMMAND_PREFIXES.join("|")})\\b`); + +const DETECTORS: Detector[] = [ + { + type: "git-status", + category: "git", + commandPatterns: [/^git\s+status\b/i], + contentPatterns: [ + /^On branch /m, + /^Changes (?:not staged|to be committed)/m, + /^Untracked files:/m, + ], + }, + { + type: "git-branch", + category: "git", + commandPatterns: [/^git\s+branch\b/i, /^git\s+checkout\b/i, /^git\s+switch\b/i], + contentPatterns: [/^\*\s+\S+/m, /Switched to (?:a new )?branch/i, /Already on ['"][^'"]+['"]/i], + }, + { + type: "git-diff", + category: "git", + commandPatterns: [/^git\s+diff\b/i, /^git\s+show\b/i], + contentPatterns: [/^diff --git /m, /^@@\s+-\d+,\d+\s+\+\d+,\d+\s+@@/m], + }, + { + type: "git-log", + category: "git", + commandPatterns: [/^git\s+log\b/i], + contentPatterns: [/^commit [0-9a-f]{7,40}/m, /^Author: /m], + }, + { + type: "make", + category: "build", + commandPatterns: [/^make\b/i], + contentPatterns: [/^make\[\d+\]: (?:Entering|Leaving) directory/m, /make: \*\*\* /], + }, + { + type: "terraform-plan", + category: "infra", + commandPatterns: [/^terraform\s+plan\b/i], + contentPatterns: [/Terraform will perform the following actions:/, /Plan: \d+ to add/i], + }, + { + type: "tofu-plan", + category: "infra", + commandPatterns: [/^(?:tofu|opentofu)\s+plan\b/i], + contentPatterns: [/OpenTofu will perform the following actions:/, /Plan: \d+ to add/i], + }, + { + type: "systemctl-status", + category: "infra", + commandPatterns: [/^systemctl\s+status\b/i], + contentPatterns: [/^\s*Loaded:\s+/m, /^\s*Active:\s+/m, /^●\s+\S+\.service/m], + }, + { + type: "test-vitest", + category: "test", + commandPatterns: [/^vitest\b/i, /^npm\s+(?:run\s+)?test:vitest\b/i], + contentPatterns: [/\bvitest\b/i, /^ ✓ /m, /^ ❯ /m, /Test Files\s+\d+\s+(?:passed|failed)/i], + }, + { + type: "test-jest", + category: "test", + commandPatterns: [/^jest\b/i, /^npm\s+(?:run\s+)?test\b/i], + contentPatterns: [/Test Suites:\s+\d+/i, /Tests:\s+\d+/i, /^PASS\s+/m, /^FAIL\s+/m], + }, + { + type: "test-pytest", + category: "test", + commandPatterns: [/^pytest\b/i, /^python\s+-m\s+pytest\b/i], + contentPatterns: [/=+\s+(?:\d+\s+)?(?:passed|failed|errors?)/i, /^E\s+/m, /^FAILED /m], + }, + { + type: "test-cargo", + category: "test", + commandPatterns: [/^cargo\s+test\b/i, /^cargo\s+nextest\b/i], + contentPatterns: [ + /^running \d+ tests?/m, + /^test\s+[\w:.-]+\s+\.\.\.\s+(?:ok|FAILED|ignored)/m, + /test result:\s+(?:ok|FAILED)/i, + ], + }, + { + type: "test-go", + category: "test", + commandPatterns: [/^go\s+test\b/i], + contentPatterns: [/^(?:ok|FAIL)\s+[\w./-]+\s+[\d.]+s/m, /^--- FAIL: /m, /^panic: /m], + }, + { + type: "build-typescript", + category: "build", + commandPatterns: [/^tsc\b/i, /^npm\s+run\s+typecheck\b/i], + contentPatterns: [/TS\d{4}:/, /error TS\d{4}/i], + }, + { + type: "build-eslint", + category: "build", + commandPatterns: [/^eslint\b/i, /^npm\s+run\s+lint\b/i], + contentPatterns: [/\s+\d+:\d+\s+(?:error|warning)\s+/, /✖\s+\d+\s+problems?/], + }, + { + type: "build-webpack", + category: "build", + commandPatterns: [/^webpack\b/i, /^npx\s+webpack\b/i, /^npm\s+run\s+build:webpack\b/i], + contentPatterns: [ + /webpack\s+\d/i, + /compiled (?:successfully|with \d+ errors?)/i, + /asset .+\.js/i, + ], + }, + { + type: "build-vite", + category: "build", + commandPatterns: [/^vite\s+build\b/i, /^npm\s+run\s+build\b/i, /^pnpm\s+build\b/i], + contentPatterns: [/vite v[\d.]+/i, /✓ built in/i, /transforming \(\d+\)/i], + }, + { + type: "biome", + category: "build", + commandPatterns: [/^biome\b/i, /^npx\s+biome\b/i], + contentPatterns: [/lint\/[A-Za-z0-9/.-]+/, /Checked \d+ files? in/i], + }, + { + type: "prettier", + category: "build", + commandPatterns: [/^prettier\b/i, /^npx\s+prettier\b/i], + contentPatterns: [/^Checking formatting\.\.\./m, /Code style issues found/i], + }, + { + type: "turbo", + category: "build", + commandPatterns: [/^turbo\b/i, /^npx\s+turbo\b/i], + contentPatterns: [/^• Packages in scope:/m, /^Tasks:\s+\d+\s+successful/m], + }, + { + type: "nx", + category: "build", + commandPatterns: [/^nx\b/i, /^npx\s+nx\b/i], + contentPatterns: [/^NX\s+/m, /^> nx run /m], + }, + { + type: "playwright", + category: "test", + commandPatterns: [/^playwright\s+test\b/i, /^npx\s+playwright\s+test\b/i], + contentPatterns: [/Running \d+ tests? using \d+ workers?/i, /^\s+\d+ failed/m], + }, + { + type: "npm-install", + category: "package", + commandPatterns: [/^(?:npm|pnpm|yarn)\s+(?:install|add|update)\b/i], + contentPatterns: [ + /added \d+ packages/i, + /packages are looking for funding/i, + /audited \d+ packages/i, + ], + }, + { + type: "npm-audit", + category: "package", + commandPatterns: [/^(?:npm|pnpm|yarn)\s+audit\b/i], + contentPatterns: [/found \d+ vulnerabilities/i, /\b(?:low|moderate|high|critical)\b/i], + }, + { + type: "ruff", + category: "build", + commandPatterns: [/^ruff\b/i, /^uv\s+run\s+ruff\b/i], + contentPatterns: [/^[\w./-]+\.py:\d+:\d+:\s+[A-Z]\d+/m, /Found \d+ errors?\./i], + }, + { + type: "mypy", + category: "build", + commandPatterns: [/^mypy\b/i, /^python\s+-m\s+mypy\b/i], + contentPatterns: [/^[\w./-]+\.py:\d+:\s+error:/m, /Found \d+ errors? in \d+ files?/i], + }, + { + type: "pip", + category: "package", + commandPatterns: [/^pip\s+(?:install|download|uninstall)\b/i, /^python\s+-m\s+pip\b/i], + contentPatterns: [/^Collecting /m, /^Successfully installed /m], + }, + { + type: "uv-sync", + category: "package", + commandPatterns: [/^uv\s+sync\b/i, /^uv\s+pip\s+install\b/i], + contentPatterns: [/^Resolved \d+ packages?/m, /^Installed \d+ packages?/m], + }, + { + type: "poetry-install", + category: "package", + commandPatterns: [/^poetry\s+install\b/i], + contentPatterns: [/^Installing dependencies from lock file/m, /^Package operations:/m], + }, + { + type: "golangci-lint", + category: "build", + commandPatterns: [/^golangci-lint\b/i], + contentPatterns: [/^[\w./-]+\.go:\d+:\d+:/m, /^\d+ issues?:/m], + }, + { + type: "bundle-install", + category: "package", + commandPatterns: [/^bundle\s+install\b/i], + contentPatterns: [/^Fetching gem metadata from /m, /^Bundle complete!/m], + }, + { + type: "rubocop", + category: "build", + commandPatterns: [/^rubocop\b/i, /^bundle\s+exec\s+rubocop\b/i], + contentPatterns: [/^Inspecting \d+ files/m, /^[\w./-]+\.rb:\d+:\d+:\s+[A-Z]:/m], + }, + { + type: "docker-ps", + category: "docker", + commandPatterns: [/^docker\s+ps\b/i], + contentPatterns: [/^CONTAINER ID\s+IMAGE\s+COMMAND/m], + }, + { + type: "docker-logs", + category: "docker", + commandPatterns: [/^docker\s+logs\b/i, /^docker\s+compose\s+logs\b/i], + contentPatterns: [ + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/m, + /\b(?:ERROR|WARN|INFO)\b/, + /^Attaching to /m, + ], + }, + { + type: "aws", + category: "cloud", + commandPatterns: [/^aws\b/i], + contentPatterns: [/An error occurred \([A-Za-z0-9]+\) when calling/, /^(?:upload|download): /m], + }, + { + type: "gcloud", + category: "cloud", + commandPatterns: [/^gcloud\b/i], + contentPatterns: [/^ERROR: \(gcloud\./m, /^Updated property \[/m], + }, + { + type: "ssh", + category: "cloud", + commandPatterns: [/^ssh\b/i], + contentPatterns: [ + /Permission denied \(/, + /Host key verification failed/, + /Connection timed out/, + ], + }, + { + type: "rsync", + category: "cloud", + commandPatterns: [/^rsync\b/i], + contentPatterns: [/^sending incremental file list/m, /^rsync error:/m], + }, + { + type: "curl", + category: "cloud", + commandPatterns: [/^curl\b/i], + contentPatterns: [/curl: \(\d+\)/, /^HTTP\/\d(?:\.\d)? \d{3}/m], + }, + { + type: "wget", + category: "cloud", + commandPatterns: [/^wget\b/i], + contentPatterns: [/^--\d{4}-\d{2}-\d{2}/m, /^ERROR \d{3}:/m], + }, + { + type: "json-output", + category: "generic", + commandPatterns: [/^jq\b/i, /^cat\s+.*\.json\b/i], + contentPatterns: [/^\s*[\[{][\s\S]*[\]}]\s*$/], + }, + { + type: "shell-ls", + category: "shell", + commandPatterns: [/^ls(?:\s+-[A-Za-z]+)?\b/i], + contentPatterns: [/^total \d+/m, /^\S+\s+\S+\s+\d+\s+\w+\s+\d{1,2}\s+/m], + }, + { + type: "shell-find", + category: "shell", + commandPatterns: [/^find\b/i], + contentPatterns: [/^(?:\.{1,2}|\/|[\w.-]+\/).+/m], + }, + { + type: "shell-grep", + category: "shell", + commandPatterns: [/^(?:grep|rg|ag)\b/i], + contentPatterns: [ + /^[\w./-]+\.(?:ts|tsx|js|jsx|py|go|rs|java|rb|md|json|ya?ml|txt):\d*:/m, + /^[\w./-]+\/[\w./-]+:\d*:/m, + ], + }, + { + type: "shell-ps", + category: "shell", + commandPatterns: [/^ps\b/i], + contentPatterns: [/^(?:USER\s+PID|\s*PID\s+)/m], + }, + { + type: "shell-df", + category: "shell", + commandPatterns: [/^df\b/i], + contentPatterns: [/^Filesystem\s+.*Use%/m], + }, + { + type: "shell-du", + category: "shell", + commandPatterns: [/^du\b/i], + contentPatterns: [/^\d+(?:\.\d+)?[KMGTP]?\s+\S+/m], + }, + { + type: "error-stacktrace", + category: "generic", + commandPatterns: [], + contentPatterns: [ + /Traceback \(most recent call last\):/, + /^\s+at\s+\S+\s+\(.+:\d+:\d+\)/m, + /^panic: /m, + /^thread '[^']+' panicked at/m, + ], + }, + { + type: "generic-error", + category: "generic", + commandPatterns: [], + contentPatterns: [/Error:/, /Exception:/, /Traceback \(most recent call last\):/], + }, +]; + +export function detectCommandFromText(text: string): string | null { + const firstLines = text.split(/\r?\n/).slice(0, 4); + for (const line of firstLines) { + const trimmed = line.trim().replace(/^\$\s+/, ""); + if (!trimmed) continue; + if (COMMAND_PREFIX_PATTERN.test(trimmed)) { + return trimmed; + } + } + return null; +} + +export function detectCommandType(text: string, command?: string | null): CommandDetectionResult { + const detectedCommand = command?.trim() || detectCommandFromText(text); + let best: CommandDetectionResult | null = null; + + for (const detector of DETECTORS) { + const matchedPatterns: string[] = []; + const commandMatched = + Boolean(detectedCommand) && + detector.commandPatterns.some((pattern) => { + const matched = pattern.test(detectedCommand ?? ""); + if (matched) matchedPatterns.push(pattern.source); + return matched; + }); + const contentMatches = detector.contentPatterns.filter((pattern) => { + const matched = pattern.test(text); + if (matched) matchedPatterns.push(pattern.source); + return matched; + }).length; + if (!commandMatched && contentMatches === 0) continue; + + const confidence = Math.min(1, (commandMatched ? 0.55 : 0) + contentMatches * 0.25); + if (!best || confidence > best.confidence) { + best = { + type: detector.type, + command: detectedCommand, + confidence, + category: detector.category, + matchedPatterns, + }; + } + } + + return ( + best ?? { + type: "unknown", + command: detectedCommand, + confidence: detectedCommand ? 0.35 : 0.1, + category: "generic", + matchedPatterns: [], + } + ); +} + +export function listCommandTypes(): string[] { + return DETECTORS.map((detector) => detector.type); +} + +export const detectCommandOutput = detectCommandType; diff --git a/open-sse/services/compression/engines/rtk/deduplicator.ts b/open-sse/services/compression/engines/rtk/deduplicator.ts new file mode 100644 index 0000000000..ea473097fd --- /dev/null +++ b/open-sse/services/compression/engines/rtk/deduplicator.ts @@ -0,0 +1,37 @@ +export interface DeduplicateOptions { + threshold?: number; +} + +export function deduplicateRepeatedLines( + text: string, + options: DeduplicateOptions = {} +): { + text: string; + collapsed: number; +} { + const threshold = Math.max(2, Math.floor(options.threshold ?? 3)); + const lines = text.split(/\r?\n/); + const output: string[] = []; + let collapsed = 0; + + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + let runLength = 1; + while (index + runLength < lines.length && lines[index + runLength] === line) { + runLength++; + } + + if (line.trim() && runLength >= threshold) { + output.push(line); + output.push(`[line repeated ${runLength - 1}x]`); + output.push(`[rtk:dropped ${runLength - 1} repeated lines]`); + collapsed += runLength - 1; + index += runLength - 1; + continue; + } + + output.push(line); + } + + return { text: output.join("\n"), collapsed }; +} diff --git a/open-sse/services/compression/engines/rtk/filterLoader.ts b/open-sse/services/compression/engines/rtk/filterLoader.ts new file mode 100644 index 0000000000..8c776a9b85 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filterLoader.ts @@ -0,0 +1,220 @@ +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import os from "node:os"; +import crypto from "node:crypto"; +import { detectCommandType } from "./commandDetector.ts"; +import { validateRtkFilter, type RtkFilterDefinition } from "./filterSchema.ts"; + +let cache: RtkFilterDefinition[] | null = null; +let cacheKey: string | null = null; +let diagnostics: RtkFilterLoadDiagnostic[] = []; + +export interface RtkFilterLoadDiagnostic { + source: "project" | "global" | "builtin"; + path?: string; + level: "warning" | "error"; + message: string; +} + +interface FilterSource { + source: "project" | "global" | "builtin"; + path: string; + trusted: boolean; +} + +interface RtkFilterLoadOptions { + refresh?: boolean; + customFiltersEnabled?: boolean; + trustProjectFilters?: boolean; +} + +function getFiltersDir(): string { + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + path.join(moduleDir, "filters"), + path.join(moduleDir, "..", "services", "compression", "engines", "rtk", "filters"), + path.join(process.cwd(), "open-sse", "services", "compression", "engines", "rtk", "filters"), + path.join( + process.cwd(), + "app", + "open-sse", + "services", + "compression", + "engines", + "rtk", + "filters" + ), + ]; + return ( + candidates.find((candidate, index) => { + return candidates.indexOf(candidate) === index && fs.existsSync(candidate); + }) ?? candidates[0] + ); +} + +function getDataDir(): string { + return process.env.DATA_DIR || path.join(os.homedir(), ".omniroute"); +} + +function sha256(value: string): string { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +function projectFiltersTrusted( + filtersPath: string, + trustProjectFilters = false +): boolean | "changed" { + if (trustProjectFilters) return true; + if (process.env.OMNIROUTE_RTK_TRUST_PROJECT_FILTERS === "1") return true; + const trustPath = path.join(path.dirname(filtersPath), "trust.json"); + if (!fs.existsSync(trustPath)) return false; + try { + const filtersHash = sha256(fs.readFileSync(filtersPath, "utf8")); + const trust = JSON.parse(fs.readFileSync(trustPath, "utf8")) as Record; + const trustedHash = + typeof trust.filtersSha256 === "string" + ? trust.filtersSha256 + : typeof trust.trustedFiltersSha256 === "string" + ? trust.trustedFiltersSha256 + : null; + if (!trustedHash) return false; + return trustedHash === filtersHash ? true : "changed"; + } catch { + return false; + } +} + +function collectFilterSources(options: RtkFilterLoadOptions = {}): FilterSource[] { + const sources: FilterSource[] = []; + const projectPath = path.join(process.cwd(), ".rtk", "filters.json"); + if (options.customFiltersEnabled !== false && fs.existsSync(projectPath)) { + const trusted = projectFiltersTrusted(projectPath, options.trustProjectFilters === true); + if (trusted === true) { + sources.push({ source: "project", path: projectPath, trusted: true }); + } else { + diagnostics.push({ + source: "project", + path: projectPath, + level: "warning", + message: + trusted === "changed" + ? "Project RTK filters changed after trust and were skipped" + : "Project RTK filters are untrusted and were skipped", + }); + } + } + + const globalPath = path.join(getDataDir(), "rtk", "filters.json"); + if (options.customFiltersEnabled !== false && fs.existsSync(globalPath)) { + sources.push({ source: "global", path: globalPath, trusted: true }); + } + + const builtinDir = getFiltersDir(); + if (fs.existsSync(builtinDir)) { + for (const file of fs + .readdirSync(builtinDir) + .filter((entry) => entry.endsWith(".json")) + .sort()) { + sources.push({ + source: "builtin", + path: path.join(builtinDir, file), + trusted: true, + }); + } + } + + return sources; +} + +function parseFilterFile(source: FilterSource): RtkFilterDefinition[] { + try { + const parsed = JSON.parse(fs.readFileSync(source.path, "utf8")); + const entries = Array.isArray(parsed) ? parsed : [parsed]; + return entries.map(validateRtkFilter); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (source.source === "builtin") { + throw new Error(`Invalid RTK filter ${path.basename(source.path)}: ${message}`); + } + diagnostics.push({ + source: source.source, + path: source.path, + level: "warning", + message: `Invalid custom RTK filter skipped: ${message}`, + }); + return []; + } +} + +export function loadRtkFilters(options: RtkFilterLoadOptions = {}): RtkFilterDefinition[] { + const currentCacheKey = [ + process.cwd(), + getDataDir(), + options.customFiltersEnabled === false ? "builtin-only" : "custom", + options.trustProjectFilters === true ? "trusted-project" : "trust-file", + process.env.OMNIROUTE_RTK_TRUST_PROJECT_FILTERS === "1" ? "env-trust" : "env-normal", + ].join("|"); + if (cache && cacheKey === currentCacheKey && !options.refresh) return cache; + diagnostics = []; + const filters: RtkFilterDefinition[] = []; + for (const source of collectFilterSources(options)) { + filters.push(...parseFilterFile(source)); + } + + const sorted = filters.sort((a, b) => b.priority - a.priority || a.id.localeCompare(b.id)); + cache = sorted; + cacheKey = currentCacheKey; + return sorted; +} + +export function getRtkFilterLoadDiagnostics(): RtkFilterLoadDiagnostic[] { + loadRtkFilters(); + return diagnostics.map((entry) => ({ ...entry })); +} + +export function getRtkFilterCatalog(): Array< + Pick< + RtkFilterDefinition, + "id" | "name" | "description" | "commandTypes" | "category" | "priority" + > +> { + return loadRtkFilters().map((filter) => ({ + id: filter.id, + name: filter.name, + description: filter.description, + commandTypes: filter.commandTypes, + category: filter.category, + priority: filter.priority, + })); +} + +export function matchRtkFilter( + text: string, + command?: string | null, + options: RtkFilterLoadOptions = {} +): RtkFilterDefinition | null { + const detection = detectCommandType(text, command); + const detectedCommand = detection.command ?? command ?? ""; + const matchesPattern = (pattern: string, value: string): boolean => { + try { + return new RegExp(pattern, "im").test(value); + } catch { + return false; + } + }; + const filters = loadRtkFilters(options); + return ( + filters.find((filter) => filter.commandTypes.includes(detection.type)) ?? + filters.find( + (filter) => + detectedCommand && + filter.commandPatterns.some((pattern) => matchesPattern(pattern, detectedCommand)) + ) ?? + filters.find((filter) => + filter.matchPatterns.some((pattern) => matchesPattern(pattern, text)) + ) ?? + filters.find((filter) => filter.commandTypes.includes("generic-output")) ?? + null + ); +} diff --git a/open-sse/services/compression/engines/rtk/filterSchema.ts b/open-sse/services/compression/engines/rtk/filterSchema.ts new file mode 100644 index 0000000000..bb5f67d974 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filterSchema.ts @@ -0,0 +1,182 @@ +import { z } from "zod"; + +const rtkFilterCategorySchema = z.enum([ + "git", + "test", + "build", + "shell", + "docker", + "package", + "infra", + "cloud", + "generic", +]); + +const rtkReplaceRuleSchema = z + .object({ + pattern: z.string().min(1), + replacement: z.string(), + }) + .strict(); + +const rtkMatchOutputRuleSchema = z + .object({ + pattern: z.string().min(1), + message: z.string(), + unless: z.string().min(1).optional(), + }) + .strict(); + +const rtkInlineTestSchema = z + .object({ + name: z.string().min(1), + input: z.string(), + expected: z.string(), + command: z.string().optional(), + }) + .strict(); + +const rtkFilterMatchSchema = z + .object({ + commands: z.array(z.string().min(1)).default([]), + patterns: z.array(z.string().min(1)).default([]), + outputTypes: z.array(z.string().min(1)).default([]), + }) + .strict(); + +const rtkFilterRulesSchema = z + .object({ + stripAnsi: z.boolean().default(false), + replace: z.array(rtkReplaceRuleSchema).default([]), + matchOutput: z.array(rtkMatchOutputRuleSchema).default([]), + includePatterns: z.array(z.string()).default([]), + dropPatterns: z.array(z.string()).default([]), + collapsePatterns: z.array(z.string()).default([]), + deduplicate: z.boolean().default(false), + truncateLineAt: z.number().int().min(0).default(0), + maxLines: z.number().int().min(0).default(0), + headLines: z.number().int().min(0).default(20), + tailLines: z.number().int().min(0).default(20), + onEmpty: z.string().default(""), + filterStderr: z.boolean().default(false), + }) + .strict(); + +const rtkFilterPreserveSchema = z + .object({ + errorPatterns: z.array(z.string()).default([]), + summaryPatterns: z.array(z.string()).default([]), + }) + .strict(); + +export const rtkFilterPackSchema = z + .object({ + id: z.string().min(1), + label: z.string().min(1), + description: z.string().default(""), + category: rtkFilterCategorySchema, + priority: z.number().int().min(0).max(100).default(50), + match: rtkFilterMatchSchema, + rules: rtkFilterRulesSchema.default({}), + preserve: rtkFilterPreserveSchema.default({}), + tests: z.array(rtkInlineTestSchema).default([]), + }) + .strict(); + +const legacyRtkFilterSchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string().default(""), + commandTypes: z.array(z.string().min(1)).min(1), + category: rtkFilterCategorySchema, + priority: z.number().int().min(0).max(100).default(50), + stripPatterns: z.array(z.string()).default([]), + keepPatterns: z.array(z.string()).default([]), + priorityPatterns: z.array(z.string()).default([]), + collapsePatterns: z.array(z.string()).default([]), + stripAnsi: z.boolean().default(false), + replace: z.array(rtkReplaceRuleSchema).default([]), + matchOutput: z.array(rtkMatchOutputRuleSchema).default([]), + truncateLineAt: z.number().int().min(0).default(0), + onEmpty: z.string().default(""), + filterStderr: z.boolean().default(false), + maxLines: z.number().int().min(0).default(0), + preserveHead: z.number().int().min(0).default(20), + preserveTail: z.number().int().min(0).default(20), + tests: z.array(rtkInlineTestSchema).default([]), + }) + .strict(); + +export const rtkFilterSchema = z.union([rtkFilterPackSchema, legacyRtkFilterSchema]); + +export type RtkFilterPack = z.infer; + +export interface RtkFilterDefinition { + id: string; + name: string; + description: string; + commandTypes: string[]; + commandPatterns: string[]; + matchPatterns: string[]; + category: z.infer; + priority: number; + stripPatterns: string[]; + keepPatterns: string[]; + priorityPatterns: string[]; + collapsePatterns: string[]; + stripAnsi: boolean; + replace: Array<{ pattern: string; replacement: string }>; + matchOutput: Array<{ pattern: string; message: string; unless?: string }>; + truncateLineAt: number; + onEmpty: string; + filterStderr: boolean; + deduplicate: boolean; + maxLines: number; + preserveHead: number; + preserveTail: number; + tests: Array<{ name: string; input: string; expected: string; command?: string }>; +} + +function isCanonicalFilter(value: z.infer): value is RtkFilterPack { + return "label" in value && "match" in value && "rules" in value; +} + +export function validateRtkFilter(value: unknown): RtkFilterDefinition { + const parsed = rtkFilterSchema.parse(value); + if (!isCanonicalFilter(parsed)) { + return { + ...parsed, + commandPatterns: [], + matchPatterns: [], + deduplicate: parsed.collapsePatterns.length > 0, + }; + } + + const preservePatterns = [...parsed.preserve.errorPatterns, ...parsed.preserve.summaryPatterns]; + return { + id: parsed.id, + name: parsed.label, + description: parsed.description, + commandTypes: parsed.match.outputTypes, + commandPatterns: parsed.match.commands, + matchPatterns: parsed.match.patterns, + category: parsed.category, + priority: parsed.priority, + stripPatterns: parsed.rules.dropPatterns, + keepPatterns: parsed.rules.includePatterns, + priorityPatterns: preservePatterns, + collapsePatterns: parsed.rules.collapsePatterns, + stripAnsi: parsed.rules.stripAnsi, + replace: parsed.rules.replace, + matchOutput: parsed.rules.matchOutput, + truncateLineAt: parsed.rules.truncateLineAt, + onEmpty: parsed.rules.onEmpty, + filterStderr: parsed.rules.filterStderr, + deduplicate: parsed.rules.deduplicate, + maxLines: parsed.rules.maxLines, + preserveHead: parsed.rules.headLines, + preserveTail: parsed.rules.tailLines, + tests: parsed.tests, + }; +} diff --git a/open-sse/services/compression/engines/rtk/filters/aws.json b/open-sse/services/compression/engines/rtk/filters/aws.json new file mode 100644 index 0000000000..7a3f82d887 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/aws.json @@ -0,0 +1,34 @@ +{ + "id": "aws", + "label": "AWS CLI", + "description": "Compress AWS CLI progress output while preserving errors and completed transfers.", + "category": "cloud", + "priority": 76, + "match": { + "outputTypes": ["aws"], + "commands": ["^aws\\b"], + "patterns": ["An error occurred \\([A-Za-z0-9]+\\) when calling", "^(?:upload|download): "] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^Completed \\d", "^\\s*$"], + "includePatterns": ["^(?:upload|download): ", "An error occurred \\("], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 120, + "headLines": 30, + "tailLines": 40 + }, + "preserve": { + "errorPatterns": ["An error occurred", "AccessDenied", "Throttling", "ValidationError"], + "summaryPatterns": ["^(?:upload|download): "] + }, + "tests": [ + { + "name": "inline sample", + "command": "aws s3 cp", + "input": "Completed 1.0 MiB/2.0 MiB\nupload: ./app.log to s3://bucket/app.log\nAn error occurred (AccessDenied) when calling the PutObject operation: denied\n", + "expected": "upload: ./app.log to s3://bucket/app.log\nAn error occurred (AccessDenied) when calling the PutObject operation: denied" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/biome.json b/open-sse/services/compression/engines/rtk/filters/biome.json new file mode 100644 index 0000000000..d0c7564b39 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/biome.json @@ -0,0 +1,35 @@ +{ + "id": "biome", + "label": "Biome", + "description": "Compact Biome lint/format output.", + "category": "build", + "priority": 80, + "match": { + "outputTypes": [], + "commands": ["^biome\\b", "^npx\\s+biome\\b"], + "patterns": ["Checked \\d+ files?", "biome"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^\\s*$", "^Checked \\d+ files? in"], + "includePatterns": ["error", "warning", "✖", "Checked", "Fixed", "^\\s*>"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 100, + "headLines": 25, + "tailLines": 40, + "onEmpty": "biome: ok" + }, + "preserve": { + "errorPatterns": ["error", "✖"], + "summaryPatterns": ["Checked", "Fixed"] + }, + "tests": [ + { + "name": "inline sample", + "command": "biome", + "input": "Checked 10 files in 20ms\nerror lint/a.ts:1:1 boom\nFixed 1 file\n", + "expected": "error lint/a.ts:1:1 boom\nFixed 1 file" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/build-eslint.json b/open-sse/services/compression/engines/rtk/filters/build-eslint.json new file mode 100644 index 0000000000..610e616363 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/build-eslint.json @@ -0,0 +1,37 @@ +{ + "id": "build-eslint", + "label": "ESLint", + "description": "Keep lint diagnostics and problem summary.", + "category": "build", + "priority": 88, + "match": { + "outputTypes": ["build-eslint"], + "commands": ["^(?:eslint|npm\\s+run\\s+lint)\\b"], + "patterns": ["\\s+\\d+:\\d+\\s+(?:error|warning)\\s+", "✖\\s+\\d+\\s+problems?"] + }, + "rules": { + "includePatterns": [ + "\\s+\\d+:\\d+\\s+(error|warning)\\s+", + "✖\\s+\\d+\\s+problems?", + "^/.*\\.(ts|tsx|js|jsx)$" + ], + "dropPatterns": ["^\\s*$"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 120, + "headLines": 20, + "tailLines": 40 + }, + "preserve": { + "errorPatterns": ["error", "warning", "problems?"], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "command": "(?:eslint|npm run lint)", + "input": "/repo/a.ts\n 1:1 error boom no-boom\n✖ 1 problem (1 error, 0 warnings)\n", + "expected": "/repo/a.ts\n 1:1 error boom no-boom\n✖ 1 problem (1 error, 0 warnings)" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/build-typescript.json b/open-sse/services/compression/engines/rtk/filters/build-typescript.json new file mode 100644 index 0000000000..5ef3e314c5 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/build-typescript.json @@ -0,0 +1,33 @@ +{ + "id": "build-typescript", + "label": "TypeScript", + "description": "Keep TypeScript diagnostics and file locations.", + "category": "build", + "priority": 93, + "match": { + "outputTypes": ["build-typescript"], + "commands": ["^(?:tsc|npm\\s+run\\s+typecheck)\\b"], + "patterns": ["TS\\d{4}:", "error TS\\d{4}"] + }, + "rules": { + "includePatterns": ["TS\\d{4}:", "error TS\\d{4}", "^Found \\d+ errors?"], + "dropPatterns": ["^\\s*$"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 120, + "headLines": 20, + "tailLines": 40 + }, + "preserve": { + "errorPatterns": ["TS\\d{4}", "Found \\d+"], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "command": "(?:tsc|npm run typecheck)", + "input": "src/a.ts:1:1 - error TS2322: Type string is not assignable\nFound 1 error.\n", + "expected": "src/a.ts:1:1 - error TS2322: Type string is not assignable\nFound 1 error." + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/build-vite.json b/open-sse/services/compression/engines/rtk/filters/build-vite.json new file mode 100644 index 0000000000..7e81845b81 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/build-vite.json @@ -0,0 +1,41 @@ +{ + "id": "build-vite", + "label": "Vite Build", + "description": "Keep Vite errors and final build summary.", + "category": "build", + "priority": 83, + "match": { + "outputTypes": ["build-vite"], + "commands": ["^vite\\s+build\\b", "^npm\\s+run\\s+build\\b", "^pnpm\\s+build\\b"], + "patterns": ["vite v[\\d.]+", "✓ built in"] + }, + "rules": { + "stripAnsi": true, + "matchOutput": [ + { + "pattern": "✓ built in", + "message": "vite: build ok", + "unless": "error|failed" + } + ], + "includePatterns": ["error", "failed", "✓ built in", "dist/", "rendering chunks"], + "dropPatterns": ["^transforming ", "^✓ \\d+ modules transformed", "^\\s*$"], + "collapsePatterns": ["^dist/"], + "deduplicate": true, + "maxLines": 120, + "headLines": 30, + "tailLines": 45 + }, + "preserve": { + "errorPatterns": ["error", "failed"], + "summaryPatterns": ["✓ built in"] + }, + "tests": [ + { + "name": "inline sample", + "command": "vite build", + "input": "vite v5.0.0 building for production...\n✓ 10 modules transformed.\n✓ built in 100ms\n", + "expected": "vite: build ok" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/build-webpack.json b/open-sse/services/compression/engines/rtk/filters/build-webpack.json new file mode 100644 index 0000000000..55e1eb845b --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/build-webpack.json @@ -0,0 +1,34 @@ +{ + "id": "build-webpack", + "label": "Webpack Build", + "description": "Keep webpack errors/warnings and compilation summary.", + "category": "build", + "priority": 84, + "match": { + "outputTypes": ["build-webpack"], + "commands": ["^webpack\\b", "^npx\\s+webpack\\b"], + "patterns": ["webpack \\d", "compiled (?:successfully|with)"] + }, + "rules": { + "stripAnsi": true, + "includePatterns": ["ERROR in", "WARNING in", "compiled ", "asset ", "Entrypoint"], + "dropPatterns": ["^\\s*$", "^cacheable modules"], + "collapsePatterns": ["^asset "], + "deduplicate": true, + "maxLines": 140, + "headLines": 35, + "tailLines": 50 + }, + "preserve": { + "errorPatterns": ["ERROR in"], + "summaryPatterns": ["compiled "] + }, + "tests": [ + { + "name": "inline sample", + "command": "webpack", + "input": "asset main.js 10 KiB\nERROR in ./src/a.ts\ncompiled with 1 error\n", + "expected": "asset main.js 10 KiB\nERROR in ./src/a.ts\ncompiled with 1 error" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/bundle-install.json b/open-sse/services/compression/engines/rtk/filters/bundle-install.json new file mode 100644 index 0000000000..7b126ae117 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/bundle-install.json @@ -0,0 +1,35 @@ +{ + "id": "bundle-install", + "label": "Bundle Install", + "description": "Strip bundle install noise and keep final status.", + "category": "package", + "priority": 70, + "match": { + "outputTypes": [], + "commands": ["^bundle\\s+install\\b"], + "patterns": ["Bundle complete", "Using "] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^Using ", "^Fetching ", "^Installing ", "^\\s*$"], + "includePatterns": ["Bundle complete", "Your bundle is complete", "ERROR", "Gem::"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 100, + "headLines": 25, + "tailLines": 35, + "onEmpty": "bundle: ok" + }, + "preserve": { + "errorPatterns": ["ERROR", "Gem::"], + "summaryPatterns": ["Bundle complete", "Your bundle is complete"] + }, + "tests": [ + { + "name": "inline sample", + "command": "bundle install", + "input": "Using rake 13.0\nInstalling pg 1.5\nBundle complete! 4 Gemfile dependencies\n", + "expected": "Bundle complete! 4 Gemfile dependencies" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/curl.json b/open-sse/services/compression/engines/rtk/filters/curl.json new file mode 100644 index 0000000000..936f290706 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/curl.json @@ -0,0 +1,34 @@ +{ + "id": "curl", + "label": "curl", + "description": "Remove curl progress noise and keep HTTP status/errors.", + "category": "cloud", + "priority": 70, + "match": { + "outputTypes": ["curl"], + "commands": ["^curl\\b"], + "patterns": ["curl: \\(\\d+\\)", "^HTTP/\\d(?:\\.\\d)? \\d{3}"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^\\s*% Total\\s+% Received", "^\\s*\\d+\\s+\\d+", "^\\s*$"], + "includePatterns": ["^HTTP/\\d(?:\\.\\d)? \\d{3}", "^curl: \\(\\d+\\)", "^[<{]"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 100, + "headLines": 30, + "tailLines": 40 + }, + "preserve": { + "errorPatterns": ["curl: \\(\\d+\\)", "^HTTP/\\d(?:\\.\\d)? [45]\\d\\d"], + "summaryPatterns": ["^HTTP/\\d(?:\\.\\d)? \\d{3}"] + }, + "tests": [ + { + "name": "inline sample", + "command": "curl -i", + "input": "% Total % Received\n 0 0 0 0\nHTTP/1.1 500 Internal Server Error\ncurl: (22) The requested URL returned error: 500\n", + "expected": "HTTP/1.1 500 Internal Server Error\ncurl: (22) The requested URL returned error: 500" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/df.json b/open-sse/services/compression/engines/rtk/filters/df.json new file mode 100644 index 0000000000..5be798a273 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/df.json @@ -0,0 +1,34 @@ +{ + "id": "df", + "label": "df", + "description": "Keep filesystem usage table headers and mounted filesystems.", + "category": "shell", + "priority": 67, + "match": { + "outputTypes": ["shell-df"], + "commands": ["^df\\b"], + "patterns": ["^Filesystem\\s+.*Use%"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^tmpfs\\s", "^udev\\s", "^\\s*$"], + "includePatterns": ["^Filesystem\\s+", "^/dev/\\S+\\s+"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 80, + "headLines": 25, + "tailLines": 25 + }, + "preserve": { + "errorPatterns": ["No such file", "Permission denied"], + "summaryPatterns": ["Use%", "^/dev/"] + }, + "tests": [ + { + "name": "inline sample", + "command": "df -h", + "input": "Filesystem Size Used Avail Use% Mounted on\nudev 2.0G 0 2.0G 0% /dev\n/dev/sda1 50G 45G 5.0G 90% /\n", + "expected": "Filesystem Size Used Avail Use% Mounted on\n/dev/sda1 50G 45G 5.0G 90% /" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/docker-logs.json b/open-sse/services/compression/engines/rtk/filters/docker-logs.json new file mode 100644 index 0000000000..10b5d11f43 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/docker-logs.json @@ -0,0 +1,42 @@ +{ + "id": "docker-logs", + "label": "Docker Logs", + "description": "Keep warnings/errors and recent Docker log context.", + "category": "docker", + "priority": 84, + "match": { + "outputTypes": ["docker-logs"], + "commands": ["^docker\\s+(?:compose\\s+)?logs\\b"], + "patterns": ["\\b(?:ERROR|WARN|INFO)\\b", "^Attaching to "] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^Attaching to ", "^\\s*$"], + "includePatterns": [ + "ERROR", + "WARN", + "Exception", + "Traceback", + "failed", + "listening", + "started" + ], + "collapsePatterns": ["INFO"], + "deduplicate": true, + "maxLines": 120, + "headLines": 20, + "tailLines": 60 + }, + "preserve": { + "errorPatterns": ["ERROR", "Exception", "failed"], + "summaryPatterns": ["started", "listening"] + }, + "tests": [ + { + "name": "inline sample", + "command": "docker (?:compose )?logs", + "input": "Attaching to api\nINFO started\nINFO started\nERROR failed to connect\n", + "expected": "INFO started\nERROR failed to connect" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/docker-ps.json b/open-sse/services/compression/engines/rtk/filters/docker-ps.json new file mode 100644 index 0000000000..b7dc4efee0 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/docker-ps.json @@ -0,0 +1,33 @@ +{ + "id": "docker-ps", + "label": "Docker PS", + "description": "Keep docker container table header and rows under a fixed cap.", + "category": "docker", + "priority": 60, + "match": { + "outputTypes": ["docker-ps"], + "commands": ["^docker\\s+ps\\b"], + "patterns": ["^CONTAINER ID\\s+IMAGE\\s+COMMAND"] + }, + "rules": { + "includePatterns": [], + "dropPatterns": ["^\\s*$"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 80, + "headLines": 30, + "tailLines": 20 + }, + "preserve": { + "errorPatterns": ["^CONTAINER ID"], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "command": "docker ps", + "input": "CONTAINER ID IMAGE COMMAND\nabc123 node \"npm start\"\n", + "expected": "CONTAINER ID IMAGE COMMAND\nabc123 node \"npm start\"" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/du.json b/open-sse/services/compression/engines/rtk/filters/du.json new file mode 100644 index 0000000000..54cca64e89 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/du.json @@ -0,0 +1,33 @@ +{ + "id": "du", + "label": "du", + "description": "Summarize disk-usage listings while preserving largest entries.", + "category": "shell", + "priority": 66, + "match": { + "outputTypes": ["shell-du"], + "commands": ["^du\\b"], + "patterns": ["^\\d+(?:\\.\\d+)?[KMGTP]?\\s+\\S+"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^\\s*$"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 80, + "headLines": 20, + "tailLines": 30 + }, + "preserve": { + "errorPatterns": ["Permission denied", "No such file"], + "summaryPatterns": ["\\s+\\.$", "\\s+total$"] + }, + "tests": [ + { + "name": "inline sample", + "command": "du -sh *", + "input": "4.0K package.json\n120M node_modules\n120M node_modules\n", + "expected": "4.0K package.json\n120M node_modules\n120M node_modules" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/error-stacktrace.json b/open-sse/services/compression/engines/rtk/filters/error-stacktrace.json new file mode 100644 index 0000000000..0d5fd3b8bf --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/error-stacktrace.json @@ -0,0 +1,41 @@ +{ + "id": "error-stacktrace", + "label": "Error Stacktrace", + "description": "Collapse stack traces while preserving error text and top frames.", + "category": "generic", + "priority": 70, + "match": { + "outputTypes": ["error-stacktrace"], + "commands": [], + "patterns": ["Traceback \\(most recent call last\\):", "^\\s+at\\s+", "^panic: "] + }, + "rules": { + "stripAnsi": true, + "includePatterns": [ + "Error:", + "Exception:", + "Traceback", + "^\\s+at ", + "^panic:", + "^thread ", + "^Caused by:" + ], + "dropPatterns": ["^\\s*$"], + "collapsePatterns": ["^\\s+at "], + "deduplicate": true, + "maxLines": 80, + "headLines": 25, + "tailLines": 30 + }, + "preserve": { + "errorPatterns": ["Error:", "Exception:", "^panic:", "Traceback"], + "summaryPatterns": ["Caused by:"] + }, + "tests": [ + { + "name": "inline sample", + "input": "Error: boom\n at fn (/repo/a.ts:1:1)\n at fn (/repo/a.ts:1:1)\nCaused by: bad\n", + "expected": "Error: boom\n at fn (/repo/a.ts:1:1)\nCaused by: bad" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/gcloud.json b/open-sse/services/compression/engines/rtk/filters/gcloud.json new file mode 100644 index 0000000000..19c5dd09a9 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/gcloud.json @@ -0,0 +1,34 @@ +{ + "id": "gcloud", + "label": "gcloud", + "description": "Compress Google Cloud CLI output and preserve actionable errors.", + "category": "cloud", + "priority": 75, + "match": { + "outputTypes": ["gcloud"], + "commands": ["^gcloud\\b"], + "patterns": ["^ERROR: \\(gcloud\\.", "^Updated property \\["] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^Waiting for operation", "^\\s*$"], + "includePatterns": ["^ERROR: \\(gcloud\\.", "^Updated property \\[", "^Created \\["], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 120, + "headLines": 30, + "tailLines": 40 + }, + "preserve": { + "errorPatterns": ["^ERROR: \\(gcloud\\.", "PERMISSION_DENIED", "NOT_FOUND"], + "summaryPatterns": ["^Updated property \\[", "^Created \\["] + }, + "tests": [ + { + "name": "inline sample", + "command": "gcloud run deploy", + "input": "Waiting for operation [deploy]\nUpdated property [core/project].\nERROR: (gcloud.run.deploy) PERMISSION_DENIED: denied\n", + "expected": "Updated property [core/project].\nERROR: (gcloud.run.deploy) PERMISSION_DENIED: denied" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/generic-output.json b/open-sse/services/compression/engines/rtk/filters/generic-output.json new file mode 100644 index 0000000000..da93022888 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/generic-output.json @@ -0,0 +1,32 @@ +{ + "id": "generic-output", + "label": "Generic Output", + "description": "Fallback filter for unknown command output.", + "category": "generic", + "priority": 10, + "match": { + "outputTypes": ["generic-output", "generic-error"], + "commands": [], + "patterns": ["Error:", "Exception:", "Traceback \\(most recent call last\\):"] + }, + "rules": { + "includePatterns": [], + "dropPatterns": ["^\\s*$"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 120, + "headLines": 35, + "tailLines": 45 + }, + "preserve": { + "errorPatterns": ["error", "failed", "exception", "traceback"], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "input": "\nError: boom\n\nTraceback (most recent call last):\n file.py:1\n", + "expected": "Error: boom\nTraceback (most recent call last):\n file.py:1" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/git-branch.json b/open-sse/services/compression/engines/rtk/filters/git-branch.json new file mode 100644 index 0000000000..b333932f08 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/git-branch.json @@ -0,0 +1,45 @@ +{ + "id": "git-branch", + "label": "Git Branch", + "description": "Compact git branch/switch output.", + "category": "git", + "priority": 94, + "match": { + "outputTypes": ["git-branch"], + "commands": ["^git\\s+(?:branch|checkout|switch)\\b"], + "patterns": ["^\\*\\s+\\S+", "Switched to (?:a new )?branch", "Already on"] + }, + "rules": { + "stripAnsi": true, + "matchOutput": [ + { + "pattern": "Switched to (?:a new )?branch", + "message": "git: switched branch", + "unless": "error|failed" + }, + { + "pattern": "Already on", + "message": "git: already on branch" + } + ], + "includePatterns": ["^\\*\\s+\\S+", "^\\s{2}\\S+", "Switched to", "Already on"], + "dropPatterns": ["^\\s*$"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 60, + "headLines": 20, + "tailLines": 20 + }, + "preserve": { + "errorPatterns": ["error", "failed"], + "summaryPatterns": ["Switched to", "Already on"] + }, + "tests": [ + { + "name": "inline sample", + "command": "git (?:branch|checkout|switch)", + "input": " main\n* feature/rtk\n release\n", + "expected": " main\n* feature/rtk\n release" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/git-diff.json b/open-sse/services/compression/engines/rtk/filters/git-diff.json new file mode 100644 index 0000000000..a97df1fbac --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/git-diff.json @@ -0,0 +1,33 @@ +{ + "id": "git-diff", + "label": "Git Diff", + "description": "Drop low-signal diff metadata while preserving files, hunks, and changed lines.", + "category": "git", + "priority": 94, + "match": { + "outputTypes": ["git-diff"], + "commands": ["^git\\s+(?:diff|show)\\b"], + "patterns": ["^diff --git ", "^@@\\s+-\\d+,\\d+\\s+\\+\\d+,\\d+\\s+@@"] + }, + "rules": { + "includePatterns": [], + "dropPatterns": ["^index [0-9a-f]+\\.\\.[0-9a-f]+.*$", "^--- a/", "^\\+\\+\\+ b/"], + "collapsePatterns": ["^\\s*$"], + "deduplicate": true, + "maxLines": 180, + "headLines": 40, + "tailLines": 50 + }, + "preserve": { + "errorPatterns": ["^diff --git ", "^@@ ", "^[+-](?![+-]{2})"], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "command": "git (?:diff|show)", + "input": "diff --git a/a.ts b/a.ts\n@@ -1,2 +1,2 @@\n-console.log(\"old\")\n+console.log(\"new\")\n", + "expected": "diff --git a/a.ts b/a.ts\n@@ -1,2 +1,2 @@\n-console.log(\"old\")\n+console.log(\"new\")\n" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/git-log.json b/open-sse/services/compression/engines/rtk/filters/git-log.json new file mode 100644 index 0000000000..4a54160537 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/git-log.json @@ -0,0 +1,33 @@ +{ + "id": "git-log", + "label": "Git Log", + "description": "Keep commit identifiers and subjects from verbose logs.", + "category": "git", + "priority": 70, + "match": { + "outputTypes": ["git-log"], + "commands": ["^git\\s+log\\b"], + "patterns": ["^commit [0-9a-f]{7,40}", "^Author: "] + }, + "rules": { + "includePatterns": ["^commit [0-9a-f]{7,40}", "^\\s{4}\\S"], + "dropPatterns": ["^Author: ", "^Date: ", "^\\s*$"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 120, + "headLines": 40, + "tailLines": 20 + }, + "preserve": { + "errorPatterns": ["^commit "], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "command": "git log", + "input": "commit abcdef1234567890\nAuthor: Dev \nDate: today\n\n feat: add rtk\n", + "expected": "commit abcdef1234567890\n feat: add rtk" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/git-status.json b/open-sse/services/compression/engines/rtk/filters/git-status.json new file mode 100644 index 0000000000..d2d21a66f5 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/git-status.json @@ -0,0 +1,40 @@ +{ + "id": "git-status", + "label": "Git Status", + "description": "Keep branch, staged/unstaged headings, and changed file lines.", + "category": "git", + "priority": 95, + "match": { + "outputTypes": ["git-status"], + "commands": ["^git\\s+status\\b"], + "patterns": ["^On branch ", "^Changes (?:not staged|to be committed)", "^Untracked files:"] + }, + "rules": { + "includePatterns": [ + "^On branch ", + "^Your branch ", + "^Changes ", + "^Untracked files:", + "^\\s*(modified|new file|deleted|renamed|both modified):", + "^\\s*[MADRCU?!]{1,2}\\s+" + ], + "dropPatterns": ["^\\s*$", "^\\s*\\(use .*\\)$"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 80, + "headLines": 12, + "tailLines": 20 + }, + "preserve": { + "errorPatterns": ["^Changes ", "^Untracked files:"], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "command": "git status", + "input": "On branch main\nChanges not staged for commit:\n (use \"git add\" to update)\n\tmodified: src/app.ts\nnothing added to commit\n", + "expected": "On branch main\nChanges not staged for commit:\n\tmodified: src/app.ts" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/golangci-lint.json b/open-sse/services/compression/engines/rtk/filters/golangci-lint.json new file mode 100644 index 0000000000..8bffbee1af --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/golangci-lint.json @@ -0,0 +1,34 @@ +{ + "id": "golangci-lint", + "label": "golangci-lint", + "description": "Keep golangci-lint findings and summary.", + "category": "build", + "priority": 82, + "match": { + "outputTypes": [], + "commands": ["^golangci-lint\\b"], + "patterns": ["issues:", "level=error", ":\\d+:\\d+:"] + }, + "rules": { + "stripAnsi": true, + "includePatterns": [":\\d+:\\d+:", "level=error", "issues:", "Error:"], + "dropPatterns": ["^\\s*$"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 140, + "headLines": 35, + "tailLines": 50 + }, + "preserve": { + "errorPatterns": ["level=error", "Error:"], + "summaryPatterns": ["issues:"] + }, + "tests": [ + { + "name": "inline sample", + "command": "golangci-lint", + "input": "main.go:1:1: issue text\nlevel=error msg=\"runner failed\"\nissues: 1\n", + "expected": "main.go:1:1: issue text\nlevel=error msg=\"runner failed\"\nissues: 1" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/json-output.json b/open-sse/services/compression/engines/rtk/filters/json-output.json new file mode 100644 index 0000000000..1ab8769266 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/json-output.json @@ -0,0 +1,40 @@ +{ + "id": "json-output", + "label": "JSON Output", + "description": "Keep actionable JSON status, error and message fields while preserving structure.", + "category": "generic", + "priority": 70, + "match": { + "outputTypes": ["json-output"], + "commands": ["^(?:jq\\b|cat\\s+.*\\.json\\b)"], + "patterns": ["^\\s*[\\[{][\\s\\S]*[\\]}]\\s*$"] + }, + "rules": { + "includePatterns": [ + "\"status\"\\s*:", + "\"error\"\\s*:", + "\"errors\"\\s*:", + "\"code\"\\s*:", + "\"message\"\\s*:", + "[{}\\[\\],]" + ], + "dropPatterns": ["^\\s*$"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 120, + "headLines": 20, + "tailLines": 30 + }, + "preserve": { + "errorPatterns": ["\"error\"", "\"errors\"", "\"message\""], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "command": "(?:jq\\b|cat .*\\.json\\b)", + "input": "{\n \"status\": \"ok\",\n \"items\": [1,2,3]\n}\n", + "expected": "{\n \"status\": \"ok\",\n \"items\": [1,2,3]\n}" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/make.json b/open-sse/services/compression/engines/rtk/filters/make.json new file mode 100644 index 0000000000..7b9d3a6b24 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/make.json @@ -0,0 +1,38 @@ +{ + "id": "make", + "label": "Make", + "description": "Strip make directory chatter and empty lines.", + "category": "build", + "priority": 78, + "match": { + "outputTypes": [], + "commands": ["^make\\b"], + "patterns": ["make\\[\\d+\\]: (?:Entering|Leaving) directory"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": [ + "^make\\[\\d+\\]: (?:Entering|Leaving) directory", + "^\\s*$", + "Nothing to be done for" + ], + "collapsePatterns": ["^(?:cc|gcc|clang|g\\+\\+)\\b"], + "deduplicate": true, + "maxLines": 120, + "headLines": 30, + "tailLines": 40, + "onEmpty": "make: ok" + }, + "preserve": { + "errorPatterns": ["error:", "failed", "\\*\\*\\*"], + "summaryPatterns": ["built", "linking"] + }, + "tests": [ + { + "name": "inline sample", + "command": "make", + "input": "make[1]: Entering directory '/repo'\ngcc -O2 foo.c\nmake[1]: Leaving directory '/repo'\n", + "expected": "gcc -O2 foo.c" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/mypy.json b/open-sse/services/compression/engines/rtk/filters/mypy.json new file mode 100644 index 0000000000..861bbe3e9b --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/mypy.json @@ -0,0 +1,34 @@ +{ + "id": "mypy", + "label": "mypy", + "description": "Keep mypy errors grouped by file and final summary.", + "category": "build", + "priority": 82, + "match": { + "outputTypes": [], + "commands": ["^mypy\\b", "^python\\s+-m\\s+mypy\\b"], + "patterns": [": error:", "Found \\d+ errors? in"] + }, + "rules": { + "stripAnsi": true, + "includePatterns": [": error:", ": note:", "Found \\d+ errors?", "Success: no issues found"], + "dropPatterns": ["^\\s*$"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 160, + "headLines": 40, + "tailLines": 60 + }, + "preserve": { + "errorPatterns": [": error:", "Found \\d+ errors?"], + "summaryPatterns": ["Success:", "Found \\d+"] + }, + "tests": [ + { + "name": "inline sample", + "command": "mypy", + "input": "app.py:1: error: Incompatible types\nFound 1 error in 1 file\n", + "expected": "app.py:1: error: Incompatible types\nFound 1 error in 1 file" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/npm-audit.json b/open-sse/services/compression/engines/rtk/filters/npm-audit.json new file mode 100644 index 0000000000..19fa224a38 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/npm-audit.json @@ -0,0 +1,43 @@ +{ + "id": "npm-audit", + "label": "npm audit", + "description": "Keep vulnerability counts and remediation hints.", + "category": "package", + "priority": 84, + "match": { + "outputTypes": ["npm-audit"], + "commands": ["^(?:npm|pnpm|yarn)\\s+audit\\b"], + "patterns": ["found \\d+ vulnerabilities", "\\bcritical\\b"] + }, + "rules": { + "stripAnsi": true, + "includePatterns": [ + "vulnerabilities", + "critical", + "high", + "moderate", + "low", + "fix available", + "npm audit fix", + "No vulnerabilities" + ], + "dropPatterns": ["^\\s*$", "^# npm audit report"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 120, + "headLines": 30, + "tailLines": 45 + }, + "preserve": { + "errorPatterns": ["critical", "high"], + "summaryPatterns": ["vulnerabilities", "No vulnerabilities"] + }, + "tests": [ + { + "name": "inline sample", + "command": "(?:npm|pnpm|yarn) audit", + "input": "# npm audit report\ncritical vulnerability in foo\nfound 1 vulnerabilities\nnpm audit fix\n", + "expected": "critical vulnerability in foo\nfound 1 vulnerabilities\nnpm audit fix" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/npm-install.json b/open-sse/services/compression/engines/rtk/filters/npm-install.json new file mode 100644 index 0000000000..df001e67b2 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/npm-install.json @@ -0,0 +1,41 @@ +{ + "id": "npm-install", + "label": "Package Install", + "description": "Keep install summaries, warnings and audit results.", + "category": "package", + "priority": 65, + "match": { + "outputTypes": ["npm-install"], + "commands": ["^(?:npm|pnpm|yarn)\\s+(?:install|add|update)\\b"], + "patterns": ["added \\d+ packages", "packages are looking for funding", "audited \\d+ packages"] + }, + "rules": { + "includePatterns": [ + "added \\d+ packages", + "removed \\d+ packages", + "changed \\d+ packages", + "audited \\d+ packages", + "vulnerabilities", + "WARN", + "ERR!" + ], + "dropPatterns": ["^\\s*$", "^\\s*\\|", "^Progress:"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 80, + "headLines": 20, + "tailLines": 30 + }, + "preserve": { + "errorPatterns": ["WARN", "ERR", "vulnerabilities"], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "command": "(?:npm|pnpm|yarn) (?:install|add|update)", + "input": "added 3 packages\npackages are looking for funding\naudited 3 packages\n", + "expected": "added 3 packages\naudited 3 packages" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/nx.json b/open-sse/services/compression/engines/rtk/filters/nx.json new file mode 100644 index 0000000000..6973433228 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/nx.json @@ -0,0 +1,34 @@ +{ + "id": "nx", + "label": "Nx", + "description": "Compact Nx task graph and output.", + "category": "build", + "priority": 76, + "match": { + "outputTypes": [], + "commands": ["^nx\\b", "^npx\\s+nx\\b"], + "patterns": ["NX ", "Successfully ran target"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^\\s*$", "^> nx run"], + "includePatterns": ["NX ", "ERROR", "Failed", "Successfully ran", "Running target"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 120, + "headLines": 25, + "tailLines": 50 + }, + "preserve": { + "errorPatterns": ["ERROR", "Failed"], + "summaryPatterns": ["Successfully ran"] + }, + "tests": [ + { + "name": "inline sample", + "command": "nx", + "input": "> nx run app:build\nNX Failed tasks:\nERROR boom\n", + "expected": "NX Failed tasks:\nERROR boom" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/pip.json b/open-sse/services/compression/engines/rtk/filters/pip.json new file mode 100644 index 0000000000..91edf38ca3 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/pip.json @@ -0,0 +1,41 @@ +{ + "id": "pip", + "label": "pip", + "description": "Strip pip install progress and keep install/failure summary.", + "category": "package", + "priority": 72, + "match": { + "outputTypes": [], + "commands": ["^pip\\s+(?:install|sync|download)\\b", "^python\\s+-m\\s+pip\\s+install\\b"], + "patterns": ["Successfully installed", "Requirement already satisfied"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": [ + "^Collecting ", + "^Downloading ", + "^Installing collected packages:", + "^Requirement already satisfied", + "^\\s*$" + ], + "includePatterns": ["Successfully installed", "ERROR:", "WARNING:", "failed", "Could not find"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 100, + "headLines": 25, + "tailLines": 35, + "onEmpty": "pip: ok" + }, + "preserve": { + "errorPatterns": ["ERROR:", "failed", "Could not find"], + "summaryPatterns": ["Successfully installed"] + }, + "tests": [ + { + "name": "inline sample", + "command": "pip (?:install|sync|download)", + "input": "Collecting requests\nDownloading requests.whl\nSuccessfully installed requests-2.0\n", + "expected": "Successfully installed requests-2.0" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/playwright.json b/open-sse/services/compression/engines/rtk/filters/playwright.json new file mode 100644 index 0000000000..1899b6367c --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/playwright.json @@ -0,0 +1,42 @@ +{ + "id": "playwright", + "label": "Playwright", + "description": "Keep failed specs, error context, and summary.", + "category": "test", + "priority": 86, + "match": { + "outputTypes": [], + "commands": ["^playwright\\s+test\\b", "^npx\\s+playwright\\s+test\\b"], + "patterns": ["Running \\d+ tests?", "Error Context:", "failed"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^\\s*$", "^Running \\d+ tests? using"], + "includePatterns": [ + "✘", + "Error:", + "Error Context:", + "failed", + "passed", + "Slow test file", + "^\\s*\\d+\\)" + ], + "collapsePatterns": ["^\\s+at "], + "deduplicate": true, + "maxLines": 160, + "headLines": 35, + "tailLines": 60 + }, + "preserve": { + "errorPatterns": ["Error:", "✘", "failed"], + "summaryPatterns": ["passed", "failed"] + }, + "tests": [ + { + "name": "inline sample", + "command": "playwright test", + "input": "Running 2 tests using 1 worker\n ✘ 1 login.spec.ts:3:1 › login\nError: expected visible\n 1 failed\n", + "expected": " ✘ 1 login.spec.ts:3:1 › login\nError: expected visible\n 1 failed" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/poetry-install.json b/open-sse/services/compression/engines/rtk/filters/poetry-install.json new file mode 100644 index 0000000000..f40169365e --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/poetry-install.json @@ -0,0 +1,35 @@ +{ + "id": "poetry-install", + "label": "Poetry Install", + "description": "Strip Poetry package operation noise.", + "category": "package", + "priority": 72, + "match": { + "outputTypes": [], + "commands": ["^poetry\\s+(?:install|update)\\b"], + "patterns": ["Package operations:", "Installing dependencies"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^ - Installing ", "^ - Updating ", "^Installing dependencies", "^\\s*$"], + "includePatterns": ["Package operations:", "Writing lock file", "Error", "failed"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 100, + "headLines": 25, + "tailLines": 35, + "onEmpty": "poetry: ok" + }, + "preserve": { + "errorPatterns": ["Error", "failed"], + "summaryPatterns": ["Package operations:", "Writing lock file"] + }, + "tests": [ + { + "name": "inline sample", + "command": "poetry (?:install|update)", + "input": "Installing dependencies\nPackage operations: 2 installs, 0 updates\n - Installing foo (1.0)\nWriting lock file\n", + "expected": "Package operations: 2 installs, 0 updates\nWriting lock file" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/prettier.json b/open-sse/services/compression/engines/rtk/filters/prettier.json new file mode 100644 index 0000000000..0a1c894fc6 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/prettier.json @@ -0,0 +1,40 @@ +{ + "id": "prettier", + "label": "Prettier", + "description": "Keep files that would be changed and final summary.", + "category": "build", + "priority": 79, + "match": { + "outputTypes": [], + "commands": ["^prettier\\b", "^npx\\s+prettier\\b"], + "patterns": ["\\[warn\\]", "All matched files use Prettier"] + }, + "rules": { + "stripAnsi": true, + "matchOutput": [ + { + "pattern": "All matched files use Prettier", + "message": "prettier: ok" + } + ], + "dropPatterns": ["^\\s*$"], + "includePatterns": ["\\[warn\\]", "Code style issues", "All matched files"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 80, + "headLines": 20, + "tailLines": 30 + }, + "preserve": { + "errorPatterns": ["\\[error\\]", "Code style issues"], + "summaryPatterns": ["All matched files"] + }, + "tests": [ + { + "name": "inline sample", + "command": "prettier", + "input": "[warn] src/a.ts\n[warn] Code style issues found in the above file. Forgot to run Prettier?\n", + "expected": "[warn] src/a.ts\n[warn] Code style issues found in the above file. Forgot to run Prettier?" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/ps.json b/open-sse/services/compression/engines/rtk/filters/ps.json new file mode 100644 index 0000000000..843866d70e --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/ps.json @@ -0,0 +1,34 @@ +{ + "id": "ps", + "label": "ps", + "description": "Keep process table headers and process rows, dropping empty noise.", + "category": "shell", + "priority": 68, + "match": { + "outputTypes": ["shell-ps"], + "commands": ["^ps\\b"], + "patterns": ["^(?:USER\\s+PID|\\s*PID\\s+)"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^\\s*$"], + "includePatterns": ["^(?:USER\\s+PID|\\s*PID\\s+)", "^\\S+\\s+\\d+\\s+", "^\\s*\\d+\\s+"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 120, + "headLines": 30, + "tailLines": 50 + }, + "preserve": { + "errorPatterns": ["defunct", "Permission denied"], + "summaryPatterns": ["^(?:USER\\s+PID|\\s*PID\\s+)"] + }, + "tests": [ + { + "name": "inline sample", + "command": "ps aux", + "input": "USER PID %CPU COMMAND\nroot 1 0.0 init\nnode 1234 5.0 node server.js\n", + "expected": "USER PID %CPU COMMAND\nroot 1 0.0 init\nnode 1234 5.0 node server.js" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/rsync.json b/open-sse/services/compression/engines/rtk/filters/rsync.json new file mode 100644 index 0000000000..36ecd135bc --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/rsync.json @@ -0,0 +1,33 @@ +{ + "id": "rsync", + "label": "rsync", + "description": "Drop rsync transfer chatter while preserving copied files and errors.", + "category": "cloud", + "priority": 73, + "match": { + "outputTypes": ["rsync"], + "commands": ["^rsync\\b"], + "patterns": ["^sending incremental file list", "^rsync error:"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^sending incremental file list", "^sent \\d", "^total size is", "^\\s*$"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 140, + "headLines": 35, + "tailLines": 50 + }, + "preserve": { + "errorPatterns": ["^rsync error:", "Permission denied", "No such file"], + "summaryPatterns": ["^sent \\d", "^total size is"] + }, + "tests": [ + { + "name": "inline sample", + "command": "rsync -av", + "input": "sending incremental file list\napp.js\nsent 120 bytes received 20 bytes\nrsync error: some files could not be transferred (code 23)\n", + "expected": "app.js\nrsync error: some files could not be transferred (code 23)" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/rubocop.json b/open-sse/services/compression/engines/rtk/filters/rubocop.json new file mode 100644 index 0000000000..da92cc4beb --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/rubocop.json @@ -0,0 +1,34 @@ +{ + "id": "rubocop", + "label": "RuboCop", + "description": "Keep RuboCop offenses and final summary.", + "category": "build", + "priority": 74, + "match": { + "outputTypes": ["rubocop"], + "commands": ["^rubocop\\b", "^bundle\\s+exec\\s+rubocop\\b"], + "patterns": ["^Inspecting \\d+ files", "^\\S+\\.rb:\\d+:\\d+: [A-Z]:"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^Inspecting \\d+ files", "^\\.+$", "^\\s*$"], + "includePatterns": ["^\\S+\\.rb:\\d+:\\d+: [A-Z]:", "^\\d+ files inspected"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 120, + "headLines": 30, + "tailLines": 45 + }, + "preserve": { + "errorPatterns": [" [EF]: ", "Offenses:"], + "summaryPatterns": ["^\\d+ files inspected"] + }, + "tests": [ + { + "name": "inline sample", + "command": "rubocop", + "input": "Inspecting 2 files\n.C\napp/models/user.rb:12:3: C: Style/IfUnlessModifier: Favor modifier if usage.\n2 files inspected, 1 offense detected\n", + "expected": "app/models/user.rb:12:3: C: Style/IfUnlessModifier: Favor modifier if usage.\n2 files inspected, 1 offense detected" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/ruff.json b/open-sse/services/compression/engines/rtk/filters/ruff.json new file mode 100644 index 0000000000..0e3d80d6ee --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/ruff.json @@ -0,0 +1,40 @@ +{ + "id": "ruff", + "label": "Ruff", + "description": "Keep Ruff diagnostics and summary.", + "category": "build", + "priority": 82, + "match": { + "outputTypes": [], + "commands": ["^ruff\\b", "^uv\\s+run\\s+ruff\\b"], + "patterns": ["Found \\d+ errors?", "All checks passed"] + }, + "rules": { + "stripAnsi": true, + "matchOutput": [ + { + "pattern": "All checks passed", + "message": "ruff: ok" + } + ], + "dropPatterns": ["^\\s*$"], + "includePatterns": ["^[A-Z]\\d{3}", "Found \\d+", "error:", "warning:", "-->"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 120, + "headLines": 30, + "tailLines": 40 + }, + "preserve": { + "errorPatterns": ["^[A-Z]\\d{3}", "error:"], + "summaryPatterns": ["Found \\d+", "All checks passed"] + }, + "tests": [ + { + "name": "inline sample", + "command": "ruff", + "input": "F401 app.py:1:1 unused import\nFound 1 error.\n", + "expected": "F401 app.py:1:1 unused import\nFound 1 error." + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/shell-find.json b/open-sse/services/compression/engines/rtk/filters/shell-find.json new file mode 100644 index 0000000000..e95ce818d9 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/shell-find.json @@ -0,0 +1,33 @@ +{ + "id": "shell-find", + "label": "find", + "description": "Limit large find output while preserving head and tail.", + "category": "shell", + "priority": 80, + "match": { + "outputTypes": ["shell-find"], + "commands": ["^find\\b"], + "patterns": ["^(?:\\./|/|[A-Za-z0-9_.-]+/)"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^\\s*$"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 120, + "headLines": 40, + "tailLines": 40 + }, + "preserve": { + "errorPatterns": ["Permission denied", "No such file"], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "command": "find", + "input": "./src/a.ts\n./src/b.ts\n./node_modules/pkg/index.js\n", + "expected": "./src/a.ts\n./src/b.ts\n./node_modules/pkg/index.js" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/shell-grep.json b/open-sse/services/compression/engines/rtk/filters/shell-grep.json new file mode 100644 index 0000000000..6429ff82f9 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/shell-grep.json @@ -0,0 +1,37 @@ +{ + "id": "shell-grep", + "label": "grep/rg", + "description": "Deduplicate grep output while preserving matches.", + "category": "shell", + "priority": 82, + "match": { + "outputTypes": ["shell-grep"], + "commands": ["^(?:grep|rg|ag)\\b"], + "patterns": [ + "^[\\w./-]+\\.(?:ts|tsx|js|jsx|py|go|rs|java|rb|md|json|ya?ml|txt):\\d*:", + "^[\\w./-]+/[\\w./-]+:\\d*:" + ] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^\\s*$"], + "includePatterns": ["^[\\w./-]+(?::\\d+)?:"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 160, + "headLines": 40, + "tailLines": 60 + }, + "preserve": { + "errorPatterns": ["No such file", "Permission denied"], + "summaryPatterns": ["^[^:\\n]+(?::\\d+)?:"] + }, + "tests": [ + { + "name": "inline sample", + "command": "(?:grep|rg|ag)", + "input": "src/a.ts:1:match\nsrc/a.ts:1:match\nsrc/b.ts:2:other\n", + "expected": "src/a.ts:1:match\nsrc/a.ts:1:match\nsrc/b.ts:2:other" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/shell-ls.json b/open-sse/services/compression/engines/rtk/filters/shell-ls.json new file mode 100644 index 0000000000..d8bb51cf8e --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/shell-ls.json @@ -0,0 +1,33 @@ +{ + "id": "shell-ls", + "label": "Shell Listing", + "description": "Keep directory listing boundaries without flooding the prompt.", + "category": "shell", + "priority": 50, + "match": { + "outputTypes": ["shell-ls"], + "commands": ["^(?:ls(?:\\s+-[A-Za-z]+)?|find)\\b"], + "patterns": ["^total \\d+", "^\\S+\\s+\\S+\\s+\\d+\\s+\\w+\\s+\\d{1,2}\\s+"] + }, + "rules": { + "includePatterns": [], + "dropPatterns": ["^\\s*$"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 100, + "headLines": 40, + "tailLines": 30 + }, + "preserve": { + "errorPatterns": ["^total \\d+"], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "command": "(?:ls(?: -[A-Za-z]+)?|find)", + "input": "total 8\ndrwxr-xr-x 2 user user 4096 May 1 src\n-rw-r--r-- 1 user user 10 May 1 package.json\n", + "expected": "total 8\ndrwxr-xr-x 2 user user 4096 May 1 src\n-rw-r--r-- 1 user user 10 May 1 package.json" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/ssh.json b/open-sse/services/compression/engines/rtk/filters/ssh.json new file mode 100644 index 0000000000..2b67759776 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/ssh.json @@ -0,0 +1,43 @@ +{ + "id": "ssh", + "label": "ssh", + "description": "Compress SSH connection logs while preserving access and host-key failures.", + "category": "cloud", + "priority": 72, + "match": { + "outputTypes": ["ssh"], + "commands": ["^ssh\\b"], + "patterns": [ + "Permission denied \\(", + "Host key verification failed", + "Connection (?:closed|timed out)" + ] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^debug\\d:", "^\\s*$"], + "includePatterns": [ + "Permission denied", + "Host key verification failed", + "Connection (?:closed|timed out)", + "^Welcome " + ], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 100, + "headLines": 25, + "tailLines": 40 + }, + "preserve": { + "errorPatterns": ["Permission denied", "Host key verification failed", "Connection timed out"], + "summaryPatterns": ["^Welcome "] + }, + "tests": [ + { + "name": "inline sample", + "command": "ssh host", + "input": "debug1: Connecting to host\nWelcome to Ubuntu\nPermission denied (publickey).\n", + "expected": "Welcome to Ubuntu\nPermission denied (publickey)." + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/systemctl-status.json b/open-sse/services/compression/engines/rtk/filters/systemctl-status.json new file mode 100644 index 0000000000..f75b502bc1 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/systemctl-status.json @@ -0,0 +1,43 @@ +{ + "id": "systemctl-status", + "label": "systemctl status", + "description": "Preserve unit status, active state, and journal errors.", + "category": "infra", + "priority": 77, + "match": { + "outputTypes": [], + "commands": ["^systemctl\\s+status\\b"], + "patterns": ["Loaded:", "Active:", "Main PID:"] + }, + "rules": { + "stripAnsi": true, + "includePatterns": [ + "Loaded:", + "Active:", + "Main PID:", + "CGroup:", + "error", + "failed", + "Started", + "Stopped" + ], + "dropPatterns": ["^\\s*$"], + "collapsePatterns": ["^\\s+Docs:"], + "deduplicate": true, + "maxLines": 100, + "headLines": 30, + "tailLines": 40 + }, + "preserve": { + "errorPatterns": ["failed", "error"], + "summaryPatterns": ["Active:", "Loaded:"] + }, + "tests": [ + { + "name": "inline sample", + "command": "systemctl status", + "input": "Loaded: loaded (/etc/systemd/system/app.service)\nActive: failed (Result: exit-code)\nMain PID: 123\nMay 1 app[123]: error boot failed\n", + "expected": "Loaded: loaded (/etc/systemd/system/app.service)\nActive: failed (Result: exit-code)\nMain PID: 123\nMay 1 app[123]: error boot failed" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/terraform-plan.json b/open-sse/services/compression/engines/rtk/filters/terraform-plan.json new file mode 100644 index 0000000000..3f9d417253 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/terraform-plan.json @@ -0,0 +1,34 @@ +{ + "id": "terraform-plan", + "label": "Terraform Plan", + "description": "Keep plan actions and summary while stripping refresh noise.", + "category": "infra", + "priority": 88, + "match": { + "outputTypes": [], + "commands": ["^terraform\\s+plan\\b"], + "patterns": ["Terraform will perform the following actions", "Plan: \\d+ to add"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["Refreshing state", "Acquiring state lock", "Releasing state lock", "^\\s*$"], + "includePatterns": ["Terraform will perform", "^[ ]*[#~+\\-/]", "Plan: \\d+", "Error:"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 160, + "headLines": 35, + "tailLines": 50 + }, + "preserve": { + "errorPatterns": ["Error:", "failed"], + "summaryPatterns": ["Plan: \\d+"] + }, + "tests": [ + { + "name": "inline sample", + "command": "terraform plan", + "input": "Refreshing state... [id=a]\nTerraform will perform the following actions:\n # aws_instance.web will be created\nPlan: 1 to add, 0 to change, 0 to destroy.\n", + "expected": "Terraform will perform the following actions:\n # aws_instance.web will be created\nPlan: 1 to add, 0 to change, 0 to destroy." + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/test-cargo.json b/open-sse/services/compression/engines/rtk/filters/test-cargo.json new file mode 100644 index 0000000000..e7aecaca5c --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/test-cargo.json @@ -0,0 +1,42 @@ +{ + "id": "test-cargo", + "label": "Cargo Test", + "description": "Keep Cargo test failures and final summary.", + "category": "test", + "priority": 90, + "match": { + "outputTypes": ["test-cargo"], + "commands": ["^cargo\\s+(?:test|nextest)\\b"], + "patterns": ["^running \\d+ tests?", "test result:"] + }, + "rules": { + "stripAnsi": true, + "includePatterns": [ + "^running \\d+ tests?", + "^test .* \\.\\.\\. FAILED", + "^failures:", + "^---- ", + "^thread ", + "^test result:", + "^error:" + ], + "dropPatterns": ["^test .* \\.\\.\\. ok$", "^\\s*$"], + "collapsePatterns": ["^\\s+at "], + "deduplicate": true, + "maxLines": 180, + "headLines": 40, + "tailLines": 70 + }, + "preserve": { + "errorPatterns": ["FAILED", "^error:", "^thread "], + "summaryPatterns": ["test result:"] + }, + "tests": [ + { + "name": "inline sample", + "command": "cargo (?:test|nextest)", + "input": "running 2 tests\ntest a ... ok\ntest b ... FAILED\nfailures:\n---- b stdout ----\nthread b panicked at boom\ntest result: FAILED. 1 passed; 1 failed\n", + "expected": "running 2 tests\ntest b ... FAILED\nfailures:\n---- b stdout ----\nthread b panicked at boom\ntest result: FAILED. 1 passed; 1 failed" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/test-go.json b/open-sse/services/compression/engines/rtk/filters/test-go.json new file mode 100644 index 0000000000..5e4f05e50d --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/test-go.json @@ -0,0 +1,41 @@ +{ + "id": "test-go", + "label": "Go Test", + "description": "Keep failing Go tests and package summaries.", + "category": "test", + "priority": 90, + "match": { + "outputTypes": ["test-go"], + "commands": ["^go\\s+test\\b"], + "patterns": ["^--- FAIL:", "^(?:ok|FAIL)\\s+"] + }, + "rules": { + "stripAnsi": true, + "includePatterns": [ + "^--- FAIL:", + "^FAIL", + "^ok\\s+", + "^panic:", + "^\\s+.*_test\\.go:\\d+", + "^\\s*Error" + ], + "dropPatterns": ["^\\s*$"], + "collapsePatterns": ["^=== RUN"], + "deduplicate": true, + "maxLines": 160, + "headLines": 35, + "tailLines": 60 + }, + "preserve": { + "errorPatterns": ["^--- FAIL:", "^panic:", "^FAIL"], + "summaryPatterns": ["^ok\\s+", "^FAIL\\s+"] + }, + "tests": [ + { + "name": "inline sample", + "command": "go test", + "input": "=== RUN TestA\n--- FAIL: TestA (0.00s)\n a_test.go:1: boom\nFAIL\t./pkg\t0.1s\n", + "expected": "--- FAIL: TestA (0.00s)\n a_test.go:1: boom\nFAIL\t./pkg\t0.1s" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/test-jest.json b/open-sse/services/compression/engines/rtk/filters/test-jest.json new file mode 100644 index 0000000000..62f4c0a5d0 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/test-jest.json @@ -0,0 +1,43 @@ +{ + "id": "test-jest", + "label": "Jest", + "description": "Keep Jest failures and summary while removing passing noise.", + "category": "test", + "priority": 90, + "match": { + "outputTypes": ["test-jest"], + "commands": ["^(?:jest|npm\\s+(?:run\\s+)?test)\\b"], + "patterns": ["Test Suites:\\s+\\d+", "Tests:\\s+\\d+", "^PASS\\s+", "^FAIL\\s+"] + }, + "rules": { + "includePatterns": [ + "^FAIL\\s+", + "^\\s*●\\s+", + "Expected:", + "Received:", + "Test Suites:", + "Tests:", + "Snapshots:", + "Time:", + "\\s+at\\s+" + ], + "dropPatterns": ["^PASS\\s+", "^\\s*✓\\s+"], + "collapsePatterns": ["^\\s*at "], + "deduplicate": true, + "maxLines": 140, + "headLines": 24, + "tailLines": 50 + }, + "preserve": { + "errorPatterns": ["FAIL", "Expected:", "Received:", "Test Suites:"], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "command": "(?:jest|npm (?:run )?test)", + "input": "PASS src/a.test.ts\nFAIL src/b.test.ts\nError: boom\nTest Suites: 1 failed, 1 passed\nTests: 1 failed, 1 passed\n", + "expected": "FAIL src/b.test.ts\nTest Suites: 1 failed, 1 passed\nTests: 1 failed, 1 passed" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/test-pytest.json b/open-sse/services/compression/engines/rtk/filters/test-pytest.json new file mode 100644 index 0000000000..4e62bcc34c --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/test-pytest.json @@ -0,0 +1,41 @@ +{ + "id": "test-pytest", + "label": "Pytest", + "description": "Keep pytest failures, tracebacks and terminal summary.", + "category": "test", + "priority": 90, + "match": { + "outputTypes": ["test-pytest"], + "commands": ["^(?:pytest|python\\s+-m\\s+pytest)\\b"], + "patterns": ["=+\\s+(?:\\d+\\s+)?(?:passed|failed|errors?)", "^E\\s+", "^FAILED "] + }, + "rules": { + "includePatterns": [ + "^FAILED ", + "^ERROR ", + "^E\\s+", + "Traceback \\(most recent call last\\):", + "=+ short test summary info =+", + "=+ .*failed", + "AssertionError" + ], + "dropPatterns": ["^\\.", "^\\s*$"], + "collapsePatterns": ["^E\\s+"], + "deduplicate": true, + "maxLines": 160, + "headLines": 28, + "tailLines": 60 + }, + "preserve": { + "errorPatterns": ["FAILED", "ERROR", "Traceback", "AssertionError"], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "command": "(?:pytest|python -m pytest)", + "input": "FAILED tests/test_a.py::test_a - AssertionError\nE assert 1 == 2\n================ 1 failed, 2 passed ================\n", + "expected": "FAILED tests/test_a.py::test_a - AssertionError\nE assert 1 == 2\n================ 1 failed, 2 passed ================" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/test-vitest.json b/open-sse/services/compression/engines/rtk/filters/test-vitest.json new file mode 100644 index 0000000000..bb8a7480de --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/test-vitest.json @@ -0,0 +1,43 @@ +{ + "id": "test-vitest", + "label": "Vitest", + "description": "Keep failing suites, failed tests, stack headers, and final summary.", + "category": "test", + "priority": 92, + "match": { + "outputTypes": ["test-vitest"], + "commands": ["^(?:vitest|npm\\s+(?:run\\s+)?test:vitest)\\b"], + "patterns": ["\\bvitest\\b", "^ ✓ ", "^ ❯ ", "Test Files\\s+\\d+\\s+(?:passed|failed)"] + }, + "rules": { + "includePatterns": [ + "^\\s*FAIL\\s+", + "^\\s*❯\\s+", + "^\\s*×\\s+", + "Test Files\\s+", + "Tests\\s+", + "Duration\\s+", + "Error:", + "AssertionError", + "\\s+at\\s+" + ], + "dropPatterns": ["^\\s*✓\\s+", "^\\s*stdout \\|", "^\\s*stderr \\|"], + "collapsePatterns": ["^\\s*at "], + "deduplicate": true, + "maxLines": 140, + "headLines": 24, + "tailLines": 50 + }, + "preserve": { + "errorPatterns": ["FAIL", "Error:", "AssertionError", "Test Files"], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "command": "(?:vitest|npm (?:run )?test:vitest)", + "input": " ✓ src/a.test.ts (1)\n ❯ src/b.test.ts (1)\n FAIL src/b.test.ts > fails\nError: boom\nTest Files 1 failed | 1 passed\nTests 1 failed | 1 passed\n", + "expected": " ❯ src/b.test.ts (1)\n FAIL src/b.test.ts > fails\nError: boom\nTest Files 1 failed | 1 passed\nTests 1 failed | 1 passed" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/tofu-plan.json b/open-sse/services/compression/engines/rtk/filters/tofu-plan.json new file mode 100644 index 0000000000..d6b8c69ee5 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/tofu-plan.json @@ -0,0 +1,34 @@ +{ + "id": "tofu-plan", + "label": "OpenTofu Plan", + "description": "Keep OpenTofu plan actions and summary while stripping refresh noise.", + "category": "infra", + "priority": 87, + "match": { + "outputTypes": [], + "commands": ["^tofu\\s+plan\\b"], + "patterns": ["OpenTofu will perform the following actions", "Plan: \\d+ to add"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["Refreshing state", "Acquiring state lock", "Releasing state lock", "^\\s*$"], + "includePatterns": ["OpenTofu will perform", "^[ ]*[#~+\\-/]", "Plan: \\d+", "Error:"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 160, + "headLines": 35, + "tailLines": 50 + }, + "preserve": { + "errorPatterns": ["Error:", "failed"], + "summaryPatterns": ["Plan: \\d+"] + }, + "tests": [ + { + "name": "inline sample", + "command": "tofu plan", + "input": "Refreshing state... [id=a]\nOpenTofu will perform the following actions:\n # tofu_resource.web will be created\nPlan: 1 to add, 0 to change, 0 to destroy.\n", + "expected": "OpenTofu will perform the following actions:\n # tofu_resource.web will be created\nPlan: 1 to add, 0 to change, 0 to destroy." + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/turbo.json b/open-sse/services/compression/engines/rtk/filters/turbo.json new file mode 100644 index 0000000000..0727717b65 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/turbo.json @@ -0,0 +1,34 @@ +{ + "id": "turbo", + "label": "Turborepo", + "description": "Compact turbo task cache and failure output.", + "category": "build", + "priority": 76, + "match": { + "outputTypes": [], + "commands": ["^turbo\\b", "^npx\\s+turbo\\b"], + "patterns": ["Tasks:", "Remote caching"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["cache hit", "cache miss", "^\\s*$"], + "includePatterns": ["failed", "error", "Tasks:", "Cached:", "Time:", "successful"], + "collapsePatterns": ["^• Packages in scope"], + "deduplicate": true, + "maxLines": 120, + "headLines": 25, + "tailLines": 50 + }, + "preserve": { + "errorPatterns": ["failed", "error"], + "summaryPatterns": ["Tasks:", "Cached:", "Time:"] + }, + "tests": [ + { + "name": "inline sample", + "command": "turbo", + "input": "cache hit app#build\napp#test: failed\nTasks: 1 successful, 1 failed\nTime: 2s\n", + "expected": "app#test: failed\nTasks: 1 successful, 1 failed\nTime: 2s" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/uv-sync.json b/open-sse/services/compression/engines/rtk/filters/uv-sync.json new file mode 100644 index 0000000000..ecac0a0321 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/uv-sync.json @@ -0,0 +1,35 @@ +{ + "id": "uv-sync", + "label": "uv sync", + "description": "Compact uv sync/install output.", + "category": "package", + "priority": 72, + "match": { + "outputTypes": [], + "commands": ["^uv\\s+sync\\b", "^uv\\s+pip\\s+install\\b"], + "patterns": ["Resolved \\d+ packages?", "Prepared \\d+ packages?"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^Resolved ", "^Prepared ", "^Installed ", "^ \\+", "^\\s*$"], + "includePatterns": ["error", "failed", "Installed", "Uninstalled", "Resolved"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 100, + "headLines": 25, + "tailLines": 35, + "onEmpty": "uv: ok" + }, + "preserve": { + "errorPatterns": ["error", "failed"], + "summaryPatterns": ["Installed", "Resolved"] + }, + "tests": [ + { + "name": "inline sample", + "command": "uv sync", + "input": "Resolved 10 packages in 1ms\nInstalled 10 packages in 2ms\n", + "expected": "uv: ok" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/wget.json b/open-sse/services/compression/engines/rtk/filters/wget.json new file mode 100644 index 0000000000..b5de52bb99 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/wget.json @@ -0,0 +1,34 @@ +{ + "id": "wget", + "label": "wget", + "description": "Remove wget progress output and keep final errors or saved-file summaries.", + "category": "cloud", + "priority": 70, + "match": { + "outputTypes": ["wget"], + "commands": ["^wget\\b"], + "patterns": ["^--\\d{4}-\\d{2}-\\d{2}", "^ERROR \\d{3}:"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^\\s*\\d+K", "^Saving to:", "^\\s*$"], + "includePatterns": ["^ERROR \\d{3}:", "saved \\[\\d", "^HTTP request sent"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 100, + "headLines": 25, + "tailLines": 40 + }, + "preserve": { + "errorPatterns": ["^ERROR \\d{3}:", "failed", "unable to resolve"], + "summaryPatterns": ["saved \\[\\d", "^HTTP request sent"] + }, + "tests": [ + { + "name": "inline sample", + "command": "wget", + "input": "--2026-05-02-- https://example.test/file\nSaving to: 'file'\nHTTP request sent, awaiting response... 404 Not Found\nERROR 404: Not Found.\n", + "expected": "HTTP request sent, awaiting response... 404 Not Found\nERROR 404: Not Found." + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/index.ts b/open-sse/services/compression/engines/rtk/index.ts new file mode 100644 index 0000000000..70d6efc529 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/index.ts @@ -0,0 +1,436 @@ +import { createCompressionStats, estimateCompressionTokens } from "../../stats.ts"; +import { DEFAULT_RTK_CONFIG, type CompressionResult, type RtkConfig } from "../../types.ts"; +import type { CompressionEngine, EngineConfigField, EngineValidationResult } from "../types.ts"; +import { detectCommandType } from "./commandDetector.ts"; +import { deduplicateRepeatedLines } from "./deduplicator.ts"; +import { matchRtkFilter } from "./filterLoader.ts"; +import { applyLineFilter } from "./lineFilter.ts"; +import { smartTruncate } from "./smartTruncate.ts"; +import { normalizeCodeLanguage, stripCode } from "./codeStripper.ts"; +import { maybePersistRtkRawOutput, type RtkRawOutputPointer } from "./rawOutput.ts"; +import { isTextBlock } from "../../messageContent.ts"; + +type Message = { + role: string; + content?: string | Array<{ type?: string; text?: string; [key: string]: unknown }>; + [key: string]: unknown; +}; + +const RTK_SCHEMA: EngineConfigField[] = [ + { + key: "intensity", + type: "select", + label: "Intensity", + defaultValue: DEFAULT_RTK_CONFIG.intensity, + options: [ + { value: "minimal", label: "minimal" }, + { value: "standard", label: "standard" }, + { value: "aggressive", label: "aggressive" }, + ], + }, + { + key: "applyToToolResults", + type: "boolean", + label: "Apply to tool results", + defaultValue: DEFAULT_RTK_CONFIG.applyToToolResults, + }, + { + key: "applyToAssistantMessages", + type: "boolean", + label: "Apply to assistant messages", + defaultValue: DEFAULT_RTK_CONFIG.applyToAssistantMessages, + }, + { + key: "applyToCodeBlocks", + type: "boolean", + label: "Apply to code blocks", + defaultValue: DEFAULT_RTK_CONFIG.applyToCodeBlocks, + }, + { + key: "maxLinesPerResult", + type: "number", + label: "Max lines per result", + defaultValue: DEFAULT_RTK_CONFIG.maxLinesPerResult, + min: 0, + max: 5000, + }, + { + key: "maxCharsPerResult", + type: "number", + label: "Max chars per result", + defaultValue: DEFAULT_RTK_CONFIG.maxCharsPerResult, + min: 0, + max: 500000, + }, + { + key: "deduplicateThreshold", + type: "number", + label: "Deduplicate threshold", + defaultValue: DEFAULT_RTK_CONFIG.deduplicateThreshold, + min: 2, + max: 100, + }, + { + key: "rawOutputRetention", + type: "select", + label: "Raw output retention", + defaultValue: DEFAULT_RTK_CONFIG.rawOutputRetention, + options: [ + { value: "never", label: "never" }, + { value: "failures", label: "failures" }, + { value: "always", label: "always" }, + ], + }, +]; + +function validateRtkEngineConfig(config: Record): EngineValidationResult { + const errors: string[] = []; + if ( + config.intensity !== undefined && + config.intensity !== "minimal" && + config.intensity !== "standard" && + config.intensity !== "aggressive" + ) { + errors.push("intensity must be minimal, standard, or aggressive"); + } + for (const key of [ + "enabled", + "applyToToolResults", + "applyToAssistantMessages", + "applyToCodeBlocks", + ]) { + if (config[key] !== undefined && typeof config[key] !== "boolean") { + errors.push(`${key} must be a boolean`); + } + } + for (const key of ["maxLinesPerResult", "maxCharsPerResult", "deduplicateThreshold"]) { + if (config[key] !== undefined && (typeof config[key] !== "number" || config[key] < 0)) { + errors.push(`${key} must be a non-negative number`); + } + } + if (config.enabledFilters !== undefined && !Array.isArray(config.enabledFilters)) { + errors.push("enabledFilters must be an array"); + } + if (config.disabledFilters !== undefined && !Array.isArray(config.disabledFilters)) { + errors.push("disabledFilters must be an array"); + } + if ( + config.rawOutputRetention !== undefined && + config.rawOutputRetention !== "never" && + config.rawOutputRetention !== "failures" && + config.rawOutputRetention !== "always" + ) { + errors.push("rawOutputRetention must be never, failures, or always"); + } + return { valid: errors.length === 0, errors }; +} + +export interface RtkProcessResult { + text: string; + compressed: boolean; + originalTokens: number; + compressedTokens: number; + techniquesUsed: string[]; + rulesApplied: string[]; + rawOutputPointers?: RtkRawOutputPointer[]; +} + +function mergeRtkConfig(base?: Partial, override?: Record): RtkConfig { + const merged = { ...DEFAULT_RTK_CONFIG, ...(base ?? {}), ...(override ?? {}) }; + return { + ...merged, + intensity: + merged.intensity === "minimal" || + merged.intensity === "standard" || + merged.intensity === "aggressive" + ? merged.intensity + : DEFAULT_RTK_CONFIG.intensity, + enabledFilters: Array.isArray(merged.enabledFilters) + ? merged.enabledFilters.filter((id): id is string => typeof id === "string") + : [], + disabledFilters: Array.isArray(merged.disabledFilters) + ? merged.disabledFilters.filter((id): id is string => typeof id === "string") + : [], + maxLinesPerResult: + typeof merged.maxLinesPerResult === "number" && Number.isFinite(merged.maxLinesPerResult) + ? Math.max(0, Math.floor(merged.maxLinesPerResult)) + : DEFAULT_RTK_CONFIG.maxLinesPerResult, + maxCharsPerResult: + typeof merged.maxCharsPerResult === "number" && Number.isFinite(merged.maxCharsPerResult) + ? Math.max(0, Math.floor(merged.maxCharsPerResult)) + : DEFAULT_RTK_CONFIG.maxCharsPerResult, + deduplicateThreshold: + typeof merged.deduplicateThreshold === "number" && + Number.isFinite(merged.deduplicateThreshold) + ? Math.max(2, Math.floor(merged.deduplicateThreshold)) + : DEFAULT_RTK_CONFIG.deduplicateThreshold, + customFiltersEnabled: + typeof merged.customFiltersEnabled === "boolean" + ? merged.customFiltersEnabled + : DEFAULT_RTK_CONFIG.customFiltersEnabled, + trustProjectFilters: + typeof merged.trustProjectFilters === "boolean" + ? merged.trustProjectFilters + : DEFAULT_RTK_CONFIG.trustProjectFilters, + rawOutputRetention: + merged.rawOutputRetention === "never" || + merged.rawOutputRetention === "failures" || + merged.rawOutputRetention === "always" + ? merged.rawOutputRetention + : DEFAULT_RTK_CONFIG.rawOutputRetention, + rawOutputMaxBytes: + typeof merged.rawOutputMaxBytes === "number" && Number.isFinite(merged.rawOutputMaxBytes) + ? Math.max(1024, Math.floor(merged.rawOutputMaxBytes)) + : DEFAULT_RTK_CONFIG.rawOutputMaxBytes, + }; +} + +function shouldCompressMessage(message: Message, config: RtkConfig): boolean { + if (message.role === "tool") return config.applyToToolResults || config.applyToCodeBlocks; + if (message.role === "assistant") + return config.applyToAssistantMessages || config.applyToCodeBlocks; + return false; +} + +export function processRtkText( + text: string, + options: { command?: string | null; config?: Partial } = {} +): RtkProcessResult { + const config = mergeRtkConfig(options.config); + const originalTokens = estimateCompressionTokens(text); + const techniquesUsed: string[] = []; + const rulesApplied: string[] = []; + const rawOutputPointers: RtkRawOutputPointer[] = []; + let result = text; + + const detection = detectCommandType(text, options.command); + const filter = matchRtkFilter(text, detection.command, { + customFiltersEnabled: config.customFiltersEnabled, + trustProjectFilters: config.trustProjectFilters, + }); + if (filter && !config.disabledFilters.includes(filter.id)) { + if (config.enabledFilters.length === 0 || config.enabledFilters.includes(filter.id)) { + const filtered = applyLineFilter(result, { + ...filter, + maxLines: filter.maxLines || config.maxLinesPerResult, + }); + result = filtered.text; + if (filtered.appliedRules.length > 0) { + techniquesUsed.push("rtk-filter"); + rulesApplied.push(...filtered.appliedRules); + } + } + } + + if (config.applyToCodeBlocks) { + let strippedCodeBlocks = 0; + result = result.replace( + /```([A-Za-z0-9_+.-]*)\r?\n([\s\S]*?)```/g, + (match, languageHint: string, code: string) => { + const stripped = stripCode(code, normalizeCodeLanguage(languageHint)); + if (stripped.strippedLines <= 0 && stripped.text === code.trim()) return match; + strippedCodeBlocks++; + const fenceLanguage = languageHint?.trim() || stripped.language; + return `\`\`\`${fenceLanguage}\n${stripped.text}\n\`\`\``; + } + ); + if (strippedCodeBlocks > 0) { + techniquesUsed.push("rtk-code-strip"); + rulesApplied.push("rtk:code-strip"); + } + } + + if (config.intensity !== "minimal") { + const deduped = deduplicateRepeatedLines(result, { threshold: config.deduplicateThreshold }); + if (deduped.collapsed > 0) { + result = deduped.text; + techniquesUsed.push("rtk-dedup"); + rulesApplied.push("rtk:dedup"); + } + } + + const truncated = smartTruncate(result, { + maxLines: config.maxLinesPerResult, + maxChars: config.maxCharsPerResult, + preserveHead: config.intensity === "aggressive" ? 16 : 24, + preserveTail: config.intensity === "aggressive" ? 16 : 24, + priorityPatterns: [/error|failed|exception|traceback|TS\d{4}|FAIL|✖/i], + }); + if (truncated.truncated) { + result = truncated.text; + techniquesUsed.push("rtk-truncate"); + rulesApplied.push("rtk:truncate"); + } + + const compressedTokens = estimateCompressionTokens(result); + if (compressedTokens < originalTokens) { + const pointer = maybePersistRtkRawOutput(text, { + retention: config.rawOutputRetention, + command: detection.command, + maxBytes: config.rawOutputMaxBytes, + }); + if (pointer) { + rawOutputPointers.push(pointer); + techniquesUsed.push("rtk-raw-output-retention"); + rulesApplied.push("rtk:raw-output-retention"); + } + } + return { + text: result, + compressed: compressedTokens < originalTokens, + originalTokens, + compressedTokens, + techniquesUsed: [...new Set(techniquesUsed)], + rulesApplied: [...new Set(rulesApplied)], + ...(rawOutputPointers.length > 0 ? { rawOutputPointers } : {}), + }; +} + +function processRtkContent( + content: Message["content"], + config: RtkConfig +): { + content: Message["content"]; + compressed: boolean; + techniquesUsed: string[]; + rulesApplied: string[]; + rawOutputPointers: RtkRawOutputPointer[]; +} { + const techniquesUsed: string[] = []; + const rulesApplied: string[] = []; + const rawOutputPointers: RtkRawOutputPointer[] = []; + + const collect = (processed: RtkProcessResult) => { + techniquesUsed.push(...processed.techniquesUsed); + rulesApplied.push(...processed.rulesApplied); + if (processed.rawOutputPointers) rawOutputPointers.push(...processed.rawOutputPointers); + }; + + if (typeof content === "string") { + if (!content) { + return { content, compressed: false, techniquesUsed, rulesApplied, rawOutputPointers }; + } + const processed = processRtkText(content, { config }); + collect(processed); + return { + content: processed.compressed ? processed.text : content, + compressed: processed.compressed, + techniquesUsed, + rulesApplied, + rawOutputPointers, + }; + } + + if (!Array.isArray(content)) { + return { content, compressed: false, techniquesUsed, rulesApplied, rawOutputPointers }; + } + + let compressed = false; + const nextContent = content.map((part) => { + if (!isTextBlock(part) || !part.text) return part; + const processed = processRtkText(part.text, { config }); + collect(processed); + if (!processed.compressed) return part; + compressed = true; + return { ...part, text: processed.text }; + }); + + return { + content: compressed ? nextContent : content, + compressed, + techniquesUsed, + rulesApplied, + rawOutputPointers, + }; +} + +export function applyRtkCompression( + body: Record, + options: { config?: Partial; stepConfig?: Record } = {} +): CompressionResult { + const start = performance.now(); + const config = mergeRtkConfig(options.config, options.stepConfig); + if (!config.enabled) return { body, compressed: false, stats: null }; + + const messages = body.messages as Message[] | undefined; + if (!Array.isArray(messages) || messages.length === 0) { + return { body, compressed: false, stats: null }; + } + + const allTechniques: string[] = []; + const allRules: string[] = []; + const rawOutputPointers: RtkRawOutputPointer[] = []; + const compressedMessages = messages.map((message) => { + if (!shouldCompressMessage(message, config)) return message; + const processed = processRtkContent(message.content, config); + allTechniques.push(...processed.techniquesUsed); + allRules.push(...processed.rulesApplied); + rawOutputPointers.push(...processed.rawOutputPointers); + if (!processed.compressed) return message; + return { + ...message, + content: processed.content, + }; + }); + + const compressedBody = { ...body, messages: compressedMessages }; + const stats = createCompressionStats( + body, + compressedBody, + "rtk", + [...new Set(allTechniques)], + allRules.length > 0 ? [...new Set(allRules)] : undefined, + Math.round((performance.now() - start) * 100) / 100 + ); + stats.engine = "rtk"; + if (rawOutputPointers.length > 0) { + stats.rtkRawOutputPointers = rawOutputPointers; + } + return { + body: compressedBody, + compressed: stats.compressedTokens < stats.originalTokens, + stats, + }; +} + +export const rtkEngine: CompressionEngine = { + id: "rtk", + name: "RTK", + description: "Command-aware tool output compression with declarative filters.", + icon: "filter_alt", + targets: ["tool_results", "code_blocks"], + stackable: true, + stackPriority: 10, + metadata: { + id: "rtk", + name: "RTK", + description: "Command-aware tool output compression with declarative filters.", + inputScope: "tool-results", + targetLatencyMs: 5, + supportsPreview: true, + stable: true, + }, + apply(body, options) { + return applyRtkCompression(body, { + config: options?.config?.rtkConfig, + stepConfig: options?.stepConfig, + }); + }, + compress(body, config) { + return this.apply(body, { stepConfig: config }); + }, + getConfigSchema() { + return RTK_SCHEMA; + }, + validateConfig(config) { + return validateRtkEngineConfig(config); + }, +}; + +export { + detectCommandFromText, + detectCommandOutput, + detectCommandType, +} from "./commandDetector.ts"; +export { runRtkFilterTests } from "./verify.ts"; +export { maybePersistRtkRawOutput, readRtkRawOutput, redactRtkRawOutput } from "./rawOutput.ts"; diff --git a/open-sse/services/compression/engines/rtk/lineFilter.ts b/open-sse/services/compression/engines/rtk/lineFilter.ts new file mode 100644 index 0000000000..589b618086 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/lineFilter.ts @@ -0,0 +1,155 @@ +import type { RtkFilterDefinition } from "./filterSchema.ts"; +import { smartTruncate } from "./smartTruncate.ts"; + +export interface LineFilterResult { + text: string; + strippedLines: number; + keptByRule: boolean; + appliedRules: string[]; +} + +function compilePatterns(patterns: string[]): RegExp[] { + return patterns.flatMap((pattern) => { + try { + return [new RegExp(pattern, "i")]; + } catch { + return []; + } + }); +} + +function compileGlobalPattern(pattern: string): RegExp | null { + try { + return new RegExp(pattern, "g"); + } catch { + return null; + } +} + +function compileBlobPattern(pattern: string): RegExp | null { + try { + return new RegExp(pattern, "im"); + } catch { + return null; + } +} + +function stripAnsi(text: string): string { + return text.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, ""); +} + +function normalizeStderrPrefix(line: string): string { + return line.replace(/^\s*(?:stderr|err)\s*(?:\||:)\s*/i, ""); +} + +function truncateUnicodeSafe(line: string, maxChars: number): string { + if (maxChars <= 0) return line; + const chars = Array.from(line); + if (chars.length <= maxChars) return line; + if (maxChars <= 3) return chars.slice(0, maxChars).join(""); + return `${chars.slice(0, maxChars - 3).join("")}...`; +} + +export function applyLineFilter(text: string, filter: RtkFilterDefinition): LineFilterResult { + const stripPatterns = compilePatterns(filter.stripPatterns); + const keepPatterns = compilePatterns(filter.keepPatterns); + const collapsePatterns = compilePatterns(filter.collapsePatterns); + const priorityPatterns = compilePatterns(filter.priorityPatterns); + const appliedRules: string[] = []; + + let lines = text.split(/\r?\n/); + const originalLineCount = lines.length; + + if (filter.stripAnsi) { + const stripped = lines.map(stripAnsi); + if (stripped.join("\n") !== lines.join("\n")) { + appliedRules.push(`${filter.id}:strip-ansi`); + } + lines = stripped; + } + + if (filter.filterStderr) { + const normalized = lines.map(normalizeStderrPrefix); + if (normalized.join("\n") !== lines.join("\n")) { + appliedRules.push(`${filter.id}:filter-stderr`); + } + lines = normalized; + } + + for (const rule of filter.replace) { + const pattern = compileGlobalPattern(rule.pattern); + if (!pattern) continue; + const replaced = lines.map((line) => line.replace(pattern, rule.replacement)); + if (replaced.join("\n") !== lines.join("\n")) { + appliedRules.push(`${filter.id}:replace`); + } + lines = replaced; + } + + if (filter.matchOutput.length > 0) { + const blob = lines.join("\n"); + for (const rule of filter.matchOutput) { + const pattern = compileBlobPattern(rule.pattern); + if (!pattern?.test(blob)) continue; + const unless = rule.unless ? compileBlobPattern(rule.unless) : null; + if (unless?.test(blob)) continue; + appliedRules.push(`${filter.id}:match-output`); + return { + text: rule.message, + strippedLines: Math.max(0, originalLineCount - 1), + keptByRule: false, + appliedRules, + }; + } + } + + if (stripPatterns.length > 0) { + lines = lines.filter((line) => !stripPatterns.some((pattern) => pattern.test(line))); + if (lines.length !== originalLineCount) appliedRules.push(`${filter.id}:strip`); + } + + if (keepPatterns.length > 0) { + const kept = lines.filter((line) => keepPatterns.some((pattern) => pattern.test(line))); + if (kept.length > 0) { + lines = kept; + appliedRules.push(`${filter.id}:keep`); + } + } + + if (collapsePatterns.length > 0) { + const seen = new Set(); + lines = lines.filter((line) => { + if (!collapsePatterns.some((pattern) => pattern.test(line))) return true; + const key = line.trim(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + appliedRules.push(`${filter.id}:collapse`); + } + + if (filter.truncateLineAt > 0) { + const truncatedLines = lines.map((line) => truncateUnicodeSafe(line, filter.truncateLineAt)); + if (truncatedLines.join("\n") !== lines.join("\n")) { + lines = truncatedLines; + appliedRules.push(`${filter.id}:truncate-line`); + } + } + + const truncated = smartTruncate(lines.join("\n"), { + maxLines: filter.maxLines, + preserveHead: filter.preserveHead, + preserveTail: filter.preserveTail, + priorityPatterns, + }); + if (truncated.truncated) appliedRules.push(`${filter.id}:truncate`); + const output = + truncated.text.trim().length === 0 && filter.onEmpty ? filter.onEmpty : truncated.text; + + return { + text: output, + strippedLines: Math.max(0, originalLineCount - output.split(/\r?\n/).length), + keptByRule: keepPatterns.length > 0, + appliedRules, + }; +} diff --git a/open-sse/services/compression/engines/rtk/rawOutput.ts b/open-sse/services/compression/engines/rtk/rawOutput.ts new file mode 100644 index 0000000000..7e40d6a9cb --- /dev/null +++ b/open-sse/services/compression/engines/rtk/rawOutput.ts @@ -0,0 +1,112 @@ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import crypto from "node:crypto"; + +export type RtkRawOutputRetention = "never" | "failures" | "always"; + +export interface RtkRawOutputPointer { + id: string; + path: string; + bytes: number; + sha256: string; + redacted: boolean; +} + +const SECRET_PATTERNS: Array<[RegExp, string]> = [ + [/\b(sk-[A-Za-z0-9_-]{16,})\b/g, "[REDACTED_OPENAI_KEY]"], + [/\b(xox[baprs]-[A-Za-z0-9-]{16,})\b/g, "[REDACTED_SLACK_TOKEN]"], + [/\b(AKIA[0-9A-Z]{16})\b/g, "[REDACTED_AWS_KEY]"], + [/((?:api[_-]?key|token|secret|password)\s*[:=]\s*)("[^"]+"|'[^']+'|[^\s]+)/gi, "$1[REDACTED]"], + [/(Authorization:\s*Bearer\s+)[A-Za-z0-9._~+/-]+=*/gi, "$1[REDACTED]"], +]; + +function dataDir(): string { + return process.env.DATA_DIR || path.join(os.homedir(), ".omniroute"); +} + +function safeId(seed: string): string { + return crypto.createHash("sha256").update(seed).digest("hex").slice(0, 24); +} + +function safeUtf8Slice(value: string, maxBytes: number): string { + if (maxBytes <= 0 || Buffer.byteLength(value, "utf8") <= maxBytes) return value; + let bytes = 0; + let output = ""; + for (const char of value) { + const len = Buffer.byteLength(char, "utf8"); + if (bytes + len > maxBytes) break; + output += char; + bytes += len; + } + return `${output}\n\n--- truncated at ${maxBytes} bytes ---`; +} + +export function redactRtkRawOutput(value: string): { text: string; redacted: boolean } { + let redacted = false; + let text = value; + for (const [pattern, replacement] of SECRET_PATTERNS) { + const next = text.replace(pattern, (...args: string[]) => { + redacted = true; + return typeof replacement === "string" + ? replacement.replace("$1", args[1] ?? "") + : replacement; + }); + text = next; + } + return { text, redacted }; +} + +export function isLikelyFailureOutput(value: string): boolean { + return /\b(error|failed|failure|exception|traceback|panic|fatal|critical|TS\d{4}|FAIL)\b/i.test( + value + ); +} + +export function maybePersistRtkRawOutput( + raw: string, + options: { + retention: RtkRawOutputRetention; + command?: string | null; + maxBytes?: number; + failure?: boolean; + } +): RtkRawOutputPointer | null { + if (options.retention === "never") return null; + const failure = options.failure ?? isLikelyFailureOutput(raw); + if (options.retention === "failures" && !failure) return null; + if (raw.trim().length === 0) return null; + + const maxBytes = Math.max(1024, Math.floor(options.maxBytes ?? 1_048_576)); + const redaction = redactRtkRawOutput(safeUtf8Slice(raw, maxBytes)); + const now = Date.now(); + const commandSlug = (options.command || "tool-output") + .replace(/[^A-Za-z0-9_-]+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 48); + const id = safeId(`${now}:${commandSlug}:${raw.length}:${redaction.text}`); + const dir = path.join(dataDir(), "rtk", "raw-output"); + const filePath = path.join(dir, `${now}-${commandSlug || "tool-output"}-${id}.log`); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, redaction.text); + + return { + id, + path: filePath, + bytes: Buffer.byteLength(redaction.text, "utf8"), + sha256: crypto.createHash("sha256").update(redaction.text).digest("hex"), + redacted: redaction.redacted, + }; +} + +export function readRtkRawOutput(pointerId: string): string | null { + const dir = path.join(dataDir(), "rtk", "raw-output"); + if (!fs.existsSync(dir)) return null; + const entry = fs + .readdirSync(dir) + .find((file) => file.endsWith(".log") && file.includes(pointerId)); + if (!entry) return null; + const fullPath = path.join(dir, entry); + if (!fullPath.startsWith(dir)) return null; + return fs.readFileSync(fullPath, "utf8"); +} diff --git a/open-sse/services/compression/engines/rtk/smartTruncate.ts b/open-sse/services/compression/engines/rtk/smartTruncate.ts new file mode 100644 index 0000000000..8af0e708c2 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/smartTruncate.ts @@ -0,0 +1,67 @@ +export interface SmartTruncateOptions { + maxLines?: number; + maxChars?: number; + preserveHead?: number; + preserveTail?: number; + priorityPatterns?: RegExp[]; +} + +export function smartTruncate( + text: string, + options: SmartTruncateOptions = {} +): { + text: string; + truncated: boolean; + droppedLines: number; +} { + const maxChars = Math.max(0, Math.floor(options.maxChars ?? 0)); + const maxLines = Math.max(0, Math.floor(options.maxLines ?? 0)); + const lines = text.split(/\r?\n/); + const overLineLimit = maxLines > 0 && lines.length > maxLines; + const overCharLimit = maxChars > 0 && text.length > maxChars; + if (!overLineLimit && !overCharLimit) { + return { text, truncated: false, droppedLines: 0 }; + } + + const preserveHead = Math.max(0, Math.floor(options.preserveHead ?? 20)); + const preserveTail = Math.max(0, Math.floor(options.preserveTail ?? 20)); + const priorityPatterns = options.priorityPatterns ?? []; + const priorityLines = priorityPatterns.length + ? lines.filter((line) => priorityPatterns.some((pattern) => pattern.test(line))) + : []; + + const head = lines.slice(0, preserveHead); + const tail = preserveTail > 0 ? lines.slice(-preserveTail) : []; + const selected = [...head]; + for (const line of priorityLines) { + if (!selected.includes(line)) selected.push(line); + } + const tailStart = lines.length - tail.length; + tail.forEach((line, offset) => { + const originalIndex = tailStart + offset; + if (originalIndex >= preserveHead && !selected.includes(line)) selected.push(line); + }); + + const droppedLines = Math.max(0, lines.length - selected.length); + let result = [ + ...selected.slice(0, head.length), + `[rtk:truncated ${droppedLines} lines]`, + ...selected.slice(head.length), + ].join("\n"); + + if (maxChars > 0 && result.length > maxChars) { + const marker = "\n[rtk:truncated by chars]\n"; + const budget = Math.max(0, maxChars - marker.length); + if (budget === 0) { + result = marker.slice(0, maxChars); + return { text: result, truncated: true, droppedLines }; + } + const headChars = Math.ceil(budget * 0.55); + const tailChars = Math.max(0, budget - headChars); + const tailText = tailChars > 0 ? result.slice(-tailChars) : ""; + result = `${result.slice(0, headChars)}${marker}${tailText}`; + if (result.length > maxChars) result = result.slice(0, maxChars); + } + + return { text: result, truncated: true, droppedLines }; +} diff --git a/open-sse/services/compression/engines/rtk/verify.ts b/open-sse/services/compression/engines/rtk/verify.ts new file mode 100644 index 0000000000..c59a5e9102 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/verify.ts @@ -0,0 +1,107 @@ +import { estimateCompressionTokens } from "../../stats.ts"; +import { + getRtkFilterLoadDiagnostics, + loadRtkFilters, + type RtkFilterLoadDiagnostic, +} from "./filterLoader.ts"; +import { applyLineFilter } from "./lineFilter.ts"; + +export interface RtkFilterTestOutcome { + filterId: string; + testName: string; + passed: boolean; + actual: string; + expected: string; +} + +export interface RtkFilterBenchmarkRow { + category: string; + filters: number; + tests: number; + averageSavingsPercent: number; +} + +export interface RtkVerifyResult { + passed: boolean; + outcomes: RtkFilterTestOutcome[]; + filtersWithoutTests: string[]; + benchmark: RtkFilterBenchmarkRow[]; + diagnostics: RtkFilterLoadDiagnostic[]; +} + +function trimComparable(value: string): string { + return value.replace(/\n+$/g, ""); +} + +export function runRtkFilterTests( + options: { + requireAll?: boolean; + customFiltersEnabled?: boolean; + trustProjectFilters?: boolean; + } = {} +): RtkVerifyResult { + const filters = loadRtkFilters({ + refresh: true, + customFiltersEnabled: options.customFiltersEnabled, + trustProjectFilters: options.trustProjectFilters, + }); + const outcomes: RtkFilterTestOutcome[] = []; + const filtersWithoutTests: string[] = []; + const benchmarkByCategory = new Map< + string, + { filters: Set; tests: number; savingsTotal: number } + >(); + + for (const filter of filters) { + const categoryStats = benchmarkByCategory.get(filter.category) ?? { + filters: new Set(), + tests: 0, + savingsTotal: 0, + }; + categoryStats.filters.add(filter.id); + benchmarkByCategory.set(filter.category, categoryStats); + + if (filter.tests.length === 0) { + filtersWithoutTests.push(filter.id); + continue; + } + + for (const test of filter.tests) { + const result = applyLineFilter(test.input, filter).text; + const actual = trimComparable(result); + const expected = trimComparable(test.expected); + const originalTokens = estimateCompressionTokens(test.input); + const compressedTokens = estimateCompressionTokens(result); + const savings = + originalTokens > 0 ? ((originalTokens - compressedTokens) / originalTokens) * 100 : 0; + categoryStats.tests += 1; + categoryStats.savingsTotal += Math.max(0, savings); + outcomes.push({ + filterId: filter.id, + testName: test.name, + passed: actual === expected, + actual, + expected, + }); + } + } + + const benchmark = Array.from(benchmarkByCategory.entries()) + .map(([category, value]) => ({ + category, + filters: value.filters.size, + tests: value.tests, + averageSavingsPercent: + value.tests > 0 ? Math.round((value.savingsTotal / value.tests) * 100) / 100 : 0, + })) + .sort((a, b) => a.category.localeCompare(b.category)); + + const failed = outcomes.some((outcome) => !outcome.passed); + return { + passed: !failed && (!options.requireAll || filtersWithoutTests.length === 0), + outcomes, + filtersWithoutTests, + benchmark, + diagnostics: options.customFiltersEnabled === false ? [] : getRtkFilterLoadDiagnostics(), + }; +} diff --git a/open-sse/services/compression/engines/types.ts b/open-sse/services/compression/engines/types.ts new file mode 100644 index 0000000000..b270d8d1d5 --- /dev/null +++ b/open-sse/services/compression/engines/types.ts @@ -0,0 +1,59 @@ +import type { CompressionConfig, CompressionResult } from "../types.ts"; + +export type CompressionEngineTarget = "messages" | "tool_results" | "code_blocks"; + +export interface EngineConfigField { + key: string; + type: "boolean" | "number" | "string" | "select" | "multiselect"; + label: string; + i18nKey?: string; + description?: string; + defaultValue: unknown; + options?: Array<{ value: string; label: string }>; + min?: number; + max?: number; +} + +export interface EngineValidationResult { + valid: boolean; + errors: string[]; +} + +export interface CompressionEngineMetadata { + id: string; + name: string; + description: string; + inputScope: "messages" | "tool-results" | "mixed"; + targetLatencyMs: number; + supportsPreview: boolean; + stable: boolean; +} + +export interface CompressionEngineApplyOptions { + model?: string; + supportsVision?: boolean | null; + config?: CompressionConfig; + compressionComboId?: string | null; + stepConfig?: Record; +} + +export interface CompressionEngine { + id: string; + name: string; + description: string; + icon: string; + targets: CompressionEngineTarget[]; + stackable: boolean; + stackPriority: number; + metadata: CompressionEngineMetadata; + apply(body: Record, options?: CompressionEngineApplyOptions): CompressionResult; + compress(body: Record, config?: Record): CompressionResult; + getConfigSchema(): EngineConfigField[]; + validateConfig(config: Record): EngineValidationResult; +} + +export interface EngineRegistryEntry { + engine: CompressionEngine; + enabled: boolean; + config: Record; +} diff --git a/open-sse/services/compression/index.ts b/open-sse/services/compression/index.ts index 98578a1f70..7f16cd9f00 100644 --- a/open-sse/services/compression/index.ts +++ b/open-sse/services/compression/index.ts @@ -5,6 +5,14 @@ export type { CompressionResult, CavemanConfig, CavemanRule, + CavemanIntensity, + CavemanOutputModeConfig, + RtkConfig, + RtkIntensity, + RtkRawOutputRetention, + CompressionEngineId, + CompressionLanguageConfig, + CompressionPipelineStep, AggressiveConfig, AgingThresholds, ToolStrategiesConfig, @@ -15,6 +23,9 @@ export type { export { DEFAULT_COMPRESSION_CONFIG, DEFAULT_CAVEMAN_CONFIG, + DEFAULT_CAVEMAN_OUTPUT_MODE_CONFIG, + DEFAULT_RTK_CONFIG, + DEFAULT_COMPRESSION_LANGUAGE_CONFIG, DEFAULT_AGGRESSIVE_CONFIG, } from "./types.ts"; @@ -28,8 +39,36 @@ export { } from "./lite.ts"; export { cavemanCompress, applyRulesToText } from "./caveman.ts"; -export { getRulesForContext, CAVEMAN_RULES } from "./cavemanRules.ts"; -export { extractPreservedBlocks, restorePreservedBlocks } from "./preservation.ts"; +export { getRulesForContext, getCavemanRuleMetadata, CAVEMAN_RULES } from "./cavemanRules.ts"; +export { + getAvailableLanguagePacks, + listCavemanRulePacks, + loadAllRulesForLanguage, + loadCavemanFileRules, + loadRulePack, + validateRulePack, +} from "./ruleLoader.ts"; +export type { RulePackMetadata } from "./ruleLoader.ts"; +export { + detectCompressionLanguage, + listSupportedCompressionLanguages, +} from "./languageDetector.ts"; +export { + extractPreservedBlocks, + restorePreservedBlocks, + findFencedCodeBlocks, +} from "./preservation.ts"; +export type { PreservedBlock, PreservationOptions } from "./preservation.ts"; +export { validateCompression } from "./validation.ts"; +export type { ValidationResult } from "./validation.ts"; +export { + applyCavemanOutputMode, + buildCavemanOutputInstruction, + shouldBypassCavemanOutputMode, +} from "./outputMode.ts"; +export type { CavemanOutputModeResult } from "./outputMode.ts"; +export { buildCompressionDiff, buildCompressionPreviewDiff } from "./diffHelper.ts"; +export type { CompressionDiffSegment, CompressionPreviewDiff } from "./diffHelper.ts"; export { estimateCompressionTokens, @@ -44,8 +83,62 @@ export { applyCompression, checkComboOverride, shouldAutoTrigger, + applyStackedCompression, } from "./strategySelector.ts"; +export type { + CompressionEngine, + CompressionEngineApplyOptions, + CompressionEngineMetadata, + CompressionEngineTarget, + EngineConfigField, + EngineRegistryEntry, + EngineValidationResult, +} from "./engines/types.ts"; + +export { + registerEngine, + registerCompressionEngine, + unregisterCompressionEngine, + getEngine, + getEngineEntry, + getCompressionEngine, + listEngines, + listCompressionEngines, + listEnabledEngines, + setEngineEnabled, + updateEngineConfig, + clearCompressionEngineRegistry, +} from "./engines/registry.ts"; +export { registerBuiltinCompressionEngines } from "./engines/index.ts"; + +export { applyRtkCompression, processRtkText, rtkEngine } from "./engines/rtk/index.ts"; +export { + detectCommandFromText, + detectCommandOutput, + detectCommandType, + type CommandDetectionResult, +} from "./engines/rtk/commandDetector.ts"; +export { + loadRtkFilters, + getRtkFilterCatalog, + matchRtkFilter, + getRtkFilterLoadDiagnostics, +} from "./engines/rtk/filterLoader.ts"; +export { runRtkFilterTests } from "./engines/rtk/verify.ts"; +export { + maybePersistRtkRawOutput, + readRtkRawOutput, + redactRtkRawOutput, +} from "./engines/rtk/rawOutput.ts"; +export { + detectCodeLanguage, + normalizeCodeLanguage, + stripCode, + stripCodeComments, +} from "./engines/rtk/codeStripper.ts"; +export type { CodeLanguage, CodeStripperOptions } from "./engines/rtk/codeStripper.ts"; + export { RuleBasedSummarizer, createSummarizer } from "./summarizer.ts"; export { compressToolResult } from "./toolResultCompressor.ts"; diff --git a/open-sse/services/compression/languageDetector.ts b/open-sse/services/compression/languageDetector.ts new file mode 100644 index 0000000000..3b3aa9b002 --- /dev/null +++ b/open-sse/services/compression/languageDetector.ts @@ -0,0 +1,18 @@ +const LANGUAGE_HINTS: Record = { + "pt-BR": [/\b(?:voce|você|preciso|arquivo|codigo|código|erro|falha|obrigado)\b/i], + es: [/\b(?:necesito|archivo|codigo|código|error|fallo|gracias|puedes)\b/i], + de: [/\b(?:ich|datei|fehler|bitte|kannst|konfiguration|danke)\b/i], + fr: [/\b(?:fichier|erreur|merci|peux|configuration|besoin)\b/i], + ja: [/[\u3040-\u30ff]/], +}; + +export function detectCompressionLanguage(text: string): string { + for (const [language, patterns] of Object.entries(LANGUAGE_HINTS)) { + if (patterns.some((pattern) => pattern.test(text))) return language; + } + return "en"; +} + +export function listSupportedCompressionLanguages(): string[] { + return ["en", ...Object.keys(LANGUAGE_HINTS)]; +} diff --git a/open-sse/services/compression/lite.ts b/open-sse/services/compression/lite.ts index 5e530e74f5..fba65badbe 100644 --- a/open-sse/services/compression/lite.ts +++ b/open-sse/services/compression/lite.ts @@ -15,6 +15,7 @@ interface ChatBody { interface LiteCompressionOptions { model?: string; supportsVision?: boolean | null; + preserveSystemPrompt?: boolean; } function trimTrailingHorizontalWhitespace(line: string): string { @@ -62,13 +63,17 @@ function modelSupportsVision(model: string): boolean { ); } -export function collapseWhitespace(body: ChatBody): { +export function collapseWhitespace( + body: ChatBody, + options: LiteCompressionOptions = {} +): { body: ChatBody; applied: boolean; } { if (!body.messages) return { body, applied: false }; let applied = false; const messages = body.messages.map((msg) => { + if (options.preserveSystemPrompt === true && msg.role === "system") return msg; if (typeof msg.content !== "string") return msg; const normalized = normalizeMessageWhitespace(msg.content); if (normalized !== msg.content) applied = true; @@ -77,11 +82,15 @@ export function collapseWhitespace(body: ChatBody): { return { body: { ...body, messages }, applied }; } -export function dedupSystemPrompt(body: ChatBody): { +export function dedupSystemPrompt( + body: ChatBody, + options: LiteCompressionOptions = {} +): { body: ChatBody; applied: boolean; } { if (!body.messages) return { body, applied: false }; + if (options.preserveSystemPrompt === true) return { body, applied: false }; const seen = new Set(); let applied = false; const messages = body.messages.filter((msg) => { @@ -116,7 +125,10 @@ export function compressToolResults(body: ChatBody): { return { body: { ...body, messages }, applied }; } -export function removeRedundantContent(body: ChatBody): { +export function removeRedundantContent( + body: ChatBody, + options: LiteCompressionOptions = {} +): { body: ChatBody; applied: boolean; } { @@ -125,6 +137,10 @@ export function removeRedundantContent(body: ChatBody): { const messages: Message[] = []; for (let i = 0; i < body.messages.length; i++) { const msg = body.messages[i]; + if (options.preserveSystemPrompt === true && msg.role === "system") { + messages.push(msg); + continue; + } const contentStr = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content); if ( i > 0 && @@ -188,11 +204,11 @@ export function applyLiteCompression( let current = body as ChatBody; const techniquesApplied: string[] = []; - const r1 = collapseWhitespace(current); + const r1 = collapseWhitespace(current, options); current = r1.body; if (r1.applied) techniquesApplied.push("whitespace"); - const r2 = dedupSystemPrompt(current); + const r2 = dedupSystemPrompt(current, options); current = r2.body; if (r2.applied) techniquesApplied.push("system-dedup"); @@ -200,7 +216,7 @@ export function applyLiteCompression( current = r3.body; if (r3.applied) techniquesApplied.push("tool-compress"); - const r4 = removeRedundantContent(current); + const r4 = removeRedundantContent(current, options); current = r4.body; if (r4.applied) techniquesApplied.push("redundant-remove"); diff --git a/open-sse/services/compression/messageContent.ts b/open-sse/services/compression/messageContent.ts new file mode 100644 index 0000000000..7bd414d924 --- /dev/null +++ b/open-sse/services/compression/messageContent.ts @@ -0,0 +1,79 @@ +export interface TextBlock { + type?: string; + text?: string; + [key: string]: unknown; +} + +export interface ChatMessageLike { + role: string; + content?: string | TextBlock[] | unknown[]; + [key: string]: unknown; +} + +export function isTextBlock(value: unknown): value is TextBlock { + return ( + !!value && + typeof value === "object" && + "text" in value && + typeof (value as TextBlock).text === "string" && + ((value as TextBlock).type === undefined || + (value as TextBlock).type === "text" || + (value as TextBlock).type === "input_text") + ); +} + +export function extractTextContent(content: ChatMessageLike["content"]): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + + const textParts: string[] = []; + for (const part of content) { + if (isTextBlock(part) && part.text) { + textParts.push(part.text); + } + } + return textParts.join("\n"); +} + +export function mapTextContent( + msg: ChatMessageLike, + transform: (text: string, index: number) => string +): ChatMessageLike { + if (typeof msg.content === "string") { + return { ...msg, content: transform(msg.content, 0) }; + } + if (!Array.isArray(msg.content)) return msg; + + let textIndex = 0; + let changed = false; + const content = msg.content.map((part) => { + if (!isTextBlock(part)) return part; + const nextText = transform(part.text ?? "", textIndex); + textIndex++; + if (nextText === part.text) return part; + changed = true; + return { ...part, text: nextText }; + }); + + return changed ? { ...msg, content } : msg; +} + +export function replaceTextContent(msg: ChatMessageLike, newText: string): ChatMessageLike { + if (typeof msg.content === "string" || !Array.isArray(msg.content)) { + return { ...msg, content: newText }; + } + + let replaced = false; + const content = msg.content.flatMap((part) => { + if (!isTextBlock(part)) return [part]; + if (replaced) return []; + replaced = true; + return [{ ...part, text: newText }]; + }); + + if (!replaced) { + return { ...msg, content: [{ type: "text", text: newText }, ...msg.content] }; + } + + return { ...msg, content }; +} diff --git a/open-sse/services/compression/outputMode.ts b/open-sse/services/compression/outputMode.ts new file mode 100644 index 0000000000..9f4e7bfe45 --- /dev/null +++ b/open-sse/services/compression/outputMode.ts @@ -0,0 +1,161 @@ +import { DEFAULT_CAVEMAN_OUTPUT_MODE_CONFIG, type CavemanOutputModeConfig } from "./types.ts"; +import { extractTextContent } from "./messageContent.ts"; + +interface ChatMessage { + role: string; + content?: string | unknown[]; + [key: string]: unknown; +} + +interface ChatRequestBody { + messages?: ChatMessage[]; + instructions?: string; + [key: string]: unknown; +} + +export interface CavemanOutputModeResult { + body: ChatRequestBody; + applied: boolean; + skippedReason?: string; +} + +const CAVEMAN_INSTRUCTION_BY_LANGUAGE = { + en: { + lite: "Respond concise. Drop filler, pleasantries, hedging. Keep full sentences, technical terms, code, errors, URLs, and identifiers exact.", + full: "Respond terse like smart caveman. Drop articles, filler, pleasantries, hedging. Fragments OK. Keep all technical substance, code, errors, URLs, identifiers exact.", + ultra: + "Respond ultra terse. Use short technical prose, arrows for causality, common abbreviations like DB/auth/config/req/res/fn. Never abbreviate code symbols, API names, error strings, URLs, or identifiers.", + }, + "pt-BR": { + lite: "Responda conciso. Remova enrolacao, cortesias e incerteza. Preserve termos tecnicos, codigo, erros, URLs e identificadores exatamente.", + full: "Responda seco e compacto. Frases curtas OK. Preserve todo conteudo tecnico, codigo, erros, URLs e identificadores exatamente.", + ultra: + "Responda ultra compacto. Use prosa tecnica curta e abreviacoes comuns como DB/auth/config/req/res/fn. Nunca abrevie simbolos de codigo, APIs, erros, URLs ou identificadores.", + }, + es: { + lite: "Responde conciso. Quita relleno, cortesias y dudas. Conserva terminos tecnicos, codigo, errores, URLs e identificadores exactos.", + full: "Responde seco y compacto. Fragmentos OK. Conserva todo el contenido tecnico, codigo, errores, URLs e identificadores exactos.", + ultra: + "Responde ultra compacto. Usa prosa tecnica corta y abreviaturas comunes como DB/auth/config/req/res/fn. Nunca abrevies simbolos de codigo, APIs, errores, URLs o identificadores.", + }, + de: { + lite: "Antworte knapp. Entferne Fuellwoerter, Hoeflichkeit und Unsicherheit. Bewahre Fachbegriffe, Code, Fehler, URLs und Bezeichner exakt.", + full: "Antworte sehr knapp. Fragmente OK. Bewahre alle technischen Inhalte, Code, Fehler, URLs und Bezeichner exakt.", + ultra: + "Antworte ultra knapp. Nutze kurze technische Prosa und uebliche Abkuerzungen wie DB/auth/config/req/res/fn. Code-Symbole, APIs, Fehler, URLs und Bezeichner nie abkuerzen.", + }, + fr: { + lite: "Reponds concis. Retire remplissage, politesses et hesitations. Garde termes techniques, code, erreurs, URLs et identifiants exacts.", + full: "Reponds tres compact. Fragments OK. Garde tout le contenu technique, code, erreurs, URLs et identifiants exacts.", + ultra: + "Reponds ultra compact. Utilise une prose technique courte et des abreviations communes comme DB/auth/config/req/res/fn. N'abrege jamais symboles de code, APIs, erreurs, URLs ou identifiants.", + }, + ja: { + lite: "簡潔に回答。冗長表現、挨拶、曖昧表現を削る。技術用語、コード、エラー、URL、識別子は正確に保持。", + full: "短く圧縮して回答。断片文可。技術内容、コード、エラー、URL、識別子は正確に保持。", + ultra: + "超短く回答。DB/auth/config/req/res/fn など一般的な略語は可。コード記号、API名、エラー文字列、URL、識別子は省略しない。", + }, +} as const; + +const CAVEMAN_OUTPUT_MARKER = "[OmniRoute Caveman Output Mode]"; + +export function shouldBypassCavemanOutputMode(messages: ChatMessage[]): string | null { + const text = messages + .slice(-3) + .map((message) => extractTextContent(message.content).toLowerCase()) + .join("\n"); + + if (!text.trim()) return null; + if ( + /\b(security|vulnerability|exploit|credential leak|secret leak|malware|phishing)\b/.test(text) + ) { + return "security_warning"; + } + if (/\b(delete|drop table|truncate|destroy|wipe|irreversible|permanently remove)\b/.test(text)) { + return "irreversible_action"; + } + if ( + /\b(clarify|explain in detail|more detail|step by step|why exactly|what do you mean)\b/.test( + text + ) + ) { + return "clarification_requested"; + } + if ( + /\b(first|then|after that|before|rollback|backup)\b[\s\S]{0,240}\b(delete|drop|migrate|deploy|release)\b/.test( + text + ) + ) { + return "order_sensitive_sequence"; + } + return null; +} + +export function buildCavemanOutputInstruction( + config: CavemanOutputModeConfig, + language = "en" +): string { + const intensity = config.intensity ?? "full"; + const instructions = + CAVEMAN_INSTRUCTION_BY_LANGUAGE[language as keyof typeof CAVEMAN_INSTRUCTION_BY_LANGUAGE] ?? + CAVEMAN_INSTRUCTION_BY_LANGUAGE.en; + return `${CAVEMAN_OUTPUT_MARKER}\n${instructions[intensity]}`; +} + +export function applyCavemanOutputMode( + body: ChatRequestBody, + options?: Partial, + language = "en" +): CavemanOutputModeResult { + const config: CavemanOutputModeConfig = { + ...DEFAULT_CAVEMAN_OUTPUT_MODE_CONFIG, + ...options, + }; + if (!config.enabled) return { body, applied: false, skippedReason: "disabled" }; + + const messages = Array.isArray(body.messages) ? body.messages : null; + if (!messages || messages.length === 0) { + if (typeof body.instructions === "string") { + if (body.instructions.includes(CAVEMAN_OUTPUT_MARKER)) { + return { body, applied: false, skippedReason: "already_applied" }; + } + return { + body: { + ...body, + instructions: `${body.instructions.trim()}\n\n${buildCavemanOutputInstruction(config, language)}`, + }, + applied: true, + }; + } + return { body, applied: false, skippedReason: "no_messages" }; + } + + if (config.autoClarity !== false) { + const bypass = shouldBypassCavemanOutputMode(messages); + if (bypass) return { body, applied: false, skippedReason: bypass }; + } + + const alreadyApplied = messages.some( + (message) => + message.role === "system" && + typeof message.content === "string" && + message.content.includes(CAVEMAN_OUTPUT_MARKER) + ); + if (alreadyApplied) return { body, applied: false, skippedReason: "already_applied" }; + + const instruction = buildCavemanOutputInstruction(config, language); + const nextMessages = [...messages]; + const first = nextMessages[0]; + + if (first?.role === "system" && typeof first.content === "string") { + nextMessages[0] = { + ...first, + content: `${first.content.trim()}\n\n${instruction}`, + }; + } else { + nextMessages.unshift({ role: "system", content: instruction }); + } + + return { body: { ...body, messages: nextMessages }, applied: true }; +} diff --git a/open-sse/services/compression/preservation.ts b/open-sse/services/compression/preservation.ts index d65edbd84b..dcd382b92a 100644 --- a/open-sse/services/compression/preservation.ts +++ b/open-sse/services/compression/preservation.ts @@ -1,72 +1,183 @@ -interface PreservedBlock { +export interface PreservedBlock { placeholder: string; content: string; + kind: string; } -export function extractPreservedBlocks(text: string): { text: string; blocks: PreservedBlock[] } { +export interface PreservationOptions { + preservePatterns?: Array; +} + +interface CompiledPattern { + pattern: RegExp; + kind: string; +} + +const SENTINEL_PREFIX = "\u0000OMNI_CAVEMAN"; + +function randomSentinelSeed(): string { + const bytes = new Uint8Array(8); + const cryptoLike = globalThis.crypto; + if (cryptoLike?.getRandomValues) { + cryptoLike.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + } + return Math.random().toString(36).slice(2); +} + +function ensureGlobal(pattern: RegExp): RegExp { + const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`; + return new RegExp(pattern.source, flags); +} + +function compileUserPatterns(patterns: Array | undefined): CompiledPattern[] { + if (!patterns?.length) return []; + const compiled: CompiledPattern[] = []; + for (const pattern of patterns) { + try { + compiled.push({ + pattern: typeof pattern === "string" ? new RegExp(pattern, "g") : ensureGlobal(pattern), + kind: "custom", + }); + } catch { + // Invalid user regexes are ignored by the hot path. Validation/preview exposes warnings. + } + } + return compiled; +} + +function replacePattern( + text: string, + pattern: RegExp, + kind: string, + addBlock: (content: string, kind: string) => string +): string { + pattern.lastIndex = 0; + return text.replace(pattern, (match) => { + if (!match) return match; + if (match.includes(SENTINEL_PREFIX)) return match; + return addBlock(match, kind); + }); +} + +export function findFencedCodeBlocks(text: string): string[] { + const blocks: string[] = []; + extractFencedCodeBlocks(text, (content) => { + blocks.push(content); + return content; + }); + return blocks; +} + +export function extractPreservedBlocks( + text: string, + options: PreservationOptions = {} +): { text: string; blocks: PreservedBlock[] } { const blocks: PreservedBlock[] = []; - let result = text; + const seed = randomSentinelSeed(); let counter = 0; - const addBlock = (content: string): string => { - const placeholder = `[PRESERVED_${counter}]`; - blocks.push({ placeholder, content }); + const addBlock = (content: string, kind: string): string => { + const placeholder = `${SENTINEL_PREFIX}_${seed}_${counter}\u0000`; + blocks.push({ placeholder, content, kind }); counter++; return placeholder; }; - // Extract fenced code blocks with a linear scan to avoid ReDoS-prone patterns. - result = extractFencedCodeBlocks(result, addBlock); + let result = text; - // Extract inline code (`...`) - result = result.replace(/`[^`\n]+`/g, (match) => addBlock(match)); + result = extractFrontmatter(result, addBlock); + result = extractFencedCodeBlocks(result, (content) => addBlock(content, "fenced_code")); - // Extract URLs - result = result.replace(/https?:\/\/[^\s)\]"'>]+/g, (match) => addBlock(match)); + const builtIns: CompiledPattern[] = [ + { pattern: /\$\$[\s\S]*?\$\$/g, kind: "math_block" }, + { pattern: /\\\[[\s\S]*?\\\]/g, kind: "math_block" }, + { pattern: /(?]+/gi, kind: "url" }, + { pattern: /\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b/g, kind: "const_case" }, + { pattern: /\bprocess\.env\.[A-Za-z_][A-Za-z0-9_]*\b/g, kind: "env_var" }, + { pattern: /\$[A-Z_][A-Z0-9_]*\b/g, kind: "env_var" }, + { pattern: /\b\d+(?:\.\d+){1,3}(?:[-+][A-Za-z0-9.-]+)?\b/g, kind: "version" }, + { pattern: /\b[a-zA-Z_$][\w$]*(?:\.[a-zA-Z_$][\w$]*)+\(\)?/g, kind: "dotted_identifier" }, + { pattern: /\b[A-Za-z_$][\w$]*\s*\([^()\n]*\)/g, kind: "function_call" }, + { + pattern: /(?:^|\s)(?:\.{0,2}\/[A-Za-z0-9_@./-]+|[A-Za-z]:\\[A-Za-z0-9_.\\/-]+)/g, + kind: "file_path", + }, + { + pattern: + /\b(?:TypeError|ReferenceError|SyntaxError|RangeError|URIError|EvalError|Error|Exception):[^\n]+/g, + kind: "error_message", + }, + ]; - // Extract file paths (Unix and Windows) - result = result.replace(/(?:\/[a-zA-Z0-9_./-]+|[a-zA-Z]:\\[a-zA-Z0-9_.\\/-]+)/g, (match) => { - if (match.length < 3) return match; - if (match.startsWith("[PRESERVED_")) return match; - return addBlock(match); - }); - - // Extract error-like patterns (TypeError:, Error:, 404, etc.) - result = result.replace( - /\b(?:TypeError|ReferenceError|SyntaxError|RangeError|URIError|EvalError|Error):\s*[^\s,;)]+/g, - (match) => addBlock(match) - ); + for (const { pattern, kind } of [...builtIns, ...compileUserPatterns(options.preservePatterns)]) { + result = replacePattern(result, ensureGlobal(pattern), kind, addBlock); + } return { text: result, blocks }; } +function extractFrontmatter( + text: string, + addBlock: (content: string, kind: string) => string +): string { + if (!text.startsWith("---\n")) return text; + const close = text.indexOf("\n---", 4); + if (close === -1) return text; + const closeEnd = text.indexOf("\n", close + 4); + const end = closeEnd === -1 ? text.length : closeEnd + 1; + return `${addBlock(text.slice(0, end), "frontmatter")}${text.slice(end)}`; +} + function extractFencedCodeBlocks(text: string, addBlock: (content: string) => string): string { + const lines = text.match(/[^\n]*(?:\n|$)/g) ?? [text]; let output = ""; - let cursor = 0; + let i = 0; - while (cursor < text.length) { - const start = text.indexOf("```", cursor); - if (start === -1) { - output += text.slice(cursor); - break; + while (i < lines.length) { + const line = lines[i]; + if (line === "" && i === lines.length - 1) break; + const opening = line.match(/^([ \t]{0,3})(`{3,}|~{3,})[^\n]*(?:\n|$)/); + if (!opening) { + output += line; + i++; + continue; } - const openingLineEnd = text.indexOf("\n", start + 3); - if (openingLineEnd === -1) { - output += text.slice(cursor); - break; + const fence = opening[2]; + const fenceChar = fence[0]; + const minLen = fence.length; + let block = line; + let j = i + 1; + let closed = false; + + while (j < lines.length) { + const candidate = lines[j]; + block += candidate; + const closeMatch = candidate.match(/^([ \t]{0,3})(`{3,}|~{3,})\s*(?:\n|$)/); + if (closeMatch && closeMatch[2][0] === fenceChar && closeMatch[2].length >= minLen) { + closed = true; + break; + } + j++; } - const closeStart = text.indexOf("\n```", openingLineEnd + 1); - if (closeStart === -1) { - output += text.slice(cursor); - break; + if (!closed) { + output += line; + i++; + continue; } - const closeEnd = closeStart + 4; - output += text.slice(cursor, start); - output += addBlock(text.slice(start, closeEnd)); - cursor = closeEnd; + output += addBlock(block); + i = j + 1; } return output; @@ -75,11 +186,14 @@ function extractFencedCodeBlocks(text: string, addBlock: (content: string) => st export function restorePreservedBlocks(text: string, blocks: PreservedBlock[]): string { let result = text; for (const block of blocks) { - result = result.replace(block.placeholder, () => block.content); + result = result.split(block.placeholder).join(block.content); } return result; } export function shouldPreserve(text: string, preservePatterns: RegExp[]): boolean { - return preservePatterns.some((pattern) => pattern.test(text)); + return preservePatterns.some((pattern) => { + pattern.lastIndex = 0; + return pattern.test(text); + }); } diff --git a/open-sse/services/compression/progressiveAging.ts b/open-sse/services/compression/progressiveAging.ts index 2b742ecf9f..f9f5399a60 100644 --- a/open-sse/services/compression/progressiveAging.ts +++ b/open-sse/services/compression/progressiveAging.ts @@ -2,6 +2,7 @@ import type { AgingThresholds, Summarizer } from "./types.ts"; import { DEFAULT_AGGRESSIVE_CONFIG } from "./types.ts"; import { applyLiteCompression } from "./lite.ts"; import { cavemanCompress } from "./caveman.ts"; +import { extractTextContent, replaceTextContent, type ChatMessageLike } from "./messageContent.ts"; const COMPRESSED_MARKER_RE = /^\[COMPRESSED:/; @@ -9,43 +10,24 @@ function estimateTokens(text: string): number { return Math.ceil(text.length / 4); } -interface ChatMessage { - role: string; - content?: string | Array<{ type: string; text?: string }>; -} - -function extractText(content?: string | Array<{ type: string; text?: string }>): string { - if (typeof content === "string") return content; - if (Array.isArray(content)) { - return content - .filter( - (p): p is { type: string; text?: string } => - typeof p === "object" && p !== null && "text" in p - ) - .map((p) => p.text ?? "") - .join("\n"); - } - return ""; -} +type ChatMessage = ChatMessageLike; function setContent(msg: ChatMessage, newContent: string): ChatMessage { - if (typeof msg.content === "string") { - return { ...msg, content: newContent }; - } - return { ...msg, content: [{ type: "text", text: newContent }] }; + return replaceTextContent(msg, newContent) as ChatMessage; } export function applyAging( messages: unknown[], thresholds?: AgingThresholds, - summarizer?: Summarizer + summarizer?: Summarizer, + preserveSystemPrompt = true ): { messages: unknown[]; saved: number } { const t = thresholds ?? DEFAULT_AGGRESSIVE_CONFIG.thresholds; const sum = summarizer ?? { summarize: (msgs: unknown[]) => { const typed = msgs as ChatMessage[]; const last = typed.filter((m) => m.role === "assistant").pop(); - return last ? extractText(last.content).slice(0, 200) : ""; + return last ? extractTextContent(last.content).slice(0, 200) : ""; }, }; @@ -58,9 +40,9 @@ export function applyAging( for (let i = 0; i < typed.length; i++) { const msg = typed[i]; - const text = extractText(msg.content); + const text = extractTextContent(msg.content); - if (COMPRESSED_MARKER_RE.test(text)) { + if ((preserveSystemPrompt && msg.role === "system") || COMPRESSED_MARKER_RE.test(text)) { result.push(msg); continue; } @@ -75,7 +57,7 @@ export function applyAging( const newContent = typeof compressed.body.messages[0].content === "string" ? compressed.body.messages[0].content - : extractText(compressed.body.messages[0].content); + : extractTextContent(compressed.body.messages[0].content); const tagged = `[COMPRESSED:aging:light] ${newContent}`; saved += estimateTokens(text) - estimateTokens(tagged); result.push(setContent(msg, tagged)); @@ -88,7 +70,7 @@ export function applyAging( const newContent = typeof compressed.body.messages[0].content === "string" ? compressed.body.messages[0].content - : extractText(compressed.body.messages[0].content); + : extractTextContent(compressed.body.messages[0].content); const tagged = `[COMPRESSED:aging:moderate] ${newContent}`; saved += estimateTokens(text) - estimateTokens(tagged); result.push(setContent(msg, tagged)); diff --git a/open-sse/services/compression/ruleLoader.ts b/open-sse/services/compression/ruleLoader.ts new file mode 100644 index 0000000000..6bea11b030 --- /dev/null +++ b/open-sse/services/compression/ruleLoader.ts @@ -0,0 +1,258 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { CavemanIntensity, CavemanRule } from "./types.ts"; + +type CavemanRuleCategory = NonNullable; +type CavemanRuleContext = CavemanRule["context"]; + +interface FileRule { + name: string; + pattern: string; + replacement?: string; + replacementMap?: Record; + flags?: string; + context?: CavemanRuleContext; + category?: CavemanRuleCategory; + minIntensity?: CavemanIntensity; + description?: string; +} + +interface RulePack { + language: string; + category: string; + rules: FileRule[]; +} + +export interface RulePackMetadata { + language: string; + categories: string[]; + ruleCount: number; +} + +const VALID_CONTEXTS = new Set(["all", "user", "system", "assistant"]); +const VALID_CATEGORIES = new Set(["filler", "context", "structural", "dedup", "terse", "ultra"]); +const VALID_INTENSITIES = new Set(["lite", "full", "ultra"]); +const cache = new Map(); +let rulesDirCache: string | null = null; + +function normalizeReplacementKey(value: string): string { + return value.trim().replace(/\s+/g, " ").toLowerCase(); +} + +function compileReplacement(rule: FileRule): CavemanRule["replacement"] { + if (!rule.replacementMap) return rule.replacement ?? ""; + + const normalizedMap = new Map( + Object.entries(rule.replacementMap).map(([key, value]) => [normalizeReplacementKey(key), value]) + ); + const fallback = rule.replacement; + return (match: string) => { + const normalized = normalizeReplacementKey(match); + if (normalizedMap.has(normalized)) return normalizedMap.get(normalized) ?? ""; + return fallback ?? match; + }; +} + +function getRuleFlags(rule: FileRule): string { + return rule.flags ?? "gi"; +} + +function getRulesDir(): string { + if (rulesDirCache) return rulesDirCache; + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + path.join(moduleDir, "rules"), + path.join(moduleDir, "..", "services", "compression", "rules"), + path.join(process.cwd(), "open-sse", "services", "compression", "rules"), + path.join(process.cwd(), "app", "open-sse", "services", "compression", "rules"), + ]; + rulesDirCache = + candidates.find((candidate, index) => { + return candidates.indexOf(candidate) === index && fs.existsSync(candidate); + }) ?? candidates[0]; + return rulesDirCache; +} + +function compileRule(rule: FileRule, source: string): CavemanRule { + try { + const flags = getRuleFlags(rule); + return { + name: rule.name, + pattern: new RegExp(rule.pattern, flags), + replacement: compileReplacement(rule), + context: rule.context ?? "all", + category: rule.category ?? "filler", + minIntensity: rule.minIntensity ?? "lite", + description: rule.description, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid Caveman rule pattern in ${source}:${rule.name}: ${message}`); + } +} + +export function validateRulePack(pack: unknown): { valid: boolean; errors: string[] } { + const errors: string[] = []; + if (!pack || typeof pack !== "object") { + return { valid: false, errors: ["Rule pack must be an object"] }; + } + + const value = pack as Partial; + if (typeof value.language !== "string" || !value.language.trim()) { + errors.push("language must be a non-empty string"); + } + if (typeof value.category !== "string" || !value.category.trim()) { + errors.push("category must be a non-empty string"); + } + if (!Array.isArray(value.rules)) { + errors.push("rules must be an array"); + } else { + value.rules.forEach((rule, index) => { + if (!rule || typeof rule !== "object") { + errors.push(`rules[${index}] must be an object`); + return; + } + const entry = rule as Partial; + const flags = typeof entry.flags === "string" ? entry.flags : "gi"; + if (typeof entry.name !== "string" || !entry.name.trim()) { + errors.push(`rules[${index}].name must be a non-empty string`); + } + if (typeof entry.pattern !== "string" || !entry.pattern.trim()) { + errors.push(`rules[${index}].pattern must be a non-empty string`); + } else { + try { + new RegExp(entry.pattern, flags); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + errors.push(`rules[${index}].pattern is invalid: ${message}`); + } + } + if (entry.flags !== undefined && typeof entry.flags !== "string") { + errors.push(`rules[${index}].flags must be a string`); + } + if (entry.replacement !== undefined && typeof entry.replacement !== "string") { + errors.push(`rules[${index}].replacement must be a string`); + } + if (entry.replacementMap !== undefined) { + if ( + !entry.replacementMap || + typeof entry.replacementMap !== "object" || + Array.isArray(entry.replacementMap) + ) { + errors.push(`rules[${index}].replacementMap must be an object`); + } else { + Object.entries(entry.replacementMap).forEach(([key, replacement]) => { + if (!key.trim()) { + errors.push(`rules[${index}].replacementMap contains an empty key`); + } + if (typeof replacement !== "string") { + errors.push(`rules[${index}].replacementMap.${key} must be a string`); + } + }); + } + } + if (typeof entry.replacement !== "string" && entry.replacementMap === undefined) { + errors.push(`rules[${index}] must define replacement or replacementMap`); + } + if (entry.context !== undefined && !VALID_CONTEXTS.has(entry.context)) { + errors.push(`rules[${index}].context is invalid`); + } + if (entry.category !== undefined && !VALID_CATEGORIES.has(entry.category)) { + errors.push(`rules[${index}].category is invalid`); + } + if (entry.minIntensity !== undefined && !VALID_INTENSITIES.has(entry.minIntensity)) { + errors.push(`rules[${index}].minIntensity is invalid`); + } + }); + } + return { valid: errors.length === 0, errors }; +} + +function readPack(language: string, category: string): RulePack | null { + const filename = path.join(getRulesDir(), language, `${category}.json`); + if (!fs.existsSync(filename)) return null; + const parsed = JSON.parse(fs.readFileSync(filename, "utf8")) as unknown; + const validation = validateRulePack(parsed); + if (!validation.valid) { + throw new Error( + `Invalid Caveman rule pack ${language}/${category}: ${validation.errors.join("; ")}` + ); + } + return parsed as RulePack; +} + +export function loadRulePack( + language: string, + category: string, + options: { refresh?: boolean } = {} +): CavemanRule[] { + const key = `${getRulesDir()}:${language}:${category}`; + if (cache.has(key) && !options.refresh) return cache.get(key) ?? []; + + const pack = readPack(language, category); + if (!pack) { + cache.set(key, []); + return []; + } + + const rules = pack.rules.map((rule) => compileRule(rule, `${language}/${category}`)); + cache.set(key, rules); + return rules; +} + +export function loadAllRulesForLanguage( + language: string, + options: { refresh?: boolean } = {} +): CavemanRule[] { + const key = `${getRulesDir()}:${language}:*`; + if (cache.has(key) && !options.refresh) return cache.get(key) ?? []; + + const languageDir = path.join(getRulesDir(), language); + if (!fs.existsSync(languageDir)) { + cache.set(key, []); + return []; + } + + const rules = fs + .readdirSync(languageDir) + .filter((entry) => entry.endsWith(".json")) + .sort() + .flatMap((entry) => loadRulePack(language, path.basename(entry, ".json"), options)); + + cache.set(key, rules); + return rules; +} + +export function getAvailableLanguagePacks(): RulePackMetadata[] { + const root = getRulesDir(); + if (!fs.existsSync(root)) return []; + + return fs + .readdirSync(root) + .filter((entry) => fs.statSync(path.join(root, entry)).isDirectory()) + .map((language) => { + const categories = fs + .readdirSync(path.join(root, language)) + .filter((entry) => entry.endsWith(".json")) + .map((entry) => path.basename(entry, ".json")) + .sort(); + const ruleCount = categories.reduce( + (count, category) => count + loadRulePack(language, category).length, + 0 + ); + return { language, categories, ruleCount }; + }) + .sort((a, b) => a.language.localeCompare(b.language)); +} + +export function loadCavemanFileRules( + language: string, + options: { refresh?: boolean } = {} +): CavemanRule[] { + return loadAllRulesForLanguage(language, options); +} + +export function listCavemanRulePacks(): RulePackMetadata[] { + return getAvailableLanguagePacks(); +} diff --git a/open-sse/services/compression/rules/_schema.json b/open-sse/services/compression/rules/_schema.json new file mode 100644 index 0000000000..9ec4fc4724 --- /dev/null +++ b/open-sse/services/compression/rules/_schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OmniRoute Caveman Rule Pack", + "type": "object", + "required": ["language", "category", "rules"], + "properties": { + "language": { "type": "string" }, + "category": { "type": "string" }, + "rules": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "pattern"], + "properties": { + "name": { "type": "string" }, + "pattern": { "type": "string" }, + "flags": { "type": "string" }, + "replacement": { "type": "string" }, + "replacementMap": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "context": { "enum": ["all", "user", "system", "assistant"] }, + "category": { "enum": ["filler", "context", "structural", "dedup", "terse", "ultra"] }, + "minIntensity": { "enum": ["lite", "full", "ultra"] }, + "description": { "type": "string" } + } + } + } + } +} diff --git a/open-sse/services/compression/rules/de/context.json b/open-sse/services/compression/rules/de/context.json new file mode 100644 index 0000000000..64173166bf --- /dev/null +++ b/open-sse/services/compression/rules/de/context.json @@ -0,0 +1,38 @@ +{ + "language": "de", + "category": "context", + "rules": [ + { + "name": "de_context_setup", + "pattern": "\\b(?:hier ist der code|unten ist der code|dies ist der code)\\b\\s*[:.]?\\s*", + "replacement": "Code:", + "context": "user", + "category": "context", + "minIntensity": "lite" + }, + { + "name": "de_intent", + "pattern": "\\b(?:mein ziel ist|was ich brauche ist|ich versuche|die idee ist)\\b\\s*", + "replacement": "Ziel:", + "context": "user", + "category": "context", + "minIntensity": "lite" + }, + { + "name": "de_question_directive", + "pattern": "\\b(?:kannst du erklaeren warum|kannst du erklären warum|kannst du zeigen wie)\\b\\s*", + "replacement": "Erklaere ", + "context": "user", + "category": "context", + "minIntensity": "lite" + }, + { + "name": "de_background", + "pattern": "\\b(?:wie du weisst|wie du weißt|wie bereits erwaehnt|wie bereits erwähnt)\\b[,.]?\\s*", + "replacement": "", + "context": "all", + "category": "context", + "minIntensity": "lite" + } + ] +} diff --git a/open-sse/services/compression/rules/de/filler.json b/open-sse/services/compression/rules/de/filler.json new file mode 100644 index 0000000000..f4f2e6eb62 --- /dev/null +++ b/open-sse/services/compression/rules/de/filler.json @@ -0,0 +1,70 @@ +{ + "language": "de", + "category": "filler", + "rules": [ + { + "name": "de_polite_framing", + "pattern": "\\b(?:bitte|kannst du|koenntest du|könntest du|ich moechte|ich möchte|ich brauche)\\b\\s*", + "replacement": "", + "context": "all", + "category": "filler", + "minIntensity": "lite" + }, + { + "name": "de_pleasantries", + "pattern": "\\b(?:hallo|guten morgen|guten tag|danke|vielen dank)\\b[,.!?\\s]*", + "replacement": "", + "context": "all", + "category": "filler", + "minIntensity": "lite" + }, + { + "name": "de_hedging", + "pattern": "\\b(?:ich denke dass|ich glaube dass|es scheint dass|vielleicht|wahrscheinlich|moeglicherweise|möglicherweise)\\b\\s*", + "replacement": "", + "context": "all", + "category": "filler", + "minIntensity": "lite" + }, + { + "name": "de_filler_adverbs", + "pattern": "\\b(?:grundsaetzlich|grundsätzlich|eigentlich|buchstaeblich|buchstäblich|einfach)\\b\\s*", + "replacement": "", + "context": "all", + "category": "filler", + "minIntensity": "lite" + }, + { + "name": "de_self_reference", + "pattern": "^(?:ich versuche|ich will|ich moechte|ich möchte|ich brauche)\\b\\s*", + "replacement": "", + "context": "user", + "category": "filler", + "minIntensity": "lite" + }, + { + "name": "de_verbose_request", + "pattern": "\\b(?:kannst du erklaeren|kannst du erklären|koenntest du erklaeren|könntest du erklären)\\b\\s*", + "replacement": "Erklaere ", + "context": "user", + "category": "filler", + "minIntensity": "lite" + }, + { + "name": "de_qualifiers", + "pattern": "\\b(?:ein bisschen|etwas|irgendwie|mehr oder weniger)\\b\\s*", + "replacement": "", + "context": "all", + "category": "filler", + "minIntensity": "lite" + }, + { + "name": "de_softeners", + "pattern": "\\b(?:wenn moeglich|wenn möglich|wenn du zeit hast|ohne eile)\\b[,.!?\\s]*", + "replacement": "", + "context": "all", + "category": "filler", + "minIntensity": "lite" + } + ] +} diff --git a/open-sse/services/compression/rules/de/structural.json b/open-sse/services/compression/rules/de/structural.json new file mode 100644 index 0000000000..7107c977b3 --- /dev/null +++ b/open-sse/services/compression/rules/de/structural.json @@ -0,0 +1,30 @@ +{ + "language": "de", + "category": "structural", + "rules": [ + { + "name": "de_purpose", + "pattern": "\\b(?:um zu|mit dem ziel|damit ich)\\b\\s*", + "replacement": "um ", + "context": "all", + "category": "structural", + "minIntensity": "lite" + }, + { + "name": "de_connectors", + "pattern": "\\b(?:ausserdem|außerdem|zusaetzlich|zusätzlich|darueber hinaus|darüber hinaus)\\b\\s*", + "replacement": "auch ", + "context": "all", + "category": "structural", + "minIntensity": "lite" + }, + { + "name": "de_emphasis", + "pattern": "\\b(?:sehr|wirklich|extrem|ziemlich)\\s+(?=[a-zäöüß])", + "replacement": "", + "context": "all", + "category": "structural", + "minIntensity": "lite" + } + ] +} diff --git a/open-sse/services/compression/rules/en/context.json b/open-sse/services/compression/rules/en/context.json new file mode 100644 index 0000000000..70ef148408 --- /dev/null +++ b/open-sse/services/compression/rules/en/context.json @@ -0,0 +1,88 @@ +{ + "language": "en", + "category": "context", + "rules": [ + { + "name": "compound_collapse", + "pattern": "\\band any potential\\b", + "replacement": "", + "context": "all", + "category": "context", + "minIntensity": "full" + }, + { + "name": "explanatory_prefix", + "pattern": "\\b(?:The function appears to be handling|The code seems to|The class is|This module is)\\b", + "replacement": "Function:", + "replacementMap": { + "the function appears to be handling": "Function:", + "the code seems to": "Code:", + "the class is": "Class:", + "this module is": "Module:" + }, + "context": "all", + "category": "context", + "minIntensity": "lite" + }, + { + "name": "question_to_directive", + "pattern": "\\b(?:Can you explain why|Could you show me how|Would you tell me|Can you tell me)\\b\\s*", + "replacement": "Explain why ", + "replacementMap": { + "can you explain why": "Explain why ", + "could you show me how": "Show how ", + "would you tell me": "Tell me ", + "can you tell me": "Tell me " + }, + "context": "user", + "category": "context", + "minIntensity": "lite" + }, + { + "name": "context_setup", + "pattern": "\\b(?:I have the following code|Here is my code|Below is the code)\\b\\s*[:.]?\\s*", + "replacement": "Code:", + "context": "user", + "category": "context", + "minIntensity": "lite" + }, + { + "name": "intent_clarification", + "pattern": "\\b(?:What I'm trying to do is|My objective is to|What I need is|I'm aiming to)\\b\\s*", + "replacement": "Goal:", + "context": "user", + "category": "context", + "minIntensity": "lite" + }, + { + "name": "background_removal", + "pattern": "\\b(?:As you may know,?\\s*|As we discussed earlier,?\\s*)", + "replacement": "", + "context": "all", + "category": "context", + "minIntensity": "lite" + }, + { + "name": "meta_commentary", + "pattern": "^(?:Note that|Keep in mind that|Remember that)\\b\\s*", + "replacement": "", + "context": "all", + "category": "context", + "minIntensity": "lite" + }, + { + "name": "purpose_statement", + "pattern": "\\b(?:for the purpose of|with the goal of|in an effort to|for every)\\b", + "replacement": "for", + "replacementMap": { + "for the purpose of": "for", + "with the goal of": "to", + "in an effort to": "to", + "for every": "per" + }, + "context": "all", + "category": "context", + "minIntensity": "lite" + } + ] +} diff --git a/open-sse/services/compression/rules/en/dedup.json b/open-sse/services/compression/rules/en/dedup.json new file mode 100644 index 0000000000..23f0820bfe --- /dev/null +++ b/open-sse/services/compression/rules/en/dedup.json @@ -0,0 +1,38 @@ +{ + "language": "en", + "category": "dedup", + "rules": [ + { + "name": "repeated_context", + "pattern": "\\b(?:As we discussed earlier|As mentioned before|As previously stated|As I said before)\\b[,.]?\\s*", + "replacement": "See above. ", + "context": "all", + "category": "dedup", + "minIntensity": "lite" + }, + { + "name": "repeated_question", + "pattern": "\\b(?:Same question as before|I asked this earlier|This is the same question)\\b[,.]?\\s*", + "replacement": "[same question] ", + "context": "user", + "category": "dedup", + "minIntensity": "lite" + }, + { + "name": "reestablished_context", + "pattern": "\\b(?:Going back to the code above|Referring back to|Returning to)\\b\\s*", + "replacement": "Re: ", + "context": "all", + "category": "dedup", + "minIntensity": "lite" + }, + { + "name": "summary_replacement", + "pattern": "\\b(?:To summarize what we've discussed|In summary of our conversation|To recap)\\b[,.]?\\s*", + "replacement": "Summary: ", + "context": "assistant", + "category": "dedup", + "minIntensity": "lite" + } + ] +} diff --git a/open-sse/services/compression/rules/en/filler.json b/open-sse/services/compression/rules/en/filler.json new file mode 100644 index 0000000000..88e4a6978a --- /dev/null +++ b/open-sse/services/compression/rules/en/filler.json @@ -0,0 +1,129 @@ +{ + "language": "en", + "category": "filler", + "rules": [ + { + "name": "pleasantries", + "pattern": "(?[0], - options?.config?.cavemanConfig - ); + const cavemanConfig = { + ...(options?.config?.cavemanConfig ?? {}), + ...(options?.config?.languageConfig?.enabled + ? { + language: options.config.languageConfig.defaultLanguage, + autoDetectLanguage: options.config.languageConfig.autoDetect, + enabledLanguagePacks: options.config.languageConfig.enabledPacks, + } + : {}), + ...(options?.config?.preserveSystemPrompt !== false + ? { + compressRoles: (options?.config?.cavemanConfig?.compressRoles ?? ["user"]).filter( + (role) => role !== "system" + ), + } + : {}), + }; + return cavemanCompress(body as Parameters[0], cavemanConfig); } if (mode === "aggressive") { const messages = (body.messages ?? []) as Array<{ @@ -82,7 +116,10 @@ export function applyCompression( if (!Array.isArray(messages) || messages.length === 0) { return { body, compressed: false, stats: null }; } - const aggressiveConfig = options?.config?.aggressive; + const aggressiveConfig = { + ...(options?.config?.aggressive ?? {}), + preserveSystemPrompt: options?.config?.preserveSystemPrompt !== false, + }; const result = compressAggressive(messages, aggressiveConfig); const compressedBody = { ...body, messages: result.messages }; return { @@ -107,8 +144,11 @@ export function applyCompression( if (!Array.isArray(messages) || messages.length === 0) { return { body, compressed: false, stats: null }; } - const ultraConfig = options?.config?.ultra; - const result = ultraCompress(messages, ultraConfig ?? {}); + const ultraConfig = { + ...(options?.config?.ultra ?? {}), + preserveSystemPrompt: options?.config?.preserveSystemPrompt !== false, + }; + const result = ultraCompress(messages, ultraConfig); const compressedBody = { ...body, messages: result.messages }; return { body: compressedBody, @@ -125,3 +165,114 @@ export function applyCompression( } return { body, compressed: false, stats: null }; } + +function normalizePipelineStep(step: CompressionPipelineStep | string): CompressionPipelineStep { + if (typeof step !== "string") return step; + if (step === "standard") return { engine: "caveman" }; + if (step === "rtk") return { engine: "rtk" }; + if (step === "lite" || step === "aggressive" || step === "ultra") return { engine: step }; + return { engine: "caveman" }; +} + +export function applyStackedCompression( + body: Record, + pipeline?: Array, + options?: { + model?: string; + supportsVision?: boolean | null; + config?: CompressionConfig; + compressionComboId?: string | null; + } +): CompressionResult { + const steps = + pipeline && pipeline.length > 0 + ? pipeline.map(normalizePipelineStep) + : [ + { engine: "rtk" as const, intensity: "standard" as const }, + { engine: "caveman" as const, intensity: "full" as const }, + ]; + registerBuiltinCompressionEngines(); + + let currentBody = body; + let compressed = false; + const techniques = new Set(); + const rules = new Set(); + const breakdown: NonNullable = []; + const rtkRawOutputPointers: NonNullable = []; + const validationWarnings = new Set(); + const validationErrors = new Set(); + let fallbackApplied = false; + const start = performance.now(); + + for (const step of steps) { + const engine = getCompressionEngine(step.engine); + if (!engine) continue; + const result = engine.apply(currentBody, { + ...options, + compressionComboId: options?.compressionComboId ?? options?.config?.compressionComboId, + stepConfig: { + ...(step.config ?? {}), + ...(step.intensity ? { intensity: step.intensity } : {}), + }, + }); + if (result.stats) { + result.stats.techniquesUsed.forEach((technique) => techniques.add(technique)); + result.stats.rulesApplied?.forEach((rule) => rules.add(rule)); + result.stats.rtkRawOutputPointers?.forEach((pointer) => { + rtkRawOutputPointers.push(pointer); + }); + result.stats.validationWarnings?.forEach((warning) => validationWarnings.add(warning)); + result.stats.validationErrors?.forEach((error) => validationErrors.add(error)); + fallbackApplied = fallbackApplied || result.stats.fallbackApplied === true; + breakdown.push({ + engine: step.engine, + originalTokens: result.stats.originalTokens, + compressedTokens: result.stats.compressedTokens, + savingsPercent: result.stats.savingsPercent, + techniquesUsed: result.stats.techniquesUsed, + ...(result.stats.rulesApplied ? { rulesApplied: result.stats.rulesApplied } : {}), + ...(result.stats.durationMs !== undefined ? { durationMs: result.stats.durationMs } : {}), + }); + } + if (result.compressed) { + currentBody = result.body; + compressed = true; + } + } + + const stats = createCompressionStats( + body, + currentBody, + "stacked", + Array.from(techniques), + rules.size > 0 ? Array.from(rules) : undefined, + Math.round((performance.now() - start) * 100) / 100 + ); + stats.engine = "stacked"; + stats.compressionComboId = + options?.compressionComboId ?? options?.config?.compressionComboId ?? null; + stats.engineBreakdown = breakdown; + if (validationWarnings.size > 0) { + stats.validationWarnings = Array.from(validationWarnings); + } + if (validationErrors.size > 0) { + stats.validationErrors = Array.from(validationErrors); + } + if (fallbackApplied) { + stats.fallbackApplied = true; + } + if (rtkRawOutputPointers.length > 0) { + const seenPointers = new Set(); + stats.rtkRawOutputPointers = rtkRawOutputPointers.filter((pointer) => { + if (seenPointers.has(pointer.id)) return false; + seenPointers.add(pointer.id); + return true; + }); + } + + return { + body: currentBody, + compressed, + stats, + }; +} diff --git a/open-sse/services/compression/types.ts b/open-sse/services/compression/types.ts index f019f62bc6..abe842fb35 100644 --- a/open-sse/services/compression/types.ts +++ b/open-sse/services/compression/types.ts @@ -1,14 +1,26 @@ /** - * Compression Pipeline Types — Phase 1 (Lite) + Phase 2 (Standard/Caveman) + Phase 3 (Aggressive) + Phase 4 (Ultra) + * Compression Pipeline Types — Lite, Caveman, Aggressive, Ultra, RTK, and Stacked modes. * * Shared type definitions for the compression pipeline. * Phase 1: 'off' and 'lite' modes. * Phase 2: 'standard' mode (caveman engine). * Phase 3: 'aggressive' mode (summarization + tool compression + aging). * Phase 4: 'ultra' mode (heuristic token pruning + optional SLM tier). + * Phase 5: 'rtk' and 'stacked' modes (tool-output filters + multi-engine pipeline). */ -export type CompressionMode = "off" | "lite" | "standard" | "aggressive" | "ultra"; +export type CompressionMode = + | "off" + | "lite" + | "standard" + | "aggressive" + | "ultra" + | "rtk" + | "stacked"; +export type CavemanIntensity = "lite" | "full" | "ultra"; +export type RtkIntensity = "minimal" | "standard" | "aggressive"; +export type RtkRawOutputRetention = "never" | "failures" | "always"; +export type CompressionEngineId = "lite" | "caveman" | "aggressive" | "ultra" | "rtk"; export interface CavemanRule { name: string; @@ -16,6 +28,9 @@ export interface CavemanRule { replacement: string | ((match: string, ...groups: string[]) => string); context: "all" | "user" | "system" | "assistant"; preservePatterns?: RegExp[]; + category?: "filler" | "context" | "structural" | "dedup" | "terse" | "ultra"; + description?: string; + minIntensity?: CavemanIntensity; } export interface CavemanConfig { @@ -24,16 +39,63 @@ export interface CavemanConfig { skipRules: string[]; minMessageLength: number; preservePatterns: string[]; + intensity: CavemanIntensity; + language?: string; + autoDetectLanguage?: boolean; + enabledLanguagePacks?: string[]; +} + +export interface CavemanOutputModeConfig { + enabled: boolean; + intensity: CavemanIntensity; + autoClarity: boolean; +} + +export interface RtkConfig { + enabled: boolean; + intensity: RtkIntensity; + applyToToolResults: boolean; + applyToCodeBlocks: boolean; + applyToAssistantMessages: boolean; + enabledFilters: string[]; + disabledFilters: string[]; + maxLinesPerResult: number; + maxCharsPerResult: number; + deduplicateThreshold: number; + customFiltersEnabled: boolean; + trustProjectFilters: boolean; + rawOutputRetention: RtkRawOutputRetention; + rawOutputMaxBytes: number; +} + +export interface CompressionLanguageConfig { + enabled: boolean; + defaultLanguage: string; + autoDetect: boolean; + enabledPacks: string[]; +} + +export interface CompressionPipelineStep { + engine: CompressionEngineId; + intensity?: CavemanIntensity | RtkIntensity; + config?: Record; } export interface CompressionConfig { enabled: boolean; defaultMode: CompressionMode; + autoTriggerMode?: CompressionMode; autoTriggerTokens: number; cacheMinutes: number; preserveSystemPrompt: boolean; + mcpDescriptionCompressionEnabled?: boolean; comboOverrides: Record; + compressionComboId?: string | null; + stackedPipeline?: CompressionPipelineStep[]; cavemanConfig?: CavemanConfig; + cavemanOutputMode?: CavemanOutputModeConfig; + rtkConfig?: RtkConfig; + languageConfig?: CompressionLanguageConfig; aggressive?: AggressiveConfig; ultra?: UltraConfig; } @@ -44,14 +106,42 @@ export interface CompressionStats { savingsPercent: number; techniquesUsed: string[]; mode: CompressionMode; + engine?: string; + compressionComboId?: string | null; timestamp: number; rulesApplied?: string[]; durationMs?: number; + validationWarnings?: string[]; + validationErrors?: string[]; + fallbackApplied?: boolean; + preservedBlockCount?: number; + rtkRawOutputPointers?: Array<{ + id: string; + path: string; + bytes: number; + sha256: string; + redacted: boolean; + }>; + realUsage?: { + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + source: "provider" | "estimated" | "stream"; + }; aggressive?: { summarizerSavings: number; toolResultSavings: number; agingSavings: number; }; + engineBreakdown?: Array<{ + engine: string; + originalTokens: number; + compressedTokens: number; + savingsPercent: number; + techniquesUsed: string[]; + rulesApplied?: string[]; + durationMs?: number; + }>; } export interface CompressionResult { @@ -63,10 +153,17 @@ export interface CompressionResult { export const DEFAULT_COMPRESSION_CONFIG: CompressionConfig = { enabled: false, defaultMode: "off", + autoTriggerMode: "lite", autoTriggerTokens: 0, cacheMinutes: 5, preserveSystemPrompt: true, + mcpDescriptionCompressionEnabled: true, comboOverrides: {}, + compressionComboId: null, + stackedPipeline: [ + { engine: "rtk", intensity: "standard" }, + { engine: "caveman", intensity: "full" }, + ], }; export const DEFAULT_CAVEMAN_CONFIG: CavemanConfig = { @@ -75,6 +172,37 @@ export const DEFAULT_CAVEMAN_CONFIG: CavemanConfig = { skipRules: [], minMessageLength: 50, preservePatterns: [], + intensity: "full", +}; + +export const DEFAULT_CAVEMAN_OUTPUT_MODE_CONFIG: CavemanOutputModeConfig = { + enabled: false, + intensity: "full", + autoClarity: true, +}; + +export const DEFAULT_RTK_CONFIG: RtkConfig = { + enabled: true, + intensity: "standard", + applyToToolResults: true, + applyToCodeBlocks: false, + applyToAssistantMessages: false, + enabledFilters: [], + disabledFilters: [], + maxLinesPerResult: 120, + maxCharsPerResult: 12000, + deduplicateThreshold: 3, + customFiltersEnabled: true, + trustProjectFilters: false, + rawOutputRetention: "never", + rawOutputMaxBytes: 1_048_576, +}; + +export const DEFAULT_COMPRESSION_LANGUAGE_CONFIG: CompressionLanguageConfig = { + enabled: false, + defaultLanguage: "en", + autoDetect: true, + enabledPacks: ["en"], }; /** Aging thresholds for progressive message degradation (Phase 3) */ @@ -101,6 +229,7 @@ export interface AggressiveConfig { summarizerEnabled: boolean; maxTokensPerMessage: number; minSavingsThreshold: number; + preserveSystemPrompt?: boolean; } /** Options for the Summarizer interface (Phase 3) */ @@ -159,6 +288,7 @@ export interface UltraConfig { * 0 = always apply when mode is "ultra". */ maxTokensPerMessage: number; + preserveSystemPrompt?: boolean; } export const DEFAULT_ULTRA_CONFIG: UltraConfig = { diff --git a/open-sse/services/compression/ultra.ts b/open-sse/services/compression/ultra.ts index 0c1dd8d219..1fc3cc0fc2 100644 --- a/open-sse/services/compression/ultra.ts +++ b/open-sse/services/compression/ultra.ts @@ -1,6 +1,7 @@ import { pruneByScore } from "./ultraHeuristic.ts"; import { DEFAULT_ULTRA_CONFIG } from "./types.ts"; import type { UltraConfig, CompressionStats, CompressionMode } from "./types.ts"; +import { extractTextContent, mapTextContent, type ChatMessageLike } from "./messageContent.ts"; const COMPRESSED_PREFIX = "[COMPRESSED:"; @@ -21,26 +22,7 @@ export interface UltraCompressResult { stats: CompressionStats; } -type Message = { role: string; content?: string | unknown[]; [key: string]: unknown }; - -function extractText(content: string | unknown[] | undefined): string { - if (!content) return ""; - if (typeof content === "string") return content; - return (content as Array<{ type?: string; text?: string }>) - .filter((b) => b.type === "text" && b.text) - .map((b) => b.text as string) - .join("\n"); -} - -function applyTextToContent( - original: string | unknown[] | undefined, - compressed: string -): string | unknown[] { - if (!original || typeof original === "string") return compressed; - return (original as Array<{ type?: string; text?: string; [k: string]: unknown }>).map((b) => - b.type === "text" ? { ...b, text: compressed } : b - ); -} +type Message = ChatMessageLike; export function ultraCompress( messages: Message[], @@ -57,21 +39,26 @@ export function ultraCompress( let compressedChars = 0; const compressed = messages.map((msg) => { - const text = extractText(msg.content); + if (effectiveConfig.preserveSystemPrompt !== false && msg.role === "system") return msg; + const text = extractTextContent(msg.content); if (!text) return msg; if (text.startsWith(COMPRESSED_PREFIX)) return msg; if (maxTokensPerMessage > 0 && Math.ceil(text.length / 4) <= maxTokensPerMessage) { return msg; } - originalChars += text.length; - const pruned = pruneByScore(text, compressionRate, minScoreThreshold); - compressedChars += pruned.length; - - return { - ...msg, - content: applyTextToContent(msg.content, pruned), - }; + let messageOriginalChars = 0; + let messageCompressedChars = 0; + const next = mapTextContent(msg, (textPart) => { + if (!textPart || textPart.startsWith(COMPRESSED_PREFIX)) return textPart; + messageOriginalChars += textPart.length; + const pruned = pruneByScore(textPart, compressionRate, minScoreThreshold); + messageCompressedChars += pruned.length; + return pruned; + }) as Message; + originalChars += messageOriginalChars; + compressedChars += messageCompressedChars; + return next; }); const originalTokens = Math.ceil(originalChars / 4); diff --git a/open-sse/services/compression/validation.ts b/open-sse/services/compression/validation.ts new file mode 100644 index 0000000000..7f2ae31115 --- /dev/null +++ b/open-sse/services/compression/validation.ts @@ -0,0 +1,132 @@ +import { findFencedCodeBlocks } from "./preservation.ts"; + +export interface ValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; + fallbackApplied: boolean; +} + +function collectMatches(text: string, pattern: RegExp): string[] { + const matches: string[] = []; + pattern.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = pattern.exec(text)) !== null) { + if (match[0]) matches.push(match[0]); + if (match.index === pattern.lastIndex) pattern.lastIndex++; + } + return matches; +} + +function requireExactPresence( + label: string, + originalItems: string[], + compressed: string, + errors: string[] +) { + for (const item of originalItems) { + if (!compressed.includes(item)) { + const preview = item.replace(/\s+/g, " ").slice(0, 80); + errors.push(`${label} changed or missing: ${preview}`); + } + } +} + +export function validateCompression(original: string, compressed: string): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + if (typeof original !== "string" || typeof compressed !== "string") { + return { + valid: false, + errors: ["validation received non-string input"], + warnings, + fallbackApplied: true, + }; + } + + if (original.length > 0 && compressed.trim().length === 0) { + errors.push("compressed text is empty"); + } + + requireExactPresence("fenced code block", findFencedCodeBlocks(original), compressed, errors); + requireExactPresence("inline code", collectMatches(original, /`[^`\n]+`/g), compressed, errors); + requireExactPresence( + "URL", + collectMatches(original, /\bhttps?:\/\/[^\s)\]"'>]+/gi), + compressed, + errors + ); + requireExactPresence( + "markdown link", + collectMatches(original, /\[[^\]\n]{1,1000}\]\([^)[ \t\n]{1,2000}(?:[ \t]+"[^"]{0,1000}")?\)/g), + compressed, + errors + ); + requireExactPresence("frontmatter", collectFrontmatter(original), compressed, errors); + requireExactPresence("heading", collectMatches(original, /^#{1,6}\s+.+$/gm), compressed, errors); + requireExactPresence( + "table row", + collectMatches(original, /^[ \t]*\|(?:[^|\n]{0,1000}\|){1,100}[ \t]*$/gm), + compressed, + errors + ); + requireExactPresence( + "math block", + collectMatches(original, /\$\$[\s\S]{0,10000}?\$\$/g), + compressed, + errors + ); + requireExactPresence( + "inline math", + collectMatches(original, /(? ${compressedFenceCount}` + ); + } + + if (compressed.length > original.length) { + warnings.push("compressed text is longer than original"); + } + + return { + valid: errors.length === 0, + errors, + warnings, + fallbackApplied: errors.length > 0, + }; +} + +function collectFrontmatter(text: string): string[] { + if (!text.startsWith("---\n")) return []; + const close = text.indexOf("\n---", 4); + if (close === -1) return []; + const closeEnd = text.indexOf("\n", close + 4); + const end = closeEnd === -1 ? text.length : closeEnd + 1; + return [text.slice(0, end)]; +} diff --git a/open-sse/services/responsesInputSanitizer.ts b/open-sse/services/responsesInputSanitizer.ts new file mode 100644 index 0000000000..31a540feb8 --- /dev/null +++ b/open-sse/services/responsesInputSanitizer.ts @@ -0,0 +1,36 @@ +type JsonRecord = Record; + +function toRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; +} + +function isResponsesMessageItem(record: JsonRecord): boolean { + return record.type === "message" || (!record.type && typeof record.role === "string"); +} + +export function isInternalAssistantMessage(record: JsonRecord): boolean { + if (!isResponsesMessageItem(record)) return false; + if (record.role !== "assistant") return false; + + const phase = typeof record.phase === "string" ? record.phase.trim().toLowerCase() : ""; + if (!phase) return false; + + // OpenCode can send assistant-side commentary/analysis frames in Responses + // shape. Those frames are local runtime state, not durable conversation turns. + return phase !== "final"; +} + +export function sanitizeResponsesInputItems(items: readonly unknown[], clone = true): unknown[] { + const sanitized: unknown[] = []; + + for (const item of items) { + const record = toRecord(item); + if (record && isInternalAssistantMessage(record)) { + continue; + } + + sanitized.push(clone ? structuredClone(item) : item); + } + + return sanitized; +} diff --git a/open-sse/services/responsesToolCallState.ts b/open-sse/services/responsesToolCallState.ts index 5160f02ec2..9ded2ecefe 100644 --- a/open-sse/services/responsesToolCallState.ts +++ b/open-sse/services/responsesToolCallState.ts @@ -1,3 +1,5 @@ +import { sanitizeResponsesInputItems } from "./responsesInputSanitizer.ts"; + type JsonRecord = Record; type RememberedFunctionCall = { @@ -28,36 +30,8 @@ function toRecord(value: unknown): JsonRecord | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; } -function isResponsesMessageItem(record: JsonRecord): boolean { - return record.type === "message" || (!record.type && typeof record.role === "string"); -} - -function isInternalAssistantMessage(record: JsonRecord): boolean { - if (!isResponsesMessageItem(record)) return false; - if (record.role !== "assistant") return false; - - const phase = typeof record.phase === "string" ? record.phase.trim().toLowerCase() : ""; - if (!phase) return false; - - // OpenCode can send assistant-side commentary/analysis frames in Responses - // shape. Those frames are local runtime state, not durable conversation turns - // for Codex replay. Re-injecting them makes the model continue hidden notes. - return phase !== "final"; -} - function sanitizeRememberedConversationItems(items: readonly unknown[]): unknown[] { - const sanitized: unknown[] = []; - - for (const item of items) { - const record = toRecord(item); - if (record && isInternalAssistantMessage(record)) { - continue; - } - - sanitized.push(structuredClone(item)); - } - - return sanitized; + return sanitizeResponsesInputItems(items); } function cleanupRememberedResponseToolCalls(now: number = Date.now()) { diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 0b9edc7bfc..5001730875 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -13,6 +13,11 @@ const CACHE_SECRET = "omniroute-token-cache"; // Key: "provider:sha256(refreshToken)" → Value: Promise const refreshPromiseCache = new Map(); +// Per-connection mutex: prevents parallel OAuth refresh for rotating tokens. +// Key: connectionId → Value: { promise, waiters } +// Primary dedup when credentials.connectionId is present; refreshPromiseCache is fallback. +const connectionRefreshMutex = new Map(); + type RefreshLogger = { info?: (tag: string, message: string, data?: Record) => void; warn?: (tag: string, message: string, data?: Record) => void; @@ -415,8 +420,8 @@ export async function refreshQwenToken(refreshToken, log, proxyConfig: unknown = /** * Specialized refresh for Codex (OpenAI) OAuth tokens. * OpenAI uses rotating (one-time-use) refresh tokens. - * Returns { error: 'refresh_token_reused' } when the token has already been consumed, - * so callers can stop retrying and request re-authentication. + * Returns { error: 'unrecoverable_refresh_error', code } when the token has already been + * consumed or is invalid, so callers can stop retrying and request re-authentication. */ export async function refreshCodexToken(refreshToken, log, proxyConfig: unknown = null) { try { @@ -831,9 +836,18 @@ export function isUnrecoverableRefreshError(result) { /** * Get access token for a specific provider (with deduplication). - * If a refresh is already in-flight for the same provider+token, - * subsequent calls share the existing promise instead of making - * parallel OAuth requests. + * + * Deduplication strategy (two layers): + * 1. Per-connection mutex (primary): if credentials.connectionId is present, all concurrent + * callers for that connection share one in-flight promise regardless of which token they + * loaded. This prevents refresh_token_reused errors with rotating (one-time-use) tokens, + * e.g. Codex/OpenAI, where callers that loaded credentials at different times may hold + * different token strings but refer to the same connection. + * 2. Token-hash fallback: if no connectionId, dedup by provider+sha256(refreshToken) as before. + * + * Additionally, when connectionId is present, the stale-token check reads the DB to detect + * whether another process already refreshed the token. If the DB token is still valid it is + * returned immediately without a new upstream call. */ export async function getAccessToken(provider, credentials, log, proxyConfig: unknown = null) { if (!credentials || !credentials.refreshToken || typeof credentials.refreshToken !== "string") { @@ -841,6 +855,57 @@ export async function getAccessToken(provider, credentials, log, proxyConfig: un return null; } + const connectionId = credentials.connectionId; + + // ── Layer 1: per-connection mutex ────────────────────────────────────────── + if (connectionId && typeof connectionId === "string") { + const existing = connectionRefreshMutex.get(connectionId); + if (existing) { + existing.waiters++; + log?.info?.("TOKEN_REFRESH", "Concurrent refresh detected — sharing in-flight refresh", { + provider, + connectionId, + waiters: existing.waiters, + }); + return existing.promise; + } + + const entry = { promise: null, waiters: 0 }; + entry.promise = _getAccessTokenWithStalenessCheck( + provider, + credentials, + log, + proxyConfig + ).finally(() => { + connectionRefreshMutex.delete(connectionId); + }); + connectionRefreshMutex.set(connectionId, entry); + return entry.promise; + } + + // ── Layer 2: token-hash fallback (no connectionId) ───────────────────────── + const cacheKey = getRefreshCacheKey(provider, credentials.refreshToken); + + if (refreshPromiseCache.has(cacheKey)) { + log?.info?.("TOKEN_REFRESH", `Reusing in-flight refresh for ${provider}`); + return refreshPromiseCache.get(cacheKey); + } + + const refreshPromise = _getAccessTokenInternal(provider, credentials, log, proxyConfig).finally( + () => { + refreshPromiseCache.delete(cacheKey); + } + ); + + refreshPromiseCache.set(cacheKey, refreshPromise); + return refreshPromise; +} + +/** + * Internal helper: performs the DB staleness check then calls the actual refresh. + * Only called from the per-connection mutex path (Layer 1 above). + */ +async function _getAccessTokenWithStalenessCheck(provider, credentials, log, proxyConfig) { // RACE CONDITION PREVENTION: // If the credentials object in memory is stale (e.g. it waited in a semaphore while another // request refreshed the token), using its OLD refreshToken will cause the provider (e.g. OpenAI) @@ -886,23 +951,7 @@ export async function getAccessToken(provider, credentials, log, proxyConfig: un } } - const cacheKey = getRefreshCacheKey(provider, credentials.refreshToken); - - // If a refresh is already in-flight, reuse it - if (refreshPromiseCache.has(cacheKey)) { - log?.info?.("TOKEN_REFRESH", `Reusing in-flight refresh for ${provider}`); - return refreshPromiseCache.get(cacheKey); - } - - // Start a new refresh and cache the promise - const refreshPromise = _getAccessTokenInternal(provider, credentials, log, proxyConfig).finally( - () => { - refreshPromiseCache.delete(cacheKey); - } - ); - - refreshPromiseCache.set(cacheKey, refreshPromise); - return refreshPromise; + return _getAccessTokenInternal(provider, credentials, log, proxyConfig); } /** @@ -1036,6 +1085,18 @@ export function isProviderBlocked(provider: string): boolean { return false; } +/** + * Get active per-connection mutex entries (for diagnostics/metrics). + * Returns a snapshot of connections that have an in-flight refresh and their waiter count. + */ +export function getConnectionRefreshMutexStatus(): Record { + const result: Record = {}; + for (const [connectionId, entry] of connectionRefreshMutex.entries()) { + result[connectionId] = { waiters: entry.waiters }; + } + return result; +} + /** * Get circuit breaker status for all providers (for diagnostics). */ diff --git a/open-sse/translator/request/claude-to-openai.ts b/open-sse/translator/request/claude-to-openai.ts index 8f890fd31c..fdf6687512 100644 --- a/open-sse/translator/request/claude-to-openai.ts +++ b/open-sse/translator/request/claude-to-openai.ts @@ -5,6 +5,21 @@ import { adjustMaxTokens } from "../helpers/maxTokensHelper.ts"; type JsonRecord = Record; const TOOL_CHOICE_ANY = ["a", "n", "y"].join(""); +/** + * Normalize tool input schema for OpenAI compatibility. + * OpenAI strict mode requires `properties: {}` on object-type schemas, + * even for zero-argument tools. Anthropic/MCP tools may omit it (#1898). + */ +function normalizeToolSchema(schema: unknown): Record { + const fallback = { type: "object", properties: {} }; + if (!schema || typeof schema !== "object" || Array.isArray(schema)) return fallback; + const s = schema as Record; + if (s.type === "object" && !s.properties) { + return { ...s, properties: {} }; + } + return s; +} + function normalizeOpenAIReasoningEffort(effort: unknown): string | undefined { if (typeof effort !== "string") return undefined; const normalized = effort.toLowerCase(); @@ -86,7 +101,7 @@ export function claudeToOpenAIRequest(model, body, stream) { function: { name, description: typeof tool.description === "string" ? tool.description : "", // fix: never null (#276) - parameters: tool.input_schema || { type: "object", properties: {} }, + parameters: normalizeToolSchema(tool.input_schema), }, }; }) diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 77289dfb71..07ff889385 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -827,6 +827,9 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { if (eventType === "response.reasoning_summary_text.delta") { const reasoningDelta = data.delta || ""; if (!reasoningDelta) return null; + const reasoningDeltaShape = state.copilotCompatibleReasoning + ? { reasoning_text: reasoningDelta } + : { reasoning: { summary: reasoningDelta } }; return { id: state.chatId, object: "chat.completion.chunk", @@ -835,7 +838,7 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { choices: [ { index: 0, - delta: { reasoning: { summary: reasoningDelta } }, + delta: reasoningDeltaShape, finish_reason: null, }, ], diff --git a/open-sse/utils/aiSdkCompat.ts b/open-sse/utils/aiSdkCompat.ts index 91d284bfc1..58ff08b738 100644 --- a/open-sse/utils/aiSdkCompat.ts +++ b/open-sse/utils/aiSdkCompat.ts @@ -22,7 +22,9 @@ export function resolveStreamFlag(bodyStream: unknown, acceptHeader: unknown): b // Explicit body value always wins if (bodyStream === true) return true; if (bodyStream === false) return false; - // No explicit stream param — fall back to Accept header heuristic + + // No explicit stream param — preserve OmniRoute's streaming default unless + // the client explicitly asks for JSON and does not also accept SSE. return !clientWantsJsonResponse(acceptHeader); } diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 111fd1095d..b75acecf51 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -134,6 +134,7 @@ type StreamOptions = { targetFormat?: string; sourceFormat?: string; clientResponseFormat?: string | null; + copilotCompatibleReasoning?: boolean; provider?: string | null; reqLogger?: StreamLogger | null; toolNameMap?: unknown; @@ -150,6 +151,7 @@ type TranslateState = ReturnType & { toolNameMap?: unknown; usage?: unknown; finishReason?: unknown; + copilotCompatibleReasoning?: boolean; /** Accumulated message content for call log response body */ accumulatedContent?: string; upstreamError?: { @@ -531,6 +533,7 @@ export function createSSEStream(options: StreamOptions = {}) { targetFormat, sourceFormat, clientResponseFormat = null, + copilotCompatibleReasoning = false, provider = null, reqLogger = null, toolNameMap = null, @@ -563,6 +566,7 @@ export function createSSEStream(options: StreamOptions = {}) { ...(initState(sourceFormat) as TranslateState), provider, toolNameMap, + copilotCompatibleReasoning, accumulatedContent: "", } : null; @@ -1904,7 +1908,8 @@ export function createSSETransformStreamWithLogger( body: unknown = null, onComplete: ((payload: StreamCompletePayload) => void) | null = null, apiKeyInfo: unknown = null, - onFailure: ((payload: StreamFailurePayload) => void | Promise) | null = null + onFailure: ((payload: StreamFailurePayload) => void | Promise) | null = null, + copilotCompatibleReasoning = false ) { return createSSEStream({ mode: STREAM_MODE.TRANSLATE, @@ -1919,6 +1924,7 @@ export function createSSETransformStreamWithLogger( body, onComplete, onFailure, + copilotCompatibleReasoning, }); } diff --git a/open-sse/utils/streamHelpers.ts b/open-sse/utils/streamHelpers.ts index 7221cd1035..2146f8cccd 100644 --- a/open-sse/utils/streamHelpers.ts +++ b/open-sse/utils/streamHelpers.ts @@ -45,6 +45,7 @@ export function hasValuableContent(chunk, format) { if (typeof delta.content === "string" && delta.content.length > 0) return true; if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) return true; + if (typeof delta.reasoning_text === "string" && delta.reasoning_text.length > 0) return true; if (Array.isArray(delta.tool_calls) && delta.tool_calls.length > 0) return true; if (chunk.choices[0].finish_reason) return true; if (typeof delta.role === "string" && delta.role.length > 0) return true; diff --git a/package-lock.json b/package-lock.json index 0fc3bcf712..8753eccd38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.7.8", + "version": "3.7.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.7.8", + "version": "3.7.9", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -79,6 +79,7 @@ "husky": "^9.1.7", "jsdom": "^29.0.1", "lint-staged": "^16.2.7", + "node-loader": "^2.1.0", "prettier": "^3.8.1", "tailwindcss": "^4", "typescript": "^6.0.2", @@ -5569,6 +5570,16 @@ "require-from-string": "^2.0.2" } }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -6773,6 +6784,16 @@ "dev": true, "license": "MIT" }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -10080,6 +10101,21 @@ "dev": true, "license": "MIT" }, + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -11260,6 +11296,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/node-loader": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/node-loader/-/node-loader-2.1.0.tgz", + "integrity": "sha512-OwjPkyh8+7jW8DMd/iq71uU1Sspufr/C2+c3t0p08J3CrM9ApZ4U53xuisNrDXOHyGi5OYHgtfmmh+aK9zJA6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.3" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, "node_modules/node-machine-id": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/node-machine-id/-/node-machine-id-1.1.12.tgz", @@ -15206,7 +15262,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.7.8" + "version": "3.7.9" } } } diff --git a/package.json b/package.json index a971888c4a..12d4fc61f0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "omniroute", - "version": "3.7.8", - "description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.", + "version": "3.7.9", + "description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { "omniroute": "bin/omniroute.mjs", @@ -174,6 +174,7 @@ "husky": "^9.1.7", "jsdom": "^29.0.1", "lint-staged": "^16.2.7", + "node-loader": "^2.1.0", "prettier": "^3.8.1", "tailwindcss": "^4", "typescript": "^6.0.2", diff --git a/pr_metadata.json b/pr_metadata.json new file mode 100644 index 0000000000..37ad0c69a5 --- /dev/null +++ b/pr_metadata.json @@ -0,0 +1,2102 @@ +[ + { + "additions": 149, + "author": { + "id": "MDQ6VXNlcjM1NzM4Ng==", + "is_bot": false, + "login": "andrewmunsell", + "name": "Andrew Munsell" + }, + "baseRefName": "main", + "body": "## Summary\r\n\r\n- Add tokens_compressed INTEGER column to call_logs (migration 041)\r\n- Capture compression savings from both legacy compressContext() and modular applyCompression() pipelines in chatCore.ts\r\n- Display inline ↓N purple badge in log list view tokens cell\r\n- Show \"Compressed: FROM → TO (-X%)\" badge in detail modal Input section\r\n- Bump CallLogArtifact schemaVersion from 4 to 5\r\n\r\n\"CleanShot\r\n\"CleanShot\r\n\r\n## Tests Added Or Updated\r\n\r\n- Add compression tokens unit tests\r\n", + "createdAt": "2026-05-04T03:48:16Z", + "deletions": 7, + "files": [ + { + "path": "open-sse/handlers/chatCore.ts", + "additions": 7, + "deletions": 0, + "changeType": "MODIFIED" + }, + { + "path": "src/i18n/messages/en.json", + "additions": 1, + "deletions": 0, + "changeType": "MODIFIED" + }, + { + "path": "src/lib/db/core.ts", + "additions": 4, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "src/lib/db/migrationRunner.ts", + "additions": 2, + "deletions": 0, + "changeType": "MODIFIED" + }, + { + "path": "src/lib/db/migrations/041_compression_tokens.sql", + "additions": 4, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "src/lib/usage/callLogArtifacts.ts", + "additions": 2, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "src/lib/usage/callLogs.ts", + "additions": 8, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "src/lib/usage/migrations.ts", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "src/shared/components/RequestLoggerDetail.tsx", + "additions": 10, + "deletions": 0, + "changeType": "MODIFIED" + }, + { + "path": "src/shared/components/RequestLoggerV2.tsx", + "additions": 11, + "deletions": 0, + "changeType": "MODIFIED" + }, + { + "path": "tests/unit/call-log-cap.test.ts", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "tests/unit/compression-tokens.test.ts", + "additions": 98, + "deletions": 0, + "changeType": "ADDED" + } + ], + "headRefName": "emdash/indicate-compression-in-logs-use9w", + "number": 1923, + "title": "feat(logs): show compression tokens in request log UI" + }, + { + "additions": 1698, + "author": { + "id": "MDQ6VXNlcjE0OTIxOTgz", + "is_bot": false, + "login": "oyi77", + "name": "Paijo" + }, + "baseRefName": "release/v3.7.9", + "body": "Adds automated model assessment, categorization, and self-healing system for omniroute combos.\n\n## The Problem\nWhen providers fail (rate limits, auth errors, model deprecation), combos silently degrade. Our production experience: 2+ hours manually testing models and updating 44 combos because most returned errors.\n\n## The Solution\nThree new domain modules:\n1. **Assessor** - Probes every provider/model pair (quick/standard/deep tiers)\n2. **Categorizer** - Classifies models by capability with fitness scores\n3. **SelfHealer** - Auto-fixes combos: removes dead models, reduces weights for rate-limited, emergency-adds working alternatives\n\n## New Files\n- src/domain/assessment/assessor.ts\n- src/domain/assessment/categorizer.ts\n- src/domain/assessment/selfHealer.ts\n- src/domain/assessment/types.ts\n- src/domain/assessment/migration.ts\n- src/domain/assessment/index.ts\n- src/app/api/assess/route.ts\n- docs/RFC-AUTO-ASSESSMENT.md (full RFC)\n\n## New DB Tables\n- model_assessments, assessment_runs, combo_health, heal_actions\n\n## API\nPOST /api/assess/models - Run assessment\nGET /api/assess/models?action=working - Get working models\nGET /api/assess/models?action=combo-health - Get combo health\n\n> [!NOTE]\n> **OmniRoute Maintainer Note:** This PR has been deferred and will be accepted and redirected for the `v4.0.0` (or greater) release cycle. Please skip this PR during `v3.x` stabilization reviews.", + "createdAt": "2026-05-03T20:53:04Z", + "deletions": 0, + "files": [ + { + "path": "docs/RFC-AUTO-ASSESSMENT.md", + "additions": 518, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "src/app/api/assess/route.ts", + "additions": 110, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "src/domain/assessment/assessor.ts", + "additions": 206, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "src/domain/assessment/categorizer.ts", + "additions": 118, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "src/domain/assessment/index.ts", + "additions": 24, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "src/domain/assessment/migration.ts", + "additions": 109, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "src/domain/assessment/selfHealer.ts", + "additions": 261, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "src/domain/assessment/types.ts", + "additions": 352, + "deletions": 0, + "changeType": "ADDED" + } + ], + "headRefName": "feat/auto-assessment-engine", + "number": 1918, + "title": "feat: Auto-Assessment and Self-Healing Combo Engine" + }, + { + "additions": 22666, + "author": { + "id": "MDQ6VXNlcjgwMTY4NDE=", + "is_bot": false, + "login": "diegosouzapw", + "name": "Diego Rodrigues de Sa e Souza" + }, + "baseRefName": "main", + "body": "### ✨ New Features\n\n- **feat(compression):** major upgrade to Caveman and RTK compression pipelines (#1876, #1889):\n - Add RTK tool-output compression, stacked Caveman + RTK pipelines, compression combo assignments, dashboard context pages, MCP management tools, and language-aware Caveman rule packs.\n - Expand RTK parity with a 39-filter catalog, RTK-style JSON DSL stages, inline verify/benchmark coverage, trust-gated custom filters, expanded command detection, and redacted raw-output recovery.\n - Expose rule intensities, track USD savings, unify config validation, and persist MCP savings.\n - Expand Caveman parity and MCP metadata compression.\n- **feat(provider):** update Jina AI model catalog to support Embeddings and Rerank natively (#1874 — thanks @backryun)\n- **feat(provider):** add NanoGPT image generation provider (#1899 — thanks @Aculeasis)\n- **feat(ui):** move proxy configuration to dedicated System → Proxy page (#1907 — thanks @oyi77)\n- **feat(ui):** add K/M/B/T cost shortener utility (#1902 — thanks @oyi77)\n- **feat(providers):** implement bulk paste for extra API keys (#1916 — thanks @0xtbug)\n- **feat(analytics):** usage history API key backfill + dark mode pricing (#1896 — thanks @Gi99lin)\n\n### 🐛 Bug Fixes\n\n- **fix(providers):** allow local OpenAI-compatible endpoints (like Ollama) to be added without an API key (fixes #1893)\n- **fix(copilot):** emit compatible reasoning text deltas (#1919 — thanks @ivan-mezentsev)\n- **fix(api-manager):** show validation errors inline in modals, not behind (#1920 — thanks @andrewmunsell)\n- **fix(compression):** align seeded standard savings combo with stacked default, preserve stacked defaults, and secure metadata routes.\n- **fix(gemini-cli):** separate Cloud Code transport from Antigravity (#1869 — thanks @dhaern)\n- **fix(codex):** map prompt field to input array for Cursor compatibility (fixes #1872)\n- **fix(core):** align stream parameter default to false per strict OpenAI spec (fixes #1873)\n- **fix(ui):** restore Next.js CSP `unsafe-eval` in production `script-src` to fix unresponsive Onboarding button (fixes #1883)\n- **fix(proxy):** globally strip `prompt_cache_retention` in `BaseExecutor` to prevent upstream 400 errors from strict endpoints like droid/gemini-2-pro (fixes #1884)\n- **fix(ui):** include `isOpen` dependency in `EditConnectionModal` state sync to ensure `maxConcurrent` is properly hydrated when reopening the modal (fixes #1859)\n- **fix(security):** remediate 4 polynomial-redos CodeQL alerts in compression regexes by bounding repetitions and removing overlapping quantifiers\n- **fix(codex):** flatten Chat Completions tool format to Codex Responses format in `normalizeCodexTools` — prevents `Missing required parameter: tools[0].name` upstream errors (#1914 — thanks @tranduykhanh030)\n- **fix(proxy):** add proxy-aware execution context to image generation route — proxy settings are now correctly applied for image providers behind restricted networks (#1904 — thanks @Aculeasis)\n- **fix(translator):** inject `properties: {}` into zero-argument MCP tool schemas during Anthropic→OpenAI translation — prevents 400 errors from OpenAI strict schema validation (#1898 — thanks @bryceIT)\n- **fix(codex):** sanitize raw responses input (#1895 — thanks @dhaern)\n- **fix(combos):** align strategy contracts (#1892 — thanks @dhaern)\n- **fix(combos):** fix combo provider breaker profile handling (#1891 — thanks @rdself)\n- **fix(migrations):** duplicate-column no-op fix (#1886 — thanks @smartenok-ops)\n- **fix(auth):** per-connection OAuth refresh mutex (#1885 — thanks @smartenok-ops)\n- **fix(auth):** require dashboard management auth for compression preview\n\n### 📝 Documentation\n\n- **docs(compression):** document RTK+Caveman stacked savings ranges\n\n### 🏆 Release Attribution & Retroactive Credits\n\n- **@payne0420** (PR #1828 / #1839) — Implementation of the **Rate Limit Watchdog** and environment overrides. (This feature was manually backported to v3.7.8, causing the automatic GitHub Release notes to omit the author's credit).\n", + "createdAt": "2026-05-03T20:35:53Z", + "deletions": 1470, + "files": [ + { + "path": ".agents/workflows/deploy-vps-akamai.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": ".agents/workflows/deploy-vps-both.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": ".agents/workflows/deploy-vps-local.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": ".agents/workflows/generate-release.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": ".agents/workflows/version-bump.md", + "additions": 9, + "deletions": 9, + "changeType": "MODIFIED" + }, + { + "path": "AGENTS.md", + "additions": 25, + "deletions": 9, + "changeType": "MODIFIED" + }, + { + "path": "CHANGELOG.md", + "additions": 47, + "deletions": 0, + "changeType": "MODIFIED" + }, + { + "path": "README.md", + "additions": 71, + "deletions": 29, + "changeType": "MODIFIED" + }, + { + "path": "docs/API_REFERENCE.md", + "additions": 32, + "deletions": 13, + "changeType": "MODIFIED" + }, + { + "path": "docs/ARCHITECTURE.md", + "additions": 16, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/COMPRESSION_ENGINES.md", + "additions": 143, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "docs/COMPRESSION_GUIDE.md", + "additions": 105, + "deletions": 12, + "changeType": "MODIFIED" + }, + { + "path": "docs/COMPRESSION_LANGUAGE_PACKS.md", + "additions": 96, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "docs/COMPRESSION_RULES_FORMAT.md", + "additions": 186, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "docs/ENVIRONMENT.md", + "additions": 6, + "deletions": 0, + "changeType": "MODIFIED" + }, + { + "path": "docs/FEATURES.md", + "additions": 16, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/MCP-SERVER.md", + "additions": 55, + "deletions": 19, + "changeType": "MODIFIED" + }, + { + "path": "docs/RTK_COMPRESSION.md", + "additions": 234, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "docs/SETUP_GUIDE.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/openapi.yaml", + "additions": 199, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "electron/package-lock.json", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "electron/package.json", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "llm.txt", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "next.config.mjs", + "additions": 8, + "deletions": 95, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/config/embeddingRegistry.ts", + "additions": 62, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/config/imageRegistry.ts", + "additions": 14, + "deletions": 0, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/config/rerankRegistry.ts", + "additions": 44, + "deletions": 8, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/executors/base.ts", + "additions": 3, + "deletions": 0, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/executors/codex.ts", + "additions": 75, + "deletions": 5, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/handlers/chatCore.ts", + "additions": 338, + "deletions": 68, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/handlers/imageGeneration.ts", + "additions": 1, + "deletions": 0, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/handlers/responseSanitizer.ts", + "additions": 3, + "deletions": 0, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/mcp-server/README.md", + "additions": 17, + "deletions": 9, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/mcp-server/descriptionCompressor.ts", + "additions": 243, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/mcp-server/schemas/tools.ts", + "additions": 121, + "deletions": 32, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/mcp-server/server.ts", + "additions": 41, + "deletions": 0, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/mcp-server/tools/advancedTools.ts", + "additions": 15, + "deletions": 16, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/mcp-server/tools/compressionTools.ts", + "additions": 157, + "deletions": 11, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/package.json", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/services/AGENTS.md", + "additions": 5, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/services/accountFallback.ts", + "additions": 21, + "deletions": 5, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/services/autoCombo/index.ts", + "additions": 1, + "deletions": 10, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/services/combo.ts", + "additions": 56, + "deletions": 7, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/services/compression/aggressive.ts", + "additions": 19, + "deletions": 29, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/services/compression/caveman.ts", + "additions": 310, + "deletions": 31, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/services/compression/cavemanRules.ts", + "additions": 189, + "deletions": 15, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/services/compression/diffHelper.ts", + "additions": 117, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/cavemanAdapter.ts", + "additions": 408, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/index.ts", + "additions": 14, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/registry.ts", + "additions": 87, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/codeStripper.ts", + "additions": 128, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/commandDetector.ts", + "additions": 465, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/deduplicator.ts", + "additions": 37, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filterLoader.ts", + "additions": 220, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filterSchema.ts", + "additions": 182, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/aws.json", + "additions": 34, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/biome.json", + "additions": 35, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/build-eslint.json", + "additions": 37, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/build-typescript.json", + "additions": 33, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/build-vite.json", + "additions": 41, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/build-webpack.json", + "additions": 34, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/bundle-install.json", + "additions": 35, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/curl.json", + "additions": 34, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/df.json", + "additions": 34, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/docker-logs.json", + "additions": 42, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/docker-ps.json", + "additions": 33, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/du.json", + "additions": 33, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/error-stacktrace.json", + "additions": 41, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/gcloud.json", + "additions": 34, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/generic-output.json", + "additions": 32, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/git-branch.json", + "additions": 45, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/git-diff.json", + "additions": 33, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/git-log.json", + "additions": 33, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/git-status.json", + "additions": 40, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/golangci-lint.json", + "additions": 34, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/json-output.json", + "additions": 40, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/make.json", + "additions": 38, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/mypy.json", + "additions": 34, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/npm-audit.json", + "additions": 43, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/npm-install.json", + "additions": 41, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/nx.json", + "additions": 34, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/pip.json", + "additions": 41, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/playwright.json", + "additions": 42, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/poetry-install.json", + "additions": 35, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/prettier.json", + "additions": 40, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/ps.json", + "additions": 34, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/rsync.json", + "additions": 33, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/rubocop.json", + "additions": 34, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/ruff.json", + "additions": 40, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/shell-find.json", + "additions": 33, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/shell-grep.json", + "additions": 37, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/shell-ls.json", + "additions": 33, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/ssh.json", + "additions": 43, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/systemctl-status.json", + "additions": 43, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/terraform-plan.json", + "additions": 34, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/test-cargo.json", + "additions": 42, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/test-go.json", + "additions": 41, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/test-jest.json", + "additions": 43, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/test-pytest.json", + "additions": 41, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/test-vitest.json", + "additions": 43, + "deletions": 0, + "changeType": "ADDED" + } + ], + "headRefName": "release/v3.7.9", + "number": 1917, + "title": "Release v3.7.9" + }, + { + "additions": 788, + "author": { + "id": "MDQ6VXNlcjI0MTk4NDIy", + "is_bot": false, + "login": "backryun", + "name": "backryun" + }, + "baseRefName": "release/v3.7.9", + "body": "## Summary\r\n\r\ncompletely removing support for Node.js 20.\r\nNode.js 20 has reached EOL.\r\n\"image\"\r\n\r\n## Validation\r\n\r\n- [x] `npm run lint`\r\n- [x] `npm run test:unit`\r\n- [ ] `npm run test:coverage`\r\n- [ ] Coverage is still `>= 60%` for statements, lines, functions, and branches\r\n\r\n## Tests Added Or Updated\r\n\r\nUpdated automated test files:\r\n\r\n- `tests/unit/node-runtime-support.test.ts`\r\n- `tests/unit/chatcore-sanitization.test.ts`\r\n- `tests/unit/codex-stream-false.test.ts`\r\n- `tests/unit/t26-ai-sdk-accept-header-compat.test.ts`\r\n- `tests/integration/chat-pipeline.test.ts`\r\n- `tests/e2e/protocol-clients.test.ts`\r\n\r\nProduction code changed: Node runtime support logic was updated in `src/shared/utils/nodeRuntimeSupport.ts` and `bin/nodeRuntimeSupport.mjs`.\r\n\r\n## Coverage Notes\r\n\r\nThis PR changes `src/` and `bin/`.\r\n\r\nCoverage for the runtime support change is primarily provided by:\r\n\r\n- `tests/unit/node-runtime-support.test.ts`\r\n- `npm run check:node-runtime`\r\n\r\nAdditional regression coverage was run through:\r\n\r\n- `npm run test:unit`\r\n- `npm run test:integration`\r\n- `npm run test:e2e`\r\n- `npm run test:protocols:e2e`\r\n\r\n`npm run test:coverage` was not run, so coverage movement for touched files has not been measured yet. Follow-up before merge: run `npm run test:coverage` and confirm statements, lines, functions, and branches remain `>= 60%`.\r\n\r\n## Reviewer Notes\r\n\r\n- Node.js 20 support is removed from runtime policy, package engines, CI, docs, and runtime warning copy.\r\n- Supported runtime policy is now Node.js `>=22.22.2 <23 || >=24.0.0 <25`.\r\n- CI matrix and local version files now target Node 22/24 support.\r\n- Docs and i18n runtime wording were updated broadly; reviewers should expect many documentation-only diffs.\r\n- `tests/e2e/protocol-clients.test.ts` now explicitly enables A2A before validating the A2A flow, matching the current default where A2A starts disabled.\r\n- Manual validation was performed on Node `v22.22.2`.\n\n> [!NOTE]\n> **OmniRoute Maintainer Note:** This PR has been deferred and will be accepted and redirected for the `v4.0.0` (or greater) release cycle. Please skip this PR during `v3.x` stabilization reviews.", + "createdAt": "2026-05-03T14:11:25Z", + "deletions": 776, + "files": [ + { + "path": ".dockerignore", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": ".env.example", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": ".github/workflows/ci.yml", + "additions": 8, + "deletions": 5, + "changeType": "MODIFIED" + }, + { + "path": ".npmignore", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "AGENTS.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "CLAUDE.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "CONTRIBUTING.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "Dockerfile", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "README.md", + "additions": 10, + "deletions": 10, + "changeType": "MODIFIED" + }, + { + "path": "bin/nodeRuntimeSupport.mjs", + "additions": 3, + "deletions": 5, + "changeType": "MODIFIED" + }, + { + "path": "compose.prod.yaml", + "additions": 3, + "deletions": 3, + "changeType": "RENAMED" + }, + { + "path": "compose.yaml", + "additions": 0, + "deletions": 0, + "changeType": "RENAMED" + }, + { + "path": "docs/CLI-TOOLS.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/ENVIRONMENT.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/FEATURES.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/RELEASE_CHECKLIST.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/TROUBLESHOOTING.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/ar/CONTRIBUTING.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/ar/README.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/ar/docs/CLI-TOOLS.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/ar/docs/ENVIRONMENT.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/ar/docs/FEATURES.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/ar/docs/RELEASE_CHECKLIST.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/ar/docs/TROUBLESHOOTING.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/ar/llm.txt", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bg/CONTRIBUTING.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bg/README.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bg/docs/CLI-TOOLS.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bg/docs/ENVIRONMENT.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bg/docs/FEATURES.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bg/docs/RELEASE_CHECKLIST.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bg/docs/TROUBLESHOOTING.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bg/llm.txt", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bn/CONTRIBUTING.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bn/README.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bn/docs/CLI-TOOLS.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bn/docs/ENVIRONMENT.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bn/docs/FEATURES.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bn/docs/RELEASE_CHECKLIST.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bn/docs/TROUBLESHOOTING.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/CONTRIBUTING.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/README.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/CLI-TOOLS.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/ENVIRONMENT.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/FEATURES.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/RELEASE_CHECKLIST.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/TROUBLESHOOTING.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/llm.txt", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/da/CONTRIBUTING.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/da/README.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/da/docs/CLI-TOOLS.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/da/docs/ENVIRONMENT.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/da/docs/FEATURES.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/da/docs/RELEASE_CHECKLIST.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/da/docs/TROUBLESHOOTING.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/da/llm.txt", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/de/CONTRIBUTING.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/de/README.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/de/docs/CLI-TOOLS.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/de/docs/ENVIRONMENT.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/de/docs/FEATURES.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/de/docs/RELEASE_CHECKLIST.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/de/docs/TROUBLESHOOTING.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/de/llm.txt", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/es/CONTRIBUTING.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/es/README.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/es/docs/CLI-TOOLS.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/es/docs/ENVIRONMENT.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/es/docs/FEATURES.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/es/docs/RELEASE_CHECKLIST.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/es/docs/TROUBLESHOOTING.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/es/llm.txt", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fa/CONTRIBUTING.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fa/README.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fa/docs/CLI-TOOLS.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fa/docs/ENVIRONMENT.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fa/docs/FEATURES.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fa/docs/RELEASE_CHECKLIST.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fa/docs/TROUBLESHOOTING.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fi/CONTRIBUTING.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fi/README.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fi/docs/CLI-TOOLS.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fi/docs/ENVIRONMENT.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fi/docs/FEATURES.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fi/docs/RELEASE_CHECKLIST.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fi/docs/TROUBLESHOOTING.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fi/llm.txt", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fr/CONTRIBUTING.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fr/README.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fr/docs/CLI-TOOLS.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fr/docs/ENVIRONMENT.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fr/docs/FEATURES.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fr/docs/RELEASE_CHECKLIST.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fr/docs/TROUBLESHOOTING.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fr/llm.txt", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/gu/CONTRIBUTING.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/gu/README.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/gu/docs/CLI-TOOLS.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/gu/docs/ENVIRONMENT.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/gu/docs/FEATURES.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + } + ], + "headRefName": "release/v3.7.9", + "number": 1915, + "title": "[will be merge 4.0 update] (Security Fix): Drop node.js 20 support + Update docker files to lastest docker standards" + }, + { + "additions": 1119, + "author": { + "id": "U_kgDODPQT-Q", + "is_bot": false, + "login": "vrdons", + "name": "" + }, + "baseRefName": "release/v3.7.9", + "body": "## Summary\r\n\r\nTo allow using on bun interface, faster typescript checks\r\n\r\n## Related Issues\r\nNone\r\n\r\n## Validation\r\n\r\n- [x] `npm run lint`\r\n- [ ] `npm run test:unit`\r\nNo, because last tests stucks\r\n- [x] `npm run test:coverage`\r\n- [ ] Coverage is still `>= 60%` for statements, lines, functions, and branches\r\n- [ ] SonarQube PR analysis is green or any remaining issues are explicitly documented below\r\n\r\n## Tests Added Or Updated\r\n\r\n- List every changed or added automated test file.\r\n- If no production code changed, state that here.\r\n\r\n## Coverage Notes\r\n\r\n- If this PR changes `src/`, `open-sse/`, `electron/`, or `bin/`, explain which tests cover the change.\r\n- If coverage moved down in any touched file, explain why and what follow-up task will recover it.\r\n\r\n## Reviewer Notes\r\n\r\n- Call out any risky areas, migrations, feature flags, or manual validation that reviewers should know about.\r\n\n> [!NOTE]\n> **OmniRoute Maintainer Note:** This PR has been deferred and will be accepted and redirected for the `v4.0.0` (or greater) release cycle. Please skip this PR during `v3.x` stabilization reviews.", + "createdAt": "2026-05-02T17:57:46Z", + "deletions": 231022, + "files": [ + { + "path": ".agents/workflows/deploy-vps-akamai.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": ".agents/workflows/deploy-vps-both.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": ".agents/workflows/deploy-vps-local.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": ".agents/workflows/generate-release.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "AGENTS.md", + "additions": 4, + "deletions": 4, + "changeType": "MODIFIED" + }, + { + "path": "CLAUDE.md", + "additions": 1, + "deletions": 218, + "changeType": "MODIFIED" + }, + { + "path": "GEMINI.md", + "additions": 1, + "deletions": 21, + "changeType": "MODIFIED" + }, + { + "path": "README.md", + "additions": 63, + "deletions": 36, + "changeType": "MODIFIED" + }, + { + "path": "docs/API_REFERENCE.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/ARCHITECTURE.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/CODEBASE_DOCUMENTATION.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/ENVIRONMENT.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/FEATURES.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/I18N.md", + "additions": 82, + "deletions": 52, + "changeType": "MODIFIED" + }, + { + "path": "docs/SETUP_GUIDE.md", + "additions": 9, + "deletions": 8, + "changeType": "MODIFIED" + }, + { + "path": "docs/TERMUX_GUIDE.md", + "additions": 2, + "deletions": 10, + "changeType": "MODIFIED" + }, + { + "path": "docs/TROUBLESHOOTING.md", + "additions": 17, + "deletions": 41, + "changeType": "MODIFIED" + }, + { + "path": "docs/UNINSTALL.md", + "additions": 12, + "deletions": 12, + "changeType": "MODIFIED" + }, + { + "path": "docs/USER_GUIDE.md", + "additions": 2, + "deletions": 10, + "changeType": "MODIFIED" + }, + { + "path": "docs/VM_DEPLOYMENT_GUIDE.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/ar/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/ar/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/ar/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/ar/README.md", + "additions": 2, + "deletions": 13, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/ar/llm.txt", + "additions": 0, + "deletions": 476, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/bg/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/bg/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/bg/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/bg/llm.txt", + "additions": 0, + "deletions": 476, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/bn/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/bn/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/bn/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/cs/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/cs/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/cs/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/cs/README.md", + "additions": 0, + "deletions": 2379, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/A2A-SERVER.md", + "additions": 0, + "deletions": 200, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/API_REFERENCE.md", + "additions": 0, + "deletions": 469, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/ARCHITECTURE.md", + "additions": 0, + "deletions": 891, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/AUTO-COMBO.md", + "additions": 0, + "deletions": 67, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/CLI-TOOLS.md", + "additions": 0, + "deletions": 398, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/CODEBASE_DOCUMENTATION.md", + "additions": 0, + "deletions": 591, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/COVERAGE_PLAN.md", + "additions": 0, + "deletions": 170, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/ENVIRONMENT.md", + "additions": 0, + "deletions": 669, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/FEATURES.md", + "additions": 0, + "deletions": 270, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/FLY_IO_DEPLOYMENT_GUIDE.md", + "additions": 0, + "deletions": 455, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/I18N.md", + "additions": 0, + "deletions": 441, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/MCP-SERVER.md", + "additions": 0, + "deletions": 87, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/RELEASE_CHECKLIST.md", + "additions": 0, + "deletions": 44, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/TROUBLESHOOTING.md", + "additions": 0, + "deletions": 341, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/UNINSTALL.md", + "additions": 0, + "deletions": 157, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/USER_GUIDE.md", + "additions": 0, + "deletions": 966, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/VM_DEPLOYMENT_GUIDE.md", + "additions": 0, + "deletions": 407, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/cloudflare-zero-trust-guide.md", + "additions": 0, + "deletions": 106, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/features/context-relay.md", + "additions": 0, + "deletions": 130, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/llm.txt", + "additions": 0, + "deletions": 476, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/da/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/da/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/da/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/da/llm.txt", + "additions": 0, + "deletions": 476, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/de/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/de/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/de/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/de/llm.txt", + "additions": 0, + "deletions": 476, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/es/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/es/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/es/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/es/llm.txt", + "additions": 0, + "deletions": 476, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/fa/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/fa/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/fa/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/fi/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/fi/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/fi/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/fi/llm.txt", + "additions": 0, + "deletions": 476, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/fr/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/fr/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/fr/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/fr/llm.txt", + "additions": 0, + "deletions": 476, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/gu/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/gu/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/gu/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/he/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/he/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/he/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/he/llm.txt", + "additions": 0, + "deletions": 476, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/hi/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/hi/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/hi/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/hi/llm.txt", + "additions": 0, + "deletions": 476, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/hu/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/hu/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/hu/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/hu/llm.txt", + "additions": 0, + "deletions": 476, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/id/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/id/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/id/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/id/llm.txt", + "additions": 0, + "deletions": 476, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/in/CHANGELOG.md", + "additions": 0, + "deletions": 3232, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/in/CLAUDE.md", + "additions": 0, + "deletions": 229, + "changeType": "DELETED" + } + ], + "headRefName": "main", + "number": 1894, + "title": "refactor(db): migrate from better-sqlite3 to node:sqlite, use tsgo" + }, + { + "additions": 276, + "author": { + "id": "U_kgDOD9eSjQ", + "is_bot": false, + "login": "smartenok-ops", + "name": "" + }, + "baseRefName": "release/v3.7.9", + "body": "When a codex SSE stream hits 429 mid-task, OmniRoute previously gave up (maxAttempts=1, no rotation). Add retry with account rotation when session affinity allows.\r\n\r\nDepends on: per-session sticky routing PR (extractSessionAffinityKey, deleteSessionAccountAffinity).\r\n\r\n## Summary\r\n\r\n- Describe the user-facing or operational change.\r\n\r\n## Related Issues\r\n\r\n- Closes #\r\n- Related to #\r\n\r\n## Validation\r\n\r\n- [ ] `npm run lint`\r\n- [ ] `npm run test:unit`\r\n- [ ] `npm run test:coverage`\r\n- [ ] Coverage is still `>= 60%` for statements, lines, functions, and branches\r\n- [ ] SonarQube PR analysis is green or any remaining issues are explicitly documented below\r\n\r\n## Tests Added Or Updated\r\n\r\n- List every changed or added automated test file.\r\n- If no production code changed, state that here.\r\n\r\n## Coverage Notes\r\n\r\n- If this PR changes `src/`, `open-sse/`, `electron/`, or `bin/`, explain which tests cover the change.\r\n- If coverage moved down in any touched file, explain why and what follow-up task will recover it.\r\n\r\n## Reviewer Notes\r\n\r\n- Call out any risky areas, migrations, feature flags, or manual validation that reviewers should know about.\r\n\n> [!NOTE]\n> **OmniRoute Maintainer Note:** This PR has been deferred and will be accepted and redirected for the `v4.0.0` (or greater) release cycle. Please skip this PR during `v3.x` stabilization reviews.", + "createdAt": "2026-05-02T16:06:11Z", + "deletions": 2, + "files": [ + { + "path": "open-sse/handlers/chatCore.ts", + "additions": 84, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "tests/unit/codex-failover.test.ts", + "additions": 192, + "deletions": 0, + "changeType": "ADDED" + } + ], + "headRefName": "feat/codex-429-mid-task-failover", + "number": 1888, + "title": "feat(sse): codex 429 mid-task failover with account rotation" + }, + { + "additions": 461, + "author": { + "id": "U_kgDOD9eSjQ", + "is_bot": false, + "login": "smartenok-ops", + "name": "" + }, + "baseRefName": "release/v3.7.9", + "body": "When concurrent SSE clients use a shared OmniRoute key, randomized account selection causes per-session inconsistencies (one stream hits account A, next request from same logical session hits B). Adds session_account_affinity table + extractSessionAffinityKey helper so a session_key (e.g. SHA of input headers) deterministically pins to one account for its lifetime.\r\n\r\nMigration: 041_session_account_affinity.sql (renumbered from local 026 — please confirm next available slot).\r\n\r\n## Summary\r\n\r\n- Describe the user-facing or operational change.\r\n\r\n## Related Issues\r\n\r\n- Closes #\r\n- Related to #\r\n\r\n## Validation\r\n\r\n- [ ] `npm run lint`\r\n- [ ] `npm run test:unit`\r\n- [ ] `npm run test:coverage`\r\n- [ ] Coverage is still `>= 60%` for statements, lines, functions, and branches\r\n- [ ] SonarQube PR analysis is green or any remaining issues are explicitly documented below\r\n\r\n## Tests Added Or Updated\r\n\r\n- List every changed or added automated test file.\r\n- If no production code changed, state that here.\r\n\r\n## Coverage Notes\r\n\r\n- If this PR changes `src/`, `open-sse/`, `electron/`, or `bin/`, explain which tests cover the change.\r\n- If coverage moved down in any touched file, explain why and what follow-up task will recover it.\r\n\r\n## Reviewer Notes\r\n\r\n- Call out any risky areas, migrations, feature flags, or manual validation that reviewers should know about.\r\n\n> [!NOTE]\n> **OmniRoute Maintainer Note:** This PR has been deferred and will be accepted and redirected for the `v4.0.0` (or greater) release cycle. Please skip this PR during `v3.x` stabilization reviews.", + "createdAt": "2026-05-02T16:05:53Z", + "deletions": 122, + "files": [ + { + "path": "src/instrumentation-node.ts", + "additions": 10, + "deletions": 5, + "changeType": "MODIFIED" + }, + { + "path": "src/lib/db/migrations/041_session_account_affinity.sql", + "additions": 11, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "src/lib/db/sessionAccountAffinity.ts", + "additions": 122, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "src/lib/localDb.ts", + "additions": 10, + "deletions": 0, + "changeType": "MODIFIED" + }, + { + "path": "src/sse/handlers/chat.ts", + "additions": 14, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "src/sse/services/auth.ts", + "additions": 177, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "tests/unit/sse-auth.test.ts", + "additions": 117, + "deletions": 114, + "changeType": "MODIFIED" + } + ], + "headRefName": "feat/per-session-sticky-routing", + "number": 1887, + "title": "feat(auth): per-session sticky routing for codex" + } +] diff --git a/scripts/pack-artifact-policy.ts b/scripts/pack-artifact-policy.ts index b2e55c0232..06793c10c6 100644 --- a/scripts/pack-artifact-policy.ts +++ b/scripts/pack-artifact-policy.ts @@ -42,6 +42,8 @@ export const APP_STAGING_ALLOWED_PATH_PREFIXES: string[] = [ ".next/", "data/", "node_modules/", + "open-sse/services/compression/engines/rtk/filters/", + "open-sse/services/compression/rules/", "public/", "src/lib/db/migrations/", "src/mitm/", @@ -88,6 +90,8 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_PATH_PREFIXES: string[] = [ ]; export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [ + "app/open-sse/services/compression/engines/rtk/filters/generic-output.json", + "app/open-sse/services/compression/rules/en/filler.json", "app/server.js", "app/server-ws.mjs", "app/responses-ws-proxy.mjs", diff --git a/scripts/prepublish.ts b/scripts/prepublish.ts index 199c76c68d..8b54f60067 100644 --- a/scripts/prepublish.ts +++ b/scripts/prepublish.ts @@ -475,6 +475,23 @@ if (existsSync(migrationsSrc)) { cpSync(migrationsSrc, migrationsDest, { recursive: true, force: true }); } +const runtimeAssetDirs = [ + { + source: join(ROOT, "open-sse", "services", "compression", "engines", "rtk", "filters"), + destination: join(APP_DIR, "open-sse", "services", "compression", "engines", "rtk", "filters"), + }, + { + source: join(ROOT, "open-sse", "services", "compression", "rules"), + destination: join(APP_DIR, "open-sse", "services", "compression", "rules"), + }, +]; +for (const assetDir of runtimeAssetDirs) { + if (existsSync(assetDir.source)) { + mkdirSync(dirname(assetDir.destination), { recursive: true }); + cpSync(assetDir.source, assetDir.destination, { recursive: true, force: true }); + } +} + // ── Step 10: Ensure data/ directory exists ────────────────── mkdirSync(join(APP_DIR, "data"), { recursive: true }); diff --git a/scripts/scratch/pr_body.md b/scripts/scratch/pr_body.md new file mode 100644 index 0000000000..25326e6e60 --- /dev/null +++ b/scripts/scratch/pr_body.md @@ -0,0 +1,46 @@ +### ✨ New Features + +- **feat(compression):** major upgrade to Caveman and RTK compression pipelines (#1876, #1889): + - Add RTK tool-output compression, stacked Caveman + RTK pipelines, compression combo assignments, dashboard context pages, MCP management tools, and language-aware Caveman rule packs. + - Expand RTK parity with a 39-filter catalog, RTK-style JSON DSL stages, inline verify/benchmark coverage, trust-gated custom filters, expanded command detection, and redacted raw-output recovery. + - Expose rule intensities, track USD savings, unify config validation, and persist MCP savings. + - Expand Caveman parity and MCP metadata compression. +- **feat(provider):** update Jina AI model catalog to support Embeddings and Rerank natively (#1874 — thanks @backryun) +- **feat(provider):** add NanoGPT image generation provider (#1899 — thanks @Aculeasis) +- **feat(ui):** move proxy configuration to dedicated System → Proxy page (#1907 — thanks @oyi77) +- **feat(ui):** add K/M/B/T cost shortener utility (#1902 — thanks @oyi77) +- **feat(providers):** implement bulk paste for extra API keys (#1916 — thanks @0xtbug) +- **feat(analytics):** usage history API key backfill + dark mode pricing (#1896 — thanks @Gi99lin) +- **feat(logs):** show RTK and Caveman compression token savings accurately in request log UI (#1923 — thanks @emdash) + +### 🐛 Bug Fixes + +- **fix(providers):** allow local OpenAI-compatible endpoints (like Ollama) to be added without an API key (fixes #1893) +- **fix(providers):** bypass AgentRouter unauthorized_client_error by spoofing Claude CLI headers via Anthropic endpoints (fixes #1921) +- **fix(copilot):** emit compatible reasoning text deltas (#1919 — thanks @ivan-mezentsev) +- **fix(api-manager):** show validation errors inline in modals, not behind (#1920 — thanks @andrewmunsell) +- **fix(compression):** align seeded standard savings combo with stacked default, preserve stacked defaults, and secure metadata routes. +- **fix(gemini-cli):** separate Cloud Code transport from Antigravity (#1869 — thanks @dhaern) +- **fix(codex):** map prompt field to input array for Cursor compatibility (fixes #1872) +- **fix(core):** align stream parameter default to false per strict OpenAI spec (fixes #1873) +- **fix(ui):** restore Next.js CSP `unsafe-eval` in production `script-src` to fix unresponsive Onboarding button (fixes #1883) +- **fix(proxy):** globally strip `prompt_cache_retention` in `BaseExecutor` to prevent upstream 400 errors from strict endpoints like droid/gemini-2-pro (fixes #1884) +- **fix(ui):** include `isOpen` dependency in `EditConnectionModal` state sync to ensure `maxConcurrent` is properly hydrated when reopening the modal (fixes #1859) +- **fix(security):** remediate 4 polynomial-redos CodeQL alerts in compression regexes by bounding repetitions and removing overlapping quantifiers +- **fix(codex):** flatten Chat Completions tool format to Codex Responses format in `normalizeCodexTools` — prevents `Missing required parameter: tools[0].name` upstream errors (#1914 — thanks @tranduykhanh030) +- **fix(proxy):** add proxy-aware execution context to image generation route — proxy settings are now correctly applied for image providers behind restricted networks (#1904 — thanks @Aculeasis) +- **fix(translator):** inject `properties: {}` into zero-argument MCP tool schemas during Anthropic→OpenAI translation — prevents 400 errors from OpenAI strict schema validation (#1898 — thanks @bryceIT) +- **fix(codex):** sanitize raw responses input (#1895 — thanks @dhaern) +- **fix(combos):** align strategy contracts (#1892 — thanks @dhaern) +- **fix(combos):** fix combo provider breaker profile handling (#1891 — thanks @rdself) +- **fix(migrations):** duplicate-column no-op fix (#1886 — thanks @smartenok-ops) +- **fix(auth):** per-connection OAuth refresh mutex (#1885 — thanks @smartenok-ops) +- **fix(auth):** require dashboard management auth for compression preview + +### 📝 Documentation + +- **docs(compression):** document RTK+Caveman stacked savings ranges + +### 🏆 Release Attribution & Retroactive Credits + +- **@payne0420** (PR #1828 / #1839) — Implementation of the **Rate Limit Watchdog** and environment overrides. (This feature was manually backported to v3.7.8, causing the automatic GitHub Release notes to omit the author's credit). diff --git a/scripts/scratch/test-migrations.ts b/scripts/scratch/test-migrations.ts index d908738b1d..f21ca16ca8 100644 --- a/scripts/scratch/test-migrations.ts +++ b/scripts/scratch/test-migrations.ts @@ -1,47 +1,56 @@ import Database from "better-sqlite3"; -import fs from "fs"; -import path from "path"; +import { runMigrations, getMigrationStatus } from "../../src/lib/db/migrationRunner.js"; -import { runMigrations, getMigrationStatus } from "../../src/lib/db/migrationRunner.ts"; +const db = new Database("/tmp/test-migrations.sqlite"); +db.exec( + "CREATE TABLE _omniroute_migrations (version TEXT PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL DEFAULT (datetime('now')))" +); -const db = new Database(":memory:"); -db.exec(` - CREATE TABLE IF NOT EXISTS _omniroute_migrations ( - version TEXT PRIMARY KEY, - name TEXT NOT NULL, - applied_at TEXT NOT NULL DEFAULT (datetime('now')) - ); - INSERT INTO _omniroute_migrations (version, name) VALUES ('001', 'initial_schema'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('002', 'mcp_a2a_tables'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('003', 'provider_node_custom_paths'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('004', 'proxy_registry'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('005', 'combo_agent_fields'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('006', 'detailed_request_logs'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('007', 'search_request_type'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('008', 'registered_keys'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('009', 'requested_model'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('010', 'model_combo_mappings'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('011', 'webhooks'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('012', 'fix_token_input_cache_tokens'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('013', 'quota_snapshots'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('014', 'unified_log_artifacts'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('015', 'create_memories'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('016', 'create_skills'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('017', 'version_manager_upstream_proxy'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('018', 'call_logs_detailed_tokens'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('019', 'context_handoffs'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('020', 'combo_sort_order'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('021', 'combo_call_log_targets'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('026', 'call_logs_cache_source'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('029', 'provider_connection_max_concurrent'); -`); - -console.log("Status before:", getMigrationStatus(db)); +const fakeApplied = [ + "001", + "002", + "003", + "004", + "005", + "006", + "007", + "008", + "009", + "010", + "011", + "012", + "013", + "014", + "015", + "016", + "017", + "018", + "019", + "020", + "021", +]; +for (const v of fakeApplied) { + db.prepare("INSERT INTO _omniroute_migrations (version, name) VALUES (?, ?)").run(v, "fake_" + v); +} +db.prepare("INSERT INTO _omniroute_migrations (version, name) VALUES (?, ?)").run( + "026", + "call_logs_cache_source" +); +db.prepare("INSERT INTO _omniroute_migrations (version, name) VALUES (?, ?)").run( + "029", + "provider_connection_max_concurrent" +); +console.log( + "Status before:", + getMigrationStatus(db).pending.map((p) => p.version) +); try { runMigrations(db, { isNewDb: false }); -} catch (e) { - console.error("Migration threw error:", e); +} catch (e: any) { + console.error("Error:", e.message); } - -console.log("Status after:", getMigrationStatus(db)); +console.log( + "Status after:", + getMigrationStatus(db).pending.map((p) => p.version) +); diff --git a/src/app/(dashboard)/dashboard/analytics/CompressionAnalyticsTab.tsx b/src/app/(dashboard)/dashboard/analytics/CompressionAnalyticsTab.tsx index 184eb936db..6c84c6b077 100644 --- a/src/app/(dashboard)/dashboard/analytics/CompressionAnalyticsTab.tsx +++ b/src/app/(dashboard)/dashboard/analytics/CompressionAnalyticsTab.tsx @@ -17,6 +17,17 @@ interface CompressionAnalyticsSummary { byMode: Record; byProvider: Record; last24h: Array<{ hour: string; count: number; tokensSaved: number }>; + validationFallbacks: number; + realUsage: { + requestsWithReceipts: number; + promptTokens: number; + completionTokens: number; + totalTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + estimatedUsdSaved: number; + bySource: Record; + }; } function StatCard({ @@ -178,7 +189,7 @@ export default function CompressionAnalyticsTab() { {/* KPI Cards */} -
+
+ +
+ {stats.realUsage.requestsWithReceipts > 0 && ( +
+

+ receipt_long + Real Usage Receipts +

+
+
+
Prompt tokens
+
+ {stats.realUsage.promptTokens.toLocaleString()} +
+
+
+
Completion tokens
+
+ {stats.realUsage.completionTokens.toLocaleString()} +
+
+
+
Total tokens
+
+ {stats.realUsage.totalTokens.toLocaleString()} +
+
+
+
Cache tokens
+
+ {( + (stats.realUsage.cacheReadTokens ?? 0) + (stats.realUsage.cacheWriteTokens ?? 0) + ).toLocaleString()} +
+
+
+
Sources
+
+ {Object.entries(stats.realUsage.bySource) + .map(([source, count]) => `${source}: ${count}`) + .join(", ")} +
+
+
+
+ )} + {/* Mode Breakdown */} {modes.length > 0 && (
@@ -290,8 +358,8 @@ export default function CompressionAnalyticsTab() { info Compression analytics: Token savings tracked per mode (off, lite, - standard, aggressive, ultra) and provider. Hover over charts for details. Use the time - selector to view different time periods. + standard, aggressive, ultra, RTK, stacked), engine, compression combo, and provider. Hover + over charts for details. Use the time selector to view different time periods.
diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index e2c71e54d6..06e27cc639 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -107,7 +107,9 @@ export default function ApiManagerPageClient() { const [editingKey, setEditingKey] = useState(null); const [showPermissionsModal, setShowPermissionsModal] = useState(false); const [searchModel, setSearchModel] = useState(""); - const [error, setError] = useState(null); + const [pageError, setPageError] = useState(null); + const [nameError, setNameError] = useState(null); + const [createError, setCreateError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const [usageStats, setUsageStats] = useState>({}); const [sessionCounts, setSessionCounts] = useState>({}); @@ -226,19 +228,20 @@ export default function ApiManagerPageClient() { } }; - const clearError = useCallback(() => setError(null), []); + const clearPageError = useCallback(() => setPageError(null), []); const handleCreateKey = async () => { // Validate raw input first, then sanitize const validation = validateKeyName(newKeyName, t); if (!validation.valid) { - setError(validation.error || t("invalidKeyName")); + setNameError(validation.error || t("invalidKeyName")); return; } const sanitizedName = sanitizeInput(newKeyName); setIsSubmitting(true); - clearError(); + setNameError(null); + setCreateError(null); try { const res = await fetch("/api/keys", { @@ -254,11 +257,11 @@ export default function ApiManagerPageClient() { setNewKeyName(""); setShowAddModal(false); } else { - setError(data.error || t("failedCreateKey")); + setCreateError(data.error || t("failedCreateKey")); } } catch (error) { console.error("Error creating key:", error); - setError(t("failedCreateKeyRetry")); + setCreateError(t("failedCreateKeyRetry")); } finally { setIsSubmitting(false); } @@ -267,14 +270,14 @@ export default function ApiManagerPageClient() { const handleDeleteKey = async (id: string) => { // Validate ID format to prevent injection if (!id || typeof id !== "string" || !/^[a-zA-Z0-9_-]+$/.test(id)) { - setError(t("invalidKeyId")); + setPageError(t("invalidKeyId")); return; } if (!confirm(t("deleteConfirm"))) return; setIsSubmitting(true); - clearError(); + clearPageError(); try { const res = await fetch(`/api/keys/${encodeURIComponent(id)}`, { method: "DELETE" }); @@ -282,11 +285,11 @@ export default function ApiManagerPageClient() { setKeys((prev) => prev.filter((k) => k.id !== id)); } else { const data = await res.json(); - setError(data.error || t("failedDeleteKey")); + setPageError(data.error || t("failedDeleteKey")); } } catch (error) { console.error("Error deleting key:", error); - setError(t("failedDeleteKeyRetry")); + setPageError(t("failedDeleteKeyRetry")); } finally { setIsSubmitting(false); } @@ -329,23 +332,15 @@ export default function ApiManagerPageClient() { ) => { if (!editingKey || !editingKey.id) return; - // Validate raw input first, then sanitize - const nameValidation = validateKeyName(name, t); - if (!nameValidation.valid) { - setError(nameValidation.error || t("invalidKeyName")); - return; - } const sanitizedName = sanitizeInput(name); // Validate models array if (!Array.isArray(allowedModels)) { - setError(t("invalidModelsSelection")); return; } // Limit number of selected models to prevent abuse if (allowedModels.length > MAX_SELECTED_MODELS) { - setError(t("cannotSelectMoreThanModels", { max: MAX_SELECTED_MODELS })); return; } @@ -364,7 +359,7 @@ export default function ApiManagerPageClient() { : 0; setIsSubmitting(true); - clearError(); + clearPageError(); try { const res = await fetch(`/api/keys/${encodeURIComponent(editingKey.id)}`, { @@ -388,11 +383,11 @@ export default function ApiManagerPageClient() { setEditingKey(null); } else { const data = await res.json(); - setError(data.error || t("failedUpdatePermissions")); + setPageError(data.error || t("failedUpdatePermissions")); } } catch (error) { console.error("Error updating permissions:", error); - setError(t("failedUpdatePermissionsRetry")); + setPageError(t("failedUpdatePermissionsRetry")); } finally { setIsSubmitting(false); } @@ -441,12 +436,12 @@ export default function ApiManagerPageClient() { return (
{/* Error Banner */} - {error && ( + {pageError && (
error -

{error}

+

{pageError}

-
@@ -554,7 +557,14 @@ export default function ApiManagerPageClient() {

{t("noKeys")}

{t("noKeysDesc")}

- @@ -759,6 +769,8 @@ export default function ApiManagerPageClient() { onClose={() => { setShowAddModal(false); setNewKeyName(""); + setNameError(null); + setCreateError(null); }} >
@@ -768,25 +780,42 @@ export default function ApiManagerPageClient() { setNewKeyName(e.target.value)} + onChange={(e) => { + setNewKeyName(e.target.value); + setNameError(null); + }} placeholder={t("keyNamePlaceholder")} maxLength={MAX_KEY_NAME_LENGTH} + error={nameError} autoFocus />

{t("keyNameDesc")}

+ {createError && ( +
+ error +

{createError}

+
+ )}
-
@@ -905,6 +934,8 @@ const PermissionsModal = memo(function PermissionsModal({ const [scheduleTz, setScheduleTz] = useState( apiKey?.accessSchedule?.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone ); + const [nameError, setNameError] = useState(null); + const [saveError, setSaveError] = useState(null); const [selectedConnections, setSelectedConnections] = useState(initialConnections); const [allowAllConnections, setAllowAllConnections] = useState(initialConnections.length === 0); const [expandedProviders, setExpandedProviders] = useState>(() => { @@ -992,6 +1023,29 @@ const PermissionsModal = memo(function PermissionsModal({ ); const handleSave = useCallback(() => { + // Clear previous inline errors + setNameError(null); + setSaveError(null); + + // Validate name inline before calling onSave + const validation = validateKeyName(keyName, t); + if (!validation.valid) { + setNameError(validation.error || t("invalidKeyName")); + return; + } + + // Validate models selection + if (!allowAll && !Array.isArray(selectedModels)) { + setSaveError(t("invalidModelsSelection")); + return; + } + + // Limit number of selected models to prevent abuse + if (!allowAll && selectedModels.length > MAX_SELECTED_MODELS) { + setSaveError(t("cannotSelectMoreThanModels", { max: MAX_SELECTED_MODELS })); + return; + } + const schedule: AccessSchedule | null = scheduleEnabled ? { enabled: true, @@ -1027,6 +1081,7 @@ const PermissionsModal = memo(function PermissionsModal({ scheduleUntil, scheduleDays, scheduleTz, + t, ]); const selectedCount = selectedModels.length; @@ -1048,13 +1103,25 @@ const PermissionsModal = memo(function PermissionsModal({
setKeyName(e.target.value)} + onChange={(e) => { + setKeyName(e.target.value); + setNameError(null); + }} placeholder={t("keyNamePlaceholder")} maxLength={MAX_KEY_NAME_LENGTH} + error={nameError} />
+ {/* Inline save error */} + {saveError && ( +
+ error +

{saveError}

+
+ )} + {/* Access Mode Toggle */}
- ); + redirect("/dashboard/context/caveman"); } diff --git a/src/app/(dashboard)/dashboard/context/caveman/CavemanContextPageClient.tsx b/src/app/(dashboard)/dashboard/context/caveman/CavemanContextPageClient.tsx new file mode 100644 index 0000000000..e879548954 --- /dev/null +++ b/src/app/(dashboard)/dashboard/context/caveman/CavemanContextPageClient.tsx @@ -0,0 +1,262 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import CompressionSettingsTab from "@/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab"; + +type AnalyticsSummary = { + totalRequests: number; + totalTokensSaved: number; + avgSavingsPct: number; + avgDurationMs: number; + byEngine?: Record; + byMode?: Record; + last24h?: Array<{ hour: string; count: number; tokensSaved: number }>; +}; + +type LanguageConfig = { + enabled: boolean; + defaultLanguage: string; + autoDetect: boolean; + enabledPacks: string[]; +}; + +type OutputModeConfig = { + enabled: boolean; + intensity: "lite" | "full" | "ultra"; + autoClarity: boolean; +}; + +type CompressionSettings = { + languageConfig?: LanguageConfig; + cavemanOutputMode?: OutputModeConfig; +}; + +type LanguagePack = { language: string; ruleCount: number; categories?: string[] }; + +function formatNumber(value: number | undefined): string { + return new Intl.NumberFormat().format(value ?? 0); +} + +export default function CavemanContextPageClient() { + const t = useTranslations("contextCaveman"); + const [analytics, setAnalytics] = useState(null); + const [settings, setSettings] = useState(null); + const [languagePacks, setLanguagePacks] = useState([]); + const [saving, setSaving] = useState(false); + + const refreshSettings = () => { + fetch("/api/context/caveman/config") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => setSettings(data)) + .catch(() => setSettings(null)); + }; + + useEffect(() => { + fetch("/api/context/analytics?since=7d") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => setAnalytics(data)) + .catch(() => setAnalytics(null)); + fetch("/api/compression/language-packs") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => setLanguagePacks(Array.isArray(data?.packs) ? data.packs : [])) + .catch(() => setLanguagePacks([])); + refreshSettings(); + }, []); + + const languageConfig: LanguageConfig = settings?.languageConfig ?? { + enabled: false, + defaultLanguage: "en", + autoDetect: true, + enabledPacks: ["en"], + }; + const outputMode: OutputModeConfig = settings?.cavemanOutputMode ?? { + enabled: false, + intensity: "full", + autoClarity: true, + }; + + const saveSettings = async (patch: Partial) => { + setSaving(true); + try { + const res = await fetch("/api/context/caveman/config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + if (res.ok) setSettings(await res.json()); + } finally { + setSaving(false); + } + }; + + const updateLanguageConfig = (patch: Partial) => { + saveSettings({ languageConfig: { ...languageConfig, ...patch } }); + }; + + const updateOutputMode = (patch: Partial) => { + saveSettings({ cavemanOutputMode: { ...outputMode, ...patch } }); + }; + + const togglePack = (language: string, enabled: boolean) => { + const enabledPacks = enabled + ? [...new Set([...languageConfig.enabledPacks, language])] + : languageConfig.enabledPacks.filter((pack) => pack !== language && pack !== "en"); + updateLanguageConfig({ enabledPacks }); + }; + + const cavemanStats = analytics?.byEngine?.caveman ?? analytics?.byEngine?.standard; + const modeBreakdown = useMemo(() => Object.entries(analytics?.byMode ?? {}), [analytics]); + const statCards = [ + [t("requests"), formatNumber(cavemanStats?.count ?? analytics?.totalRequests)], + [t("tokensSaved"), formatNumber(cavemanStats?.tokensSaved ?? analytics?.totalTokensSaved)], + [t("savingsPercent"), `${cavemanStats?.avgSavingsPct ?? analytics?.avgSavingsPct ?? 0}%`], + [t("avgLatency"), `${analytics?.avgDurationMs ?? 0}ms`], + ]; + const previewPrompt = `[OmniRoute Caveman Output Mode]\n${t(`preview.${outputMode.intensity}`)}`; + + return ( +
+
+
+ compress +
+

{t("title")}

+

{t("description")}

+
+
+
+ +
+ {statCards.map(([label, value]) => ( +
+

{label}

+

{value}

+
+ ))} +
+ +
+
+

{t("languagePacks")}

+

{t("languagePacksDesc")}

+
+
+ + + +
+
+ {languagePacks.map((pack) => ( + + ))} +
+
+ +
+
+

{t("analyticsTitle")}

+
+ {modeBreakdown.length === 0 ? ( +

{t("noAnalytics")}

+ ) : ( + modeBreakdown.map(([mode, stats]) => ( +
+ {mode} + + {formatNumber(stats.tokensSaved)} / {stats.avgSavingsPct}% + +
+ )) + )} +
+
+ +
+

{t("outputModeTitle")}

+

{t("outputModeDesc")}

+
+ + + +
+
+            {previewPrompt}
+          
+

+ {t("bypassConditions")}: {t("bypassConditionsList")} +

+
+
+ + +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/context/caveman/page.tsx b/src/app/(dashboard)/dashboard/context/caveman/page.tsx new file mode 100644 index 0000000000..bfeae4c1cb --- /dev/null +++ b/src/app/(dashboard)/dashboard/context/caveman/page.tsx @@ -0,0 +1,5 @@ +import CavemanContextPageClient from "./CavemanContextPageClient"; + +export default function CavemanContextPage() { + return ; +} diff --git a/src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient.tsx b/src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient.tsx new file mode 100644 index 0000000000..58149a9aa6 --- /dev/null +++ b/src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient.tsx @@ -0,0 +1,391 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; + +type PipelineStep = { engine: string; intensity?: string }; +type CompressionCombo = { + id: string; + name: string; + description: string; + pipeline: PipelineStep[]; + languagePacks: string[]; + outputMode: boolean; + outputModeIntensity: string; + isDefault: boolean; +}; +type RoutingCombo = { id?: string; name?: string }; +type LanguagePack = { language: string; ruleCount: number }; + +const EMPTY_PIPELINE: PipelineStep[] = [ + { engine: "rtk", intensity: "standard" }, + { engine: "caveman", intensity: "full" }, +]; + +const ENGINE_INTENSITIES: Record = { + rtk: ["minimal", "standard", "aggressive"], + caveman: ["lite", "full", "ultra"], + lite: ["lite"], + aggressive: ["standard"], + ultra: ["ultra"], +}; + +export default function CompressionCombosPageClient() { + const t = useTranslations("contextCombos"); + const [combos, setCombos] = useState([]); + const [routingCombos, setRoutingCombos] = useState([]); + const [languagePacks, setLanguagePacks] = useState([]); + const [editingId, setEditingId] = useState(null); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [pipeline, setPipeline] = useState(EMPTY_PIPELINE); + const [selectedPacks, setSelectedPacks] = useState(["en"]); + const [outputMode, setOutputMode] = useState(false); + const [outputModeIntensity, setOutputModeIntensity] = useState("full"); + const [assignmentIds, setAssignmentIds] = useState([]); + const [saving, setSaving] = useState(false); + + const refresh = () => { + fetch("/api/context/combos") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => setCombos(Array.isArray(data?.combos) ? data.combos : [])) + .catch(() => {}); + }; + + useEffect(() => { + refresh(); + fetch("/api/combos") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => setRoutingCombos(Array.isArray(data?.combos) ? data.combos : [])) + .catch(() => {}); + fetch("/api/compression/language-packs") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => setLanguagePacks(Array.isArray(data?.packs) ? data.packs : [])) + .catch(() => {}); + }, []); + + const resetForm = () => { + setEditingId(null); + setName(""); + setDescription(""); + setPipeline(EMPTY_PIPELINE); + setSelectedPacks(["en"]); + setOutputMode(false); + setOutputModeIntensity("full"); + setAssignmentIds([]); + }; + + const loadAssignments = async (id: string) => { + const res = await fetch(`/api/context/combos/${id}/assignments`); + if (!res.ok) return []; + const data = await res.json(); + return Array.isArray(data?.assignments) + ? data.assignments.map((item: { routingComboId: string }) => item.routingComboId) + : []; + }; + + const editCombo = async (combo: CompressionCombo) => { + setEditingId(combo.id); + setName(combo.name); + setDescription(combo.description ?? ""); + setPipeline(combo.pipeline.length > 0 ? combo.pipeline : EMPTY_PIPELINE); + setSelectedPacks(combo.languagePacks?.length ? combo.languagePacks : ["en"]); + setOutputMode(Boolean(combo.outputMode)); + setOutputModeIntensity(combo.outputModeIntensity ?? "full"); + setAssignmentIds(await loadAssignments(combo.id)); + }; + + const saveCombo = async () => { + const trimmed = name.trim(); + if (!trimmed) return; + setSaving(true); + try { + const payload = { + name: trimmed, + description, + pipeline, + languagePacks: selectedPacks, + outputMode, + outputModeIntensity, + }; + const res = await fetch( + editingId ? `/api/context/combos/${editingId}` : "/api/context/combos", + { + method: editingId ? "PUT" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + } + ); + if (!res.ok) return; + const combo = await res.json(); + await fetch(`/api/context/combos/${combo.id}/assignments`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ routingComboIds: assignmentIds }), + }); + resetForm(); + refresh(); + } finally { + setSaving(false); + } + }; + + const deleteCombo = async (combo: CompressionCombo) => { + if (!confirm(t("deleteConfirm"))) return; + const res = await fetch(`/api/context/combos/${combo.id}`, { method: "DELETE" }); + if (res.ok) refresh(); + }; + + const setDefault = async (id: string) => { + const res = await fetch(`/api/context/combos/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isDefault: true }), + }); + if (res.ok) refresh(); + }; + + const updateStep = (index: number, patch: Partial) => { + setPipeline((current) => + current.map((step, stepIndex) => { + if (stepIndex !== index) return step; + const next = { ...step, ...patch }; + const allowed = ENGINE_INTENSITIES[next.engine] ?? ["standard"]; + return { + ...next, + intensity: allowed.includes(next.intensity ?? "") ? next.intensity : allowed[0], + }; + }) + ); + }; + + const togglePack = (language: string, enabled: boolean) => { + setSelectedPacks((current) => + enabled + ? [...new Set([...current, language])] + : current.filter((item) => item !== language && item !== "en") + ); + }; + + const toggleAssignment = (id: string, enabled: boolean) => { + setAssignmentIds((current) => + enabled ? [...new Set([...current, id])] : current.filter((item) => item !== id) + ); + }; + + return ( +
+
+
+ hub +
+

{t("title")}

+

{t("description")}

+
+
+
+ +
+
+ setName(event.target.value)} + placeholder={t("name")} + className="rounded-lg border border-border bg-bg px-3 py-2 text-sm text-text-main" + /> + setDescription(event.target.value)} + placeholder={t("descriptionField")} + className="rounded-lg border border-border bg-bg px-3 py-2 text-sm text-text-main" + /> +
+ +
+
+

{t("pipeline")}

+ +
+ {pipeline.map((step, index) => ( +
+ + + +
+ ))} +
+ +
+
+

{t("languagePacks")}

+
+ {languagePacks.map((pack) => ( + + ))} +
+
+
+

{t("outputMode")}

+ + +
+
+

{t("assignToRouting")}

+
+ {routingCombos.length === 0 ? ( +

{t("noAssignments")}

+ ) : ( + routingCombos.map((combo) => { + const id = combo.id ?? combo.name ?? ""; + if (!id) return null; + return ( + + ); + }) + )} +
+
+
+ +
+ + {editingId && ( + + )} +
+
+ +
+ {combos.map((combo) => ( +
+
+
+

{combo.name}

+

{combo.description}

+
+ {combo.isDefault && ( + + {t("default")} + + )} +
+
+ {combo.pipeline.map((step, index) => ( + + {index + 1}. {step.engine} + {step.intensity ? `:${step.intensity}` : ""} + + ))} +
+

+ {t("languagePacks")}: {combo.languagePacks.join(", ")} +

+
+ + {!combo.isDefault && ( + + )} + {!combo.isDefault && ( + + )} +
+
+ ))} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/context/combos/page.tsx b/src/app/(dashboard)/dashboard/context/combos/page.tsx new file mode 100644 index 0000000000..934ab7d451 --- /dev/null +++ b/src/app/(dashboard)/dashboard/context/combos/page.tsx @@ -0,0 +1,5 @@ +import CompressionCombosPageClient from "./CompressionCombosPageClient"; + +export default function CompressionCombosPage() { + return ; +} diff --git a/src/app/(dashboard)/dashboard/context/rtk/RtkContextPageClient.tsx b/src/app/(dashboard)/dashboard/context/rtk/RtkContextPageClient.tsx new file mode 100644 index 0000000000..e42b098115 --- /dev/null +++ b/src/app/(dashboard)/dashboard/context/rtk/RtkContextPageClient.tsx @@ -0,0 +1,349 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; + +type RtkFilter = { + id: string; + name: string; + description: string; + commandTypes: string[]; + category: string; + priority: number; +}; + +type RtkConfig = { + enabled: boolean; + intensity: "minimal" | "standard" | "aggressive"; + applyToToolResults: boolean; + applyToAssistantMessages: boolean; + applyToCodeBlocks: boolean; + enabledFilters: string[]; + disabledFilters: string[]; + maxLinesPerResult: number; + maxCharsPerResult: number; + deduplicateThreshold: number; + customFiltersEnabled: boolean; + trustProjectFilters: boolean; + rawOutputRetention: "never" | "failures" | "always"; + rawOutputMaxBytes: number; +}; + +type AnalyticsSummary = { + totalRequests: number; + totalTokensSaved: number; + avgSavingsPct: number; + byEngine?: Record; +}; + +type PreviewResult = { + text?: string; + compressed?: boolean; + originalTokens?: number; + compressedTokens?: number; + techniquesUsed?: string[]; + detection?: { type: string; confidence: number; category: string }; + error?: string; +}; + +const SAMPLE_OUTPUT = `$ npm run typecheck +src/lib/example.ts:10:15 - error TS2322: Type 'string' is not assignable to type 'number'. + +10 const value: number = "bad"; + ~~~~~ + +Found 1 error in src/lib/example.ts:10`; + +function formatNumber(value: number | undefined): string { + return new Intl.NumberFormat().format(value ?? 0); +} + +export default function RtkContextPageClient() { + const t = useTranslations("contextRtk"); + const [filters, setFilters] = useState([]); + const [config, setConfig] = useState(null); + const [analytics, setAnalytics] = useState(null); + const [sample, setSample] = useState(SAMPLE_OUTPUT); + const [preview, setPreview] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + fetch("/api/context/rtk/filters") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => setFilters(Array.isArray(data?.filters) ? data.filters : [])) + .catch(() => {}); + fetch("/api/context/rtk/config") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => setConfig(data)) + .catch(() => {}); + fetch("/api/context/analytics?since=7d") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => setAnalytics(data)) + .catch(() => {}); + }, []); + + const groupedFilters = useMemo(() => { + return filters.reduce>((groups, filter) => { + groups[filter.category] = [...(groups[filter.category] ?? []), filter]; + return groups; + }, {}); + }, [filters]); + + const activeFilterCount = useMemo(() => { + if (!config) return 0; + if (config.enabledFilters.length > 0) return config.enabledFilters.length; + return filters.filter((filter) => !config.disabledFilters.includes(filter.id)).length; + }, [config, filters]); + + const saveConfig = async (patch: Partial) => { + if (!config) return; + const nextConfig = { ...config, ...patch }; + setConfig(nextConfig); + setSaving(true); + try { + const res = await fetch("/api/context/rtk/config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + if (res.ok) setConfig(await res.json()); + } finally { + setSaving(false); + } + }; + + const toggleFilter = (filterId: string, enabled: boolean) => { + if (!config) return; + const disabledFilters = enabled + ? config.disabledFilters.filter((id) => id !== filterId) + : [...new Set([...config.disabledFilters, filterId])]; + saveConfig({ disabledFilters }); + }; + + const runPreview = async () => { + const res = await fetch("/api/context/rtk/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: sample, config: config ?? undefined }), + }); + setPreview(res.ok ? await res.json() : { error: await res.text() }); + }; + + const rtkStats = analytics?.byEngine?.rtk; + const statCards = [ + [t("tokensFiltered"), formatNumber(rtkStats?.tokensSaved ?? analytics?.totalTokensSaved)], + [t("filtersActive"), formatNumber(activeFilterCount)], + [t("requests"), formatNumber(rtkStats?.count ?? analytics?.totalRequests)], + [t("avgSavings"), `${rtkStats?.avgSavingsPct ?? analytics?.avgSavingsPct ?? 0}%`], + ]; + + return ( +
+
+
+ filter_alt +
+

{t("title")}

+

{t("description")}

+
+
+
+ +
+ {statCards.map(([label, value]) => ( +
+

{label}

+

{value}

+
+ ))} +
+ + {config && ( +
+
+ + + + + + +
+
+ {[ + ["applyToToolResults", t("toolResults")], + ["applyToAssistantMessages", t("assistantMessages")], + ["applyToCodeBlocks", t("codeBlocks")], + ["customFiltersEnabled", t("customFilters")], + ["trustProjectFilters", t("trustProjectFilters")], + ].map(([key, label]) => ( + + ))} +
+
+ +
+
+ )} + +
+
+
+

{t("filterTesting")}

+ +
+