diff --git a/.agents/workflows/review-prs.md b/.agents/workflows/review-prs.md index 58a5018646..ea985ef5f2 100644 --- a/.agents/workflows/review-prs.md +++ b/.agents/workflows/review-prs.md @@ -199,19 +199,15 @@ Perform a **global impact assessment** to verify whether the PR changes are comp git push ``` -- **Fallback (For external forks without maintainer edit access):** - If `git push` fails because the PR comes from an external fork without write access, you MUST: - 1. Create a new branch ending in `-fix` (e.g., `checkout -b fix-pr-`). - 2. Push your branch to the main repo (`git push origin fix-pr-`). - 3. Create a Pull Request targeting the contributor's repository and branch (use `gh pr create --repo --base --head diegosouzapw:fix-pr-`). - 4. Once they accept our PR into their branch, their original PR to our `main` will automatically update and become green. +- **Fallback (For external forks without maintainer edit access or severe conflicts):** + If `git push` fails because the PR comes from an external fork without write access, or there are extreme conflicts, you MUST NOT CLOSE THE PR. + Instead, use `git cherry-pick`, or a reverse merge to bring their changes into the release branch, fix the issues locally, and commit them. Ensure you preserve the contributor's authorship (`git commit --author="Contributor Name "` if creating new commits). + Once you have integrated their work into the release branch, DO NOT close their PR. Instead, leave it open or try to merge it using the CLI if possible. Under NO CIRCUMSTANCES should you use `gh pr close`. Leave it open so the contributor retains credit. - Run the project's test suite locally to verify nothing breaks: // turbo - Run: `npm test` or equivalent test command -### 8. Merge into Release Branch - ### 8. Merge into Release Branch (NEVER CLOSE!) > **⚠️ CRITICAL**: NEVER use `gh pr close` for a PR whose idea or code was accepted. Closing a PR in a contributor's face after taking their idea—or closing it just because it had conflicts—is unacceptable. @@ -219,23 +215,19 @@ Perform a **global impact assessment** to verify whether the PR changes are comp Even if the PR had severe conflicts or required significant architectural adjustments, you MUST: -1. Resolve any conflicts and apply the fixes directly to their PR branch (as detailed in step 7). -2. Once the PR branch is green, conflict-free, and correct, merge it into the release branch using the GitHub CLI. - -```bash -# Merge the PR (base is already set to release/vX.Y.Z from step 3.5) -gh pr merge --repo / --squash --body "Integrated into release/vX.Y.Z" -``` +1. Resolve any conflicts and apply the fixes directly to their PR branch (as detailed in step 7) or use cherry-picking into the release branch. +2. If you managed to fix their branch, merge it into the release branch using the GitHub CLI: + `gh pr merge --repo / --squash --body "Integrated into release/vX.Y.Z"` +3. If you had to use cherry-picking because you couldn't push to their branch, DO NOT close the PR. GitHub will sometimes auto-detect the cherry-picked commits and mark it as Merged. If it doesn't, leave it open. The repository owner will handle it. NEVER run `gh pr close`. In ALL cases: - Post a **thank-you comment** on the PR via the GitHub API before or immediately after merging. - The message should: - Thank the author by name/username for their contribution. - - Explain what was adjusted or improved (if we pushed fixes to their branch). + - Explain what was adjusted or improved (if we pushed fixes to their branch or cherry-picked). - Note it will be included in the upcoming release. - Be friendly, professional, and encouraging. -- Example: _"Thanks @author for this great contribution! 🎉 We've added a few small adjustments to your branch to align with our latest architecture, and it's now officially merged into the release/vX.Y.Z branch. It will be part of the next release. We appreciate your effort!"_ ### 9. Sync Local Release Branch diff --git a/.env.example b/.env.example index e9992bfb91..089b87379c 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,9 @@ # ┌─────────────────────────────────────────────────────────────────────────────┐ -# │ OmniRoute — .env Contract │ -# │ This file documents EVERY environment variable read by the runtime. │ -# │ Copy to .env and adjust values. Lines starting with # are commented out │ -# │ (optional / off-by-default). Uncomment only what you need. │ -# │ Reference: docs/ENVIRONMENT.md for full details and usage scenarios. │ +# │ OmniRoute — .env Contract │ +# │ This file documents EVERY environment variable read by the runtime. │ +# │ Copy to .env and adjust values. Lines starting with # are commented out │ +# │ (optional / off-by-default). Uncomment only what you need. │ +# │ Reference: docs/ENVIRONMENT.md for full details and usage scenarios. │ # └─────────────────────────────────────────────────────────────────────────────┘ @@ -209,6 +209,14 @@ CLOUD_URL= # Public-facing base URL — CRITICAL for reverse proxy / OAuth callback setups. # Used by: OAuth redirect_uri computation, Dashboard UI links, cloud/model sync. # Set to your public URL when behind nginx/Caddy (e.g., https://omniroute.example.com). +# +# Dashboard display behavior: when this variable is unset, the dashboard +# auto-detects the base URL shown in curl examples and CLI tool snippets +# from window.location.origin (the host the user is browsing). Setting it +# explicitly is only required when running behind a reverse proxy with a +# different public hostname, or when OAuth callbacks must point to a +# canonical URL. +# # Default: http://localhost:20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 diff --git a/CHANGELOG.md b/CHANGELOG.md index 80b3a71240..9285cab7f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ ## [3.7.9] — 2026-05-03 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) - **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. @@ -21,6 +25,9 @@ ### 🐛 Bug Fixes +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941, #1945) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) +- **fix(mcp):** reclassify MCP endpoints to ensure API key authentication works even when dashboard auth is enabled (#1970) - **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) @@ -56,6 +63,12 @@ ## [3.7.8] — 2026-05-01 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** add Grok 4.3 and Xiaomi Mimo TTS provider (#1837) - **feat(core):** implement Rate Limit Watchdog with environment override capability to detect and reset stalled queues (#1839) @@ -88,6 +101,12 @@ ## [3.7.7] — 2026-04-30 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **Prompt Compression Pipeline:** Implemented a multi-phase prompt compression engine including `lite` (whitespace/duplication collapse), `aggressive` (summarization, tool compression), and `ultra` modes (heuristic pruning and SLM stub) (#1633, #1738, #1739, #1741) - **Compression Dashboard & Analytics:** Added a compression settings UI, real-time log viewer, pipeline statistics tracking, and interactive playground preview (#1756) @@ -113,6 +132,12 @@ ## [3.7.6] — 2026-04-30 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(api-keys):** add rename support in the permissions modal — editable key name field with validation (#1796) - **feat(chatgpt-web):** support `thinking_effort` parameter (Standard/Extended) for thinking-capable models (#1821) @@ -209,6 +234,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.7.5] — 2026-04-29 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(tunnels):** integrate native ngrok tunnel support with dashboard UI parity (#1753) @@ -254,6 +285,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.7.4] — 2026-04-28 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(ui):** add endpoint tunnel visibility settings (#1743) - **feat(cli):** refresh CLI fingerprint provider profiles (#1746) @@ -310,6 +347,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.7.2] — 2026-04-28 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(authz):** introduce centralized proxy-based authz pipeline and lifecycle policy (#1632) - **feat(logs):** configure call log pipeline artifacts (#1650) @@ -379,6 +422,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.7.1] — 2026-04-26 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Add GPT-5.5 support to the Codex provider — includes 1.05M context window, tool calling, vision, and reasoning capabilities with proper pricing entries across `cx` and `openai` providers. Refactors `splitCodexReasoningSuffix()` into a shared helper for cleaner effort-level parsing (#1617 — thanks @Zhaba1337228). - **feat(cli):** Add `omniroute reset-encrypted-columns` recovery command — nulls encrypted credential columns (`api_key`, `access_token`, `refresh_token`, `id_token`) in `provider_connections` while preserving provider metadata, giving users affected by #1622 a clean recovery path without losing configurations. @@ -420,6 +469,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.7.0] — 2026-04-26 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -578,6 +633,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.6.9] — 2026-04-19 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -660,6 +721,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.6.8] — 2026-04-17 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -740,6 +807,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.6.6] — 2026-04-15 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -816,6 +889,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.6.5] — 2026-04-13 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -880,6 +959,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.6.4] — 2026-04-12 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -980,6 +1065,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.6.3] — 2026-04-11 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -1018,6 +1109,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.6.2] — 2026-04-11 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -1050,6 +1147,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.6.1] — 2026-04-10 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -1079,6 +1182,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.6.0] — 2026-04-10 ### ✨ New Features & Analytics +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -1109,6 +1218,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.5.9] — 2026-04-09 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -1142,6 +1257,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.5.8] — 2026-04-09 ### ✨ New Features & Analytics +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -1194,6 +1315,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.5.6] — 2026-04-09 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -1240,6 +1367,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.5.5] — 2026-04-08 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -1287,6 +1420,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.5.4] — 2026-04-07 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -1375,6 +1514,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.5.2] — 2026-04-05 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -1407,6 +1552,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.5.1] — 2026-04-04 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -1435,6 +1586,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.5.0] — 2026-04-03 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -1539,6 +1696,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.4.6] - 2026-04-02 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -1573,6 +1736,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.4.5] - 2026-04-02 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -1635,6 +1804,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.4.3] - 2026-04-02 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -1695,6 +1870,12 @@ We identified that **155 community PRs** across the entire project history (from > On the first startup after upgrading, OmniRoute archives legacy request logs from `DATA_DIR/logs/`, legacy `DATA_DIR/call_logs/`, and `DATA_DIR/log.txt` into `DATA_DIR/log_archives/*.zip`, then removes the deprecated layout and switches to the new unified artifact format under `DATA_DIR/call_logs/`. ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -1869,6 +2050,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.3.5] - 2026-03-30 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -1899,6 +2086,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.3.4] - 2026-03-30 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -1960,6 +2153,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.3.2] - 2026-03-29 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -2170,6 +2369,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.2.2] — 2026-03-29 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -2198,6 +2403,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.2.1] — 2026-03-29 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -2230,6 +2441,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.2.0] — 2026-03-28 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -2284,6 +2501,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.1.9] — 2026-03-28 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -2479,6 +2702,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.1.1] — 2026-03-26 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -2513,6 +2742,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.1.0] — 2026-03-26 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -2621,6 +2856,12 @@ We identified that **155 community PRs** across the entire project history (from - **Proxy Test:** Test endpoint now resolves real credentials from DB via proxyId ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -2657,6 +2898,12 @@ We identified that **155 community PRs** across the entire project history (from - **Settings:** Proxy test button now shows success/failure results immediately (previously hidden behind health data) ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -2675,6 +2922,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.0.5] — 2026-03-25 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -2710,6 +2963,12 @@ We identified that **155 community PRs** across the entire project history (from ## [3.0.3] — 2026-03-25 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -3155,6 +3414,12 @@ docker pull diegosouzapw/omniroute:3.0.0 ## [3.0.0-rc.16] — 2026-03-24 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -3170,6 +3435,12 @@ docker pull diegosouzapw/omniroute:3.0.0 ## [3.0.0-rc.15] — 2026-03-24 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -3267,6 +3538,12 @@ docker pull diegosouzapw/omniroute:3.0.0 ## [3.0.0-rc.9] — 2026-03-23 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -3314,6 +3591,12 @@ Both providers use the new `OpencodeExecutor` with multi-format routing (`/chat/ --- ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -3449,6 +3732,12 @@ OmniRoute now automatically refreshes model lists for connected providers every ## [3.0.0-rc.5] - 2026-03-22 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -3472,6 +3761,12 @@ OmniRoute now automatically refreshes model lists for connected providers every ## [3.0.0-rc.4] - 2026-03-22 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -3488,6 +3783,12 @@ OmniRoute now automatically refreshes model lists for connected providers every ## [3.0.0-rc.3] - 2026-03-22 ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -3697,6 +3998,12 @@ OmniRoute now automatically refreshes model lists for connected providers every > Sprint: Cross-platform machineId fix, per-API-key rate limits, streaming context cache, Alibaba DashScope, search analytics, ZWS v5, and 8 issues closed. ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -4164,6 +4471,12 @@ OmniRoute now automatically refreshes model lists for connected providers every > Sprint: Unified web search routing (POST /v1/search) with 5 providers + Next.js 16.1.7 security fixes (6 CVEs). ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -4384,6 +4697,12 @@ OmniRoute now automatically refreshes model lists for connected providers every > Sprint: reasoning model param filtering, local provider 404 fix, Kilo Gateway provider, dependency bumps. ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -4663,6 +4982,12 @@ OmniRoute now automatically refreshes model lists for connected providers every - **fix(electron) #379**: New `scripts/prepare-electron-standalone.mjs` stages a dedicated `/.next/electron-standalone` bundle before Electron packaging. Aborts with a clear error if `node_modules` is a symlink (electron-builder would ship a runtime dependency on the build machine). Cross-platform path sanitization via `path.basename`. By @kfiramar. ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -4728,6 +5053,12 @@ OmniRoute now automatically refreshes model lists for connected providers every > Codex account quota policy with auto-rotation, fast tier toggle, gpt-5.4 model, and analytics label fix. ### ✨ New Features (PRs #366, #367, #368) +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -4756,6 +5087,12 @@ OmniRoute now automatically refreshes model lists for connected providers every > Major release: strict-random routing strategy, API key access controls, connection groups, external pricing sync, and critical bug fixes for thinking models, combo testing, and tool name validation. ### ✨ New Features (PRs #363 & #365) +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -4792,6 +5129,12 @@ OmniRoute now automatically refreshes model lists for connected providers every > API Key Round-Robin support for multi-key provider setups, and confirmation of wildcard routing and quota window rolling already in place. ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -4809,6 +5152,12 @@ OmniRoute now automatically refreshes model lists for connected providers every > UI polish, routing strategy additions, and graceful error handling for usage limits. ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -4839,6 +5188,12 @@ OmniRoute now automatically refreshes model lists for connected providers every > Multiple improvements from community issue analysis, new provider support, bug fixes for token tracking, model routing, and streaming reliability. ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). @@ -4962,6 +5317,12 @@ OmniRoute now automatically refreshes model lists for connected providers every - **Tier Scoring (API + Validation)**: Added `tierPriority` (weight `0.05`) to the `ScoringWeights` Zod schema and the `combos/auto` API route — the 7th scoring factor is now fully accepted by the REST API and validated on input. `stability` weight adjusted from `0.10` to `0.05` to keep total sum = `1.0`. ### ✨ New Features +- **feat(docs):** integrate multi-page documentation into OmniRoute dashboard (#1969) +- **feat(settings):** add request body limit setting (#1968) +- **feat(auth):** add Gemini CLI OAuth client secret default (#1974) +- **feat(models):** expose models.dev context windows in /v1/models (#1972) +- **fix(db):** resolve legacy encryption fallback causing re-encryption loops (#1941) +- **fix(auth):** fix Codex assistant final_answer response sanitization (#1965) - **feat(providers):** Implement Image Generation and Editing capabilities for ChatGPT Web, including in-band chat image generation and caching (#1606). - **feat(ui):** Integrate OpenCode Zen/Go API tool logo SVG and polish API key copy-to-clipboard interactions (#1607). diff --git a/bin/nodeRuntimeSupport.mjs b/bin/nodeRuntimeSupport.mjs index 8c203f0722..0dfdd81424 100644 --- a/bin/nodeRuntimeSupport.mjs +++ b/bin/nodeRuntimeSupport.mjs @@ -4,12 +4,14 @@ export const SECURE_NODE_LINES = Object.freeze([ Object.freeze({ major: 20, minor: 20, patch: 2 }), Object.freeze({ major: 22, minor: 22, patch: 2 }), Object.freeze({ major: 24, minor: 0, patch: 0 }), + Object.freeze({ major: 25, minor: 0, patch: 0 }), + Object.freeze({ major: 26, minor: 0, patch: 0 }), ]); export const RECOMMENDED_NODE_VERSION = "24.14.1"; -export const SUPPORTED_NODE_RANGE = ">=20.20.2 <21 || >=22.22.2 <23 || >=24.0.0 <25"; +export const SUPPORTED_NODE_RANGE = ">=20.20.2 <21 || >=22.22.2 <23 || >=24.0.0 <27"; export const SUPPORTED_NODE_DISPLAY = - "Node.js 20.20.2+ (20.x LTS), 22.22.2+ (22.x LTS), or 24.0.0+ (24.x LTS)"; + "Node.js 20.20.2+ (20.x LTS), 22.22.2+ (22.x LTS), 24.0.0+ (24.x LTS), 25.0.0+ (25.x), or 26.0.0+ (26.x)"; function formatVersion(version) { return `${version.major}.${version.minor}.${version.patch}`; @@ -52,7 +54,7 @@ export function getNodeRuntimeSupport(version = process.versions.node) { reason = "supported"; } else if (secureFloor) { reason = "below-security-floor"; - } else if (parsed.major >= 25) { + } else if (parsed.major >= 27) { reason = "unreleased-major"; } @@ -76,7 +78,7 @@ export function getNodeRuntimeWarning(version = process.versions.node) { } if (support.reason === "unreleased-major") { - return `Node.js ${support.nodeVersion} is outside the supported LTS lines. OmniRoute currently supports Node.js 20.x, 22.x, and 24.x.`; + return `Node.js ${support.nodeVersion} is outside the supported LTS lines. OmniRoute currently supports Node.js 20.x, 22.x, 24.x, 25.x, and 26.x.`; } return `Node.js ${support.nodeVersion} is outside OmniRoute's approved secure runtime policy.`; diff --git a/docs-overview.png b/docs-overview.png new file mode 100644 index 0000000000..8782d4c4de Binary files /dev/null and b/docs-overview.png differ diff --git a/docs/RFC-AUTO-ASSESSMENT.md b/docs/RFC-AUTO-ASSESSMENT.md new file mode 100644 index 0000000000..3d9558aeae --- /dev/null +++ b/docs/RFC-AUTO-ASSESSMENT.md @@ -0,0 +1,518 @@ +# RFC: Auto-Assessment & Self-Healing Combo Engine + +## Summary + +Omniroute's combo system currently requires manual configuration: users must know which providers and models are actually working, then manually wire them into combo chains. When providers fail (rate limits, auth errors, model deprecation), combos silently degrade — routing to dead endpoints that timeout or return errors. There is no automated way to: + +1. **Discover** which provider/model pairs actually respond to chat completions +2. **Categorize** models by capability (coding, reasoning, vision, speed, etc.) +3. **Self-heal** combos by removing dead models and promoting working ones +4. **Auto-generate** sensible combo configurations from available providers + +This proposes an **Auto-Assessment Engine** that continuously tests, categorizes, and self-heals combo configurations — making omniroute truly "plug and play" for non-technical users. + +--- + +## Problem Statement + +### What we encountered (real production incident) + +While configuring omniroute for production use, we discovered: + +- **44 combos had models from providers that returned "`Invalid model`" errors** — `kiro/claude-opus-4.6`, `kiro/claude-sonnet-4.6`, `gh/claude-sonnet-4.5` all fail with 400/404 +- **No automated way to know which models actually work** — the `/v1/models` endpoint lists 1,236 models, but `/v1/chat/completions` fails for most of them +- **Weight-based routing sends traffic to dead models** — a model weighted at 30% that returns errors wastes 30% of requests +- **Manual diagnosis took hours** — we had to curl each model individually, categorize results, then update the SQLite DB +- **Provider `test_status` field exists but isn't used for routing** — `provider_connections` has `test_status` (active/banned/expired/credits_exhausted) but the combo resolver ignores it + +### Current flow (broken) + +``` +User adds providers → Manually creates combos → Manually assigns models → ??? + ↓ + Some models work, + some return errors, + some timeout... + BUT routing doesn't know! +``` + +### Proposed flow (self-healing) + +``` +User adds providers → Auto-Assessment runs → Working models discovered + ↓ + Capability categorization + ↓ + Combos auto-generated/updated with working models + ↓ + Continuous health monitoring keeps combos healthy +``` + +--- + +## Architecture + +### New Components + +``` +┌─────────────────────────────────────────────────────────┐ +│ Auto-Assessment Engine │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Assessor │ │ Categorizer │ │ Self-Healer │ │ +│ │ │ │ │ │ │ │ +│ │ • Probe all │ │ • Classify │ │ • Remove │ │ +│ │ models │ │ models by │ │ dead │ │ +│ │ • Measure │ │ capability │ │ models │ │ +│ │ latency │ │ • Assign │ │ • Promote │ │ +│ │ • Track │ │ tier tags │ │ working │ │ +│ │ success │ │ • Build │ │ models │ │ +│ │ rates │ │ fitness │ │ • Re-weight │ │ +│ │ │ │ scores │ │ combos │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Assessment Database │ │ +│ │ │ │ +│ │ model_assessments: │ │ +│ │ model_id | provider | status | latency_p50 │ │ +│ │ latency_p95 | success_rate | last_tested │ │ +│ │ error_type | tier | categories[] | fitness │ │ +│ │ context_window | output_tokens | vision | tbc │ │ +│ │ │ │ +│ │ assessment_runs: │ │ +│ │ run_id | started_at | completed_at │ │ +│ │ models_tested | models_passed | models_failed │ │ +│ │ │ │ +│ │ combo_health: │ │ +│ │ combo_id | healthy_models | dead_models │ │ +│ │ last_auto_fix | auto_fix_count │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +└──────────────────────────────┬──────────────────────────┘ + │ + ▼ + Existing combo system (weighted-fallback, priority, etc.) + + Enhanced comboResolver that skips dead models +``` + +--- + +## Detailed Design + +### 1. Assessor — `src/domain/assessor.ts` + +**Purpose**: Probe every provider/model pair with a lightweight chat completion to determine if it works and measure performance. + +```typescript +interface ModelAssessment { + modelId: string; + providerId: string; + status: "working" | "broken" | "rate_limited" | "timeout" | "auth_error" | "unknown"; + + // Performance metrics + latencyP50: number; // milliseconds + latencyP95: number; // milliseconds + successRate: number; // 0..1 over last N probes + + // Capability detection + supportsVision: boolean; + supportsToolCall: boolean; + supportsStreaming: boolean; + maxContextWindow: number; + maxOutputTokens: number; + categories: ModelCategory[]; // 'coding' | 'reasoning' | 'chat' | 'fast' | 'vision' | 'reasoning_deep' + tier: "premium" | "balanced" | "fast" | "free"; + + // Metadata + lastTested: string; // ISO timestamp + lastError: string | null; + consecutiveFails: number; + probeCount: number; +} + +type ModelCategory = + | "coding" // Good at code generation, debugging, refactoring + | "reasoning" // Strong logical reasoning, math, analysis + | "reasoning_deep" // Extended thinking, complex multi-step reasoning + | "chat" // Good conversational ability + | "fast" // Sub-2s response time + | "vision" // Image input support + | "tool_call" // Function/tool calling support + | "structured_output"; // JSON mode / structured output +``` + +**Assessment Probes** — three tiers of testing: + +| Probe | Prompt | Max Tokens | Purpose | +| ------------ | ------------------------------------------ | ---------- | ---------------------------------------------- | +| **Quick** | `"ok"` | 1 | Does it respond at all? | +| **Standard** | `"Write a function that adds two numbers"` | 50 | Coding, tool call, structured output detection | +| **Deep** | Vision input + multi-turn | 100 | Vision, streaming, context window | + +**Scheduling**: + +- Full assessment on startup (or first provider addition) +- Quick probe every 5 minutes for working models +- Standard probe every 30 minutes +- Deep probe every 6 hours (or on demand) +- Immediate probe after any model returns an error +- Exponential backoff: 1 min → 5 min → 15 min → 30 min for consistently failing models + +### 2. Categorizer — `src/domain/categorizer.ts` + +**Purpose**: Classify each working model into capability categories and assign fitness scores per category. + +**Category Detection Logic**: + +```typescript +function categorizeModel(assessment: ModelAssessment): ModelCategory[] { + const categories: ModelCategory[] = []; + + // Speed classification + if (assessment.latencyP50 < 2000) categories.push("fast"); + + // Capability from probe responses + if (assessment.supportsToolCall) categories.push("tool_call"); + if (assessment.supportsVision) categories.push("vision"); + if (assessment.supportsStreaming) categories.push("structured_output"); // if supports JSON mode + + // Tier-based reasoning classification + if (assessment.tier === "premium") { + categories.push("reasoning_deep", "coding", "reasoning"); + } else if (assessment.tier === "balanced") { + categories.push("coding", "reasoning"); + } else if (assessment.tier === "fast") { + categories.push("chat"); + } + + return categories; +} +``` + +**Fitness Scores** (0..1 per category): + +| Category | Scoring Formula | +| ---------------- | -------------------------------------------------------------------- | +| `coding` | `0.4 * successRate + 0.3 * (1 - latencyP95/10000) + 0.3 * tierScore` | +| `reasoning` | `0.5 * tierScore + 0.3 * successRate + 0.2 * (1 - latencyP95/15000)` | +| `reasoning_deep` | `0.7 * tierScore + 0.3 * successRate` (only premium tier eligible) | +| `chat` | `0.4 * successRate + 0.4 * (1 - latencyP95/5000) + 0.2 * tierScore` | +| `fast` | `0.6 * (1 - latencyP50/3000) + 0.3 * successRate + 0.1 * costInv` | +| `vision` | `0.5 * successRate + 0.3 * (1 - latencyP95/15000) + 0.2 * tierScore` | + +### 3. Self-Healer — `src/domain/selfHealer.ts` + +**Purpose**: Automatically update combo model lists based on assessment results. + +**Auto-Heal Rules**: + +``` +IF model.status == 'broken' OR model.consecutiveFails >= 3: + REMOVE model from all combos + LOG "Auto-heal: removed {model} from {combo} (status: {status}, fails: {n})" + +IF model.status == 'rate_limited' OR model.status == 'timeout': + REDUCE model weight by 50% (minimum weight: 5) + LOG "Auto-heal: reduced weight of {model} in {combo} (status: {status})" + +IF combo has 0 working models: + FIND best working model for combo's category + ADD to combo with weight 100 + LOG "Auto-heal: emergency added {model} to {combo} (was empty)" + +IF combo has fewer than 3 working models: + FIND additional working models in same category + ADD with proportional weights + LOG "Auto-heal: expanded {combo} with {n} models" + +IF model.status transitions from 'broken' → 'working': + RESTORE original weight (or proportional weight) + LOG "Auto-heal: restored {model} in {combo}" +``` + +**Auto-Generation of Combos**: + +When a new provider is added, or on first startup, auto-generate standard combos: + +```typescript +const AUTO_COMBOS = [ + { name: "auto/best-coding", categories: ["coding"], tier: ["premium", "balanced"] }, + { name: "auto/best-reasoning", categories: ["reasoning_deep"], tier: ["premium"] }, + { name: "auto/best-fast", categories: ["fast"], tier: ["fast", "balanced"] }, + { name: "auto/best-vision", categories: ["vision"], tier: ["premium", "balanced"] }, + { name: "auto/best-chat", categories: ["chat"], tier: ["balanced", "premium"] }, + { name: "auto/coding", categories: ["coding"], tier: ["balanced", "fast", "premium"] }, + { name: "auto/fast", categories: ["fast"], tier: ["fast"] }, + { name: "auto/pro-coding", categories: ["coding"], tier: ["premium"] }, + { name: "auto/pro-reasoning", categories: ["reasoning_deep"], tier: ["premium"] }, + { name: "auto/pro-vision", categories: ["vision"], tier: ["premium"] }, + { name: "auto/pro-chat", categories: ["chat"], tier: ["premium"] }, + { name: "auto/pro-fast", categories: ["fast"], tier: ["fast"] }, +]; +``` + +### 4. Database Schema + +```sql +-- New tables for assessment engine + +CREATE TABLE IF NOT EXISTS model_assessments ( + id TEXT PRIMARY KEY, + model_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'unknown', -- working|broken|rate_limited|timeout|auth_error|unknown + latency_p50 INTEGER, -- milliseconds + latency_p95 INTEGER, -- milliseconds + success_rate REAL DEFAULT 0, -- 0..1 + supports_vision INTEGER DEFAULT 0, + supports_tool_call INTEGER DEFAULT 0, + supports_streaming INTEGER DEFAULT 0, + supports_structured_output INTEGER DEFAULT 0, + max_context_window INTEGER, + max_output_tokens INTEGER, + categories TEXT DEFAULT '[]', -- JSON array of ModelCategory + fitness_scores TEXT DEFAULT '{}', -- JSON object: {category: score} + tier TEXT DEFAULT 'balanced', -- premium|balanced|fast|free + last_tested TEXT, + last_error TEXT, + consecutive_fails INTEGER DEFAULT 0, + probe_count INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(model_id, provider_id) +); + +CREATE TABLE IF NOT EXISTS assessment_runs ( + id TEXT PRIMARY KEY, + started_at TEXT NOT NULL, + completed_at TEXT, + models_tested INTEGER DEFAULT 0, + models_passed INTEGER DEFAULT 0, + models_failed INTEGER DEFAULT 0, + models_rate_limited INTEGER DEFAULT 0, + duration_ms INTEGER, + trigger TEXT DEFAULT 'scheduled', -- scheduled|on_demand|on_provider_change|on_error + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS combo_health ( + combo_id TEXT PRIMARY KEY, + healthy_model_count INTEGER DEFAULT 0, + dead_model_count INTEGER DEFAULT 0, + total_model_count INTEGER DEFAULT 0, + last_auto_fix TEXT, + auto_fix_count INTEGER DEFAULT 0, + health_score REAL DEFAULT 0, -- 0..1, weighted by model health + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (combo_id) REFERENCES combos(id) +); + +CREATE INDEX IF NOT EXISTS idx_model_assessments_status ON model_assessments(status); +CREATE INDEX IF NOT EXISTS idx_model_assessments_provider ON model_assessments(provider_id); +CREATE INDEX IF NOT EXISTS idx_model_assessments_tier ON model_assessments(tier); +CREATE INDEX IF NOT EXISTS idx_combo_health_health_score ON combo_health(health_score); +``` + +### 5. API Endpoints + +```bash +# Trigger assessment (blocking or background) +POST /api/assess/models + Body: { "scope": "all" | "provider:" | "model:", "tier": "quick" | "standard" | "deep" } + Response: { "run_id": "...", "status": "started" } + +# Get assessment results +GET /api/assess/results + Query: ?status=working|broken|rate_limited&provider=kiro&category=coding + Response: { "models": [...] } + +# Get combo health dashboard +GET /api/assess/combo-health + Response: { "combos": [{ "id": "...", "name": "...", "healthy_models": 5, "dead_models": 2, "health_score": 0.71 }] } + +# Auto-fix all combos +POST /api/assess/auto-fix + Response: { "fixed_combos": 3, "removed_models": ["ollamacloud/glm-5.1", "..."], "added_models": [...] } + +# Auto-generate combos from assessments +POST /api/assess/auto-generate + Response: { "generated_combos": ["auto/best-coding", "..."], "models_per_combo": { "auto/best-coding": 5 } } + +# Get assessment run history +GET /api/assess/runs + Response: { "runs": [...] } +``` + +### 6. Integration with Existing comboResolver + +The existing `comboResolver.ts` already handles `priority`, `weighted`, `round-robin`, `random`, and `least-used` strategies. The enhancement: + +```typescript +// In comboResolver.ts — add health-aware filtering +export function resolveComboModel(combo, context = {}) { + const models = combo.models || []; + if (models.length === 0) { + throw new Error(`Combo "${combo.name}" has no models configured`); + } + + const normalized = models + .map((entry) => ({ + model: getComboStepTarget(entry) || "", + weight: getComboStepWeight(entry) || 1, + })) + .filter((entry) => entry.model); + + // NEW: Filter out models known to be broken/rate_limited + const healthy = normalized.filter((entry) => { + const assessment = getAssessment(entry.model); + if (!assessment) return true; // Unknown → allow (haven't tested yet) + return assessment.status === "working" || assessment.status === "unknown"; + }); + + // If all models are unhealthy, fall back to full list (better to try than to fail) + const pool = healthy.length > 0 ? healthy : normalized; + + const strategy = combo.strategy || "priority"; + // ... existing resolution logic using `pool` instead of `normalized` +} +``` + +### 7. Integration with Existing Auto-Combo Scoring (`open-sse/services/autoCombo/scoring.ts`) + +The existing scoring function already uses 6 factors + tier. The assessment engine enriches these: + +| Existing Factor | Current Source | Enhanced Source | +| ------------------- | ------------------------ | ----------------------------------------- | +| Quota (0.20) | Provider connection data | Same + assessment success_rate | +| Health (0.25) | Circuit breaker state | Same + assessment status (working/broken) | +| CostInv (0.20) | Static cost data | Same + live cost measurement | +| LatencyInv (0.15) | P95 latency from logs | Same + assessment probe latency | +| TaskFit (0.10) | Static fitness table | **NEW: from assessment categories** | +| Stability (0.05) | Latency stddev | Same + assessment consecutive_fails | +| TierPriority (0.05) | Account tier | Same | + +The key enhancement: `taskFit` currently uses a **static** fitness lookup (`taskFitness.ts`). With assessments, we derive fitness from **live probe results** — a model that actually passes coding probes gets a high `coding` fitness, not just because its name contains "coder". + +### 8. Dashboard UI (Future PR) + +The assessment state should be visible in the omniroute dashboard: + +- **Model Health Grid**: table showing each provider/model with colored status indicators +- **Combo Health Summary**: per-combo health score with expandable model details +- **Assessment History**: timeline of assessment runs with pass/fail counts +- **Auto-Fix Log**: history of automatic combo modifications + +--- + +## Migration Path + +### Phase 1: Assessment Engine (This PR) + +- New `model_assessments`, `assessment_runs`, `combo_health` tables +- Assessor service with quick/standard/deep probes +- Categorizer with fitness scoring +- Self-healer with auto-fix rules +- REST API endpoints +- Integration with comboResolver to skip broken models + +### Phase 2: Auto-Generation (Follow-up PR) + +- Auto-generate combos from assessed models +- Smart combo naming and categorization +- Cross-provider fallback chains +- Dashboard UI for assessment results + +### Phase 3: Continuous Learning (Follow-up PR) + +- Feeds assessment results back into auto-combo scoring weights +- Adapts fitness scores based on real request outcomes +- A/B testing across providers to find optimal routing +- Automatic mode pack switching (ship-fast during peak, cost-saver off-peak) + +--- + +## Implementation Files + +| File | Purpose | New/Modified | +| ---------------------------------------- | ---------------------------------------- | ------------ | +| `src/domain/assessor.ts` | Probe engine (quick/standard/deep) | **NEW** | +| `src/domain/categorizer.ts` | Model categorization & fitness | **NEW** | +| `src/domain/selfHealer.ts` | Auto-fix combos, remove dead models | **NEW** | +| `src/domain/comboResolver.ts` | Add health-aware filtering | **MODIFIED** | +| `src/domain/types.ts` | Add ModelAssessment, ModelCategory types | **MODIFIED** | +| `src/lib/db/assessments.ts` | DB access for assessment tables | **NEW** | +| `open-sse/services/autoCombo/scoring.ts` | Use assessment data for taskFit | **MODIFIED** | +| `src/app/api/assess/route.ts` | REST API routes | **NEW** | +| `scripts/assess-models.mjs` | CLI script for on-demand assessment | **NEW** | +| `scripts/migrate-assessments.mjs` | DB migration script | **NEW** | + +--- + +## Testing Plan + +### Unit Tests + +- Assessor probe logic (mock provider responses) +- Categorizer fitness score calculations +- Self-healer rules (remove, reduce, restore, emergency add) +- comboResolver integration (skip broken models) + +### Integration Tests + +- Full assessment cycle: add provider → probe models → categorize → auto-fix combos +- Health-aware routing: broken model skipped, restored model re-included +- Assessment run persistence and recovery + +### Manual Testing (our experience as reference) + +```bash +# Our actual test sequence that should be automated: +# 1. Start omniroute with 406 provider connections (51 providers) +# 2. Discover only 8 models actually work from 2 providers (kiro, ollamacloud) +# 3. Manually update 44 combos with working models only +# 4. Verify all 15 key combos pass end-to-end +# 5. Set up auto-sync cron for model list updates + +# With auto-assessment, this entire process should be: +# 1. Start omniroute +# 2. Run: curl -X POST http://localhost:20128/api/assess/models -d '{"scope":"all"}' +# 3. Wait for assessment to complete +# 4. Run: curl -X POST http://localhost:20128/api/assess/auto-fix +# 5. All combos are now healthy +``` + +--- + +## Success Metrics + +| Metric | Current (Manual) | Target (Auto-Assessment) | +| -------------------------------- | --------------------------------- | ------------------------------- | +| Time to configure working combos | 2-4 hours | < 5 minutes | +| Dead model detection | Manual curl testing | Automatic, continuous | +| Combo health visibility | None | Dashboard + API | +| Provider failure recovery | Manual DB updates | Auto-heal within 5 minutes | +| New provider onboarding | Manual combo editing | Auto-discover + auto-categorize | +| Model deprecation handling | Manual detection (users complain) | Proactive removal + alerting | + +--- + +## Backwards Compatibility + +- All new tables are additive — no schema changes to existing tables +- comboResolver filtering is opt-in via config flag (default: enabled) +- Existing combo configurations are preserved — self-healer only modifies them, doesn't replace +- Assessment can be disabled with `OMNIRoute_DISABLE_ASSESSMENT=1` env var +- All API endpoints are new — no existing endpoints changed + +--- + +## Open Questions + +1. **Probe cost**: Who pays for assessment probes? Should we limit to free-tier models or use a separate assessment budget? +2. **Assessment frequency**: How often should deep probes run? Current proposal: 6h, but some users may want more/less frequent. +3. **Auto-fix aggression**: Should self-healer remove models immediately on first failure, or wait for N consecutive failures? +4. **Cross-provider model equivalence**: Should `kiro/claude-sonnet-4.5` and `gh/claude-sonnet-4.5` be treated as the same model for combo purposes? +5. **Assessment during startup**: Should assessment block startup or run in background? diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index dc8ccfaf9b..8cec75b755 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -131,11 +131,7 @@ export const IMAGE_PROVIDERS: Record = { authType: "oauth", authHeader: "bearer", format: "codex-responses", - models: [ - { id: "gpt-5.5", name: "GPT 5.5 (Codex Image)" }, - { id: "gpt-5.4", name: "GPT 5.4 (Codex Image)" }, - { id: "gpt-5.3-codex", name: "GPT 5.3 Codex (Image)" }, - ], + models: [{ id: "gpt-5.5", name: "GPT 5.5 (Codex Image)" }], supportedSizes: ["1024x1024", "1024x1536", "1536x1024"], }, @@ -156,7 +152,10 @@ export const IMAGE_PROVIDERS: Record = { authType: "apikey", authHeader: "bearer", format: "openai", - models: [{ id: "grok-imagine-image", name: "Grok Imagine Image" }], + models: [ + { id: "grok-imagine-image-pro", name: "Grok Imagine Image Pro" }, + { id: "grok-imagine-image", name: "Grok Imagine Image" }, + ], supportedSizes: ["1024x1024"], }, diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 063d047396..b02226a8fd 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -133,9 +133,8 @@ const KIMI_CODING_SHARED = { "Anthropic-Beta": ANTHROPIC_BETA_API_KEY, }, models: [ - { id: "kimi-k2.5", name: "Kimi K2.5" }, - { id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" }, - { id: "kimi-latest", name: "Kimi Latest" }, + { id: "kimi-k2.6", name: "Kimi K2.6" }, + { id: "kimi-k2.6-thinking", name: "Kimi K2.6 Thinking" }, ] as RegistryModel[], } as const; @@ -384,11 +383,9 @@ export const REGISTRY: Record = { tokenUrl: "https://auth.openai.com/oauth/token", }, models: [ - { id: "codex-auto-review", name: "Codex Auto Review", targetFormat: "openai-responses" }, { id: "gpt-5.5-xhigh", name: "GPT 5.5 (xHigh)", ...GPT_5_5_CODEX_CAPABILITIES }, { id: "gpt-5.5-high", name: "GPT 5.5 (High)", ...GPT_5_5_CODEX_CAPABILITIES }, { id: "gpt-5.5-medium", name: "GPT 5.5 (Medium)", ...GPT_5_5_CODEX_CAPABILITIES }, - { id: "gpt-5.5", name: "GPT 5.5", ...GPT_5_5_CODEX_CAPABILITIES }, { id: "gpt-5.5-low", name: "GPT 5.5 (Low)", ...GPT_5_5_CODEX_CAPABILITIES }, { id: "gpt-5.4", name: "GPT 5.4", targetFormat: "openai-responses" }, { id: "gpt-5.4-mini", name: "GPT 5.4 Mini", targetFormat: "openai-responses" }, @@ -493,10 +490,7 @@ export const REGISTRY: Record = { models: [ { id: "gpt-4.1", name: "GPT-4.1" }, { id: "gpt-5-mini", name: "GPT-5 Mini" }, - { id: "gpt-5.2", name: "GPT-5.2" }, - { id: "gpt-5.2-codex", name: "GPT-5.2 Codex", targetFormat: "openai-responses" }, { id: "gpt-5.3-codex", name: "GPT-5.3 Codex", targetFormat: "openai-responses" }, - { id: "gpt-5.4-nano", name: "GPT-5.4 Nano", targetFormat: "openai-responses" }, { id: "gpt-5.4-mini", name: "GPT-5.4 Mini", targetFormat: "openai-responses" }, { id: "gpt-5.4", name: "GPT-5.4", targetFormat: "openai-responses" }, { id: "gpt-5.5", name: "GPT-5.5", ...GPT_5_5_CODEX_CAPABILITIES }, @@ -510,7 +504,7 @@ export const REGISTRY: Record = { { id: "gemini-3-flash-preview", name: "Gemini 3 Flash" }, { id: "grok-code-fast-1", name: "Grok Code Fast 1" }, { id: "oswe-vscode-prime", name: "Raptor Mini" }, - //{id: "?", name: "Goldeneye" }, + //{ id: "?", name: "Goldeneye" }, ], }, @@ -2083,6 +2077,20 @@ export const REGISTRY: Record = { { id: "Hermes-4-70B", name: "Hermes 4 70B (Nous Research)" }, ], }, + + reka: { + id: "reka", + alias: "reka", + format: "openai", + executor: "default", + baseUrl: "https://api.reka.ai/v1/chat/completions", + authType: "apikey", + authHeader: "bearer", + models: [ + { id: "reka-flash-3", name: "Reka Flash 3" }, + { id: "reka-edge-2603", name: "Reka Edge 2603" }, + ], + }, }; // ── Generator Functions ─────────────────────────────────────────────────── diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 6abe29c8bb..34f1e744cc 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -295,66 +295,72 @@ export class AntigravityExecutor extends BaseExecutor { } const upstreamModel = cleanModelName(model); + const isClaude = upstreamModel.toLowerCase().includes("claude"); const baseBody = body && typeof body === "object" ? body : {}; const normalizedBody = shouldStripCloudCodeThinking(this.provider, upstreamModel) ? stripCloudCodeThinkingConfig(baseBody) : baseBody; - // Fix contents for Claude models via Antigravity - const normalizedContents = - normalizedBody.request?.contents?.map((c) => { - let role = c.role; - // functionResponse must be role "user" for Claude models - if (c.parts?.some((p) => p.functionResponse)) { - role = "user"; + let transformedRequest; + + if (isClaude) { + // Claude models on Vertex AI Cloud Code expect the native Anthropic payload + // exactly as generated by openaiToClaudeRequestForAntigravity, without Gemini mappings. + transformedRequest = { + ...normalizedBody.request, + sessionId: normalizedBody.request?.sessionId || this.generateSessionId(), + }; + } else { + // Fix contents for Gemini models via Antigravity + const normalizedContents = + normalizedBody.request?.contents?.map((c) => { + let role = c.role; + if (c.parts?.some((p) => p.functionResponse)) { + role = "user"; + } + + const hasFunctionCall = c.parts?.some((p) => p.functionCall) || false; + + const parts = + c.parts?.filter((p) => { + if (typeof p.text === "string" && p.text === "") return false; + if (p.functionCall && !p.functionCall.name) return false; + + return !p.thought && (hasFunctionCall || !p.thoughtSignature); + }) || []; + return { ...c, role, parts }; + }) || []; + + const contents = []; + for (const c of normalizedContents) { + if (!Array.isArray(c.parts) || c.parts.length === 0) continue; + if (contents.length > 0 && contents[contents.length - 1].role === c.role) { + contents[contents.length - 1].parts.push(...c.parts); + } else { + contents.push(c); } - - const hasFunctionCall = c.parts?.some((p) => p.functionCall) || false; - - // Antigravity rejects synthetic thought text, but Gemini 3+ requires any - // returned thoughtSignature metadata to survive model tool-call turns. - const parts = - c.parts?.filter((p) => { - // Drop empty text parts - if (typeof p.text === "string" && p.text === "") return false; - // Drop empty functionCalls - if (p.functionCall && !p.functionCall.name) return false; - - return !p.thought && (hasFunctionCall || !p.thoughtSignature); - }) || []; - return { ...c, role, parts }; - }) || []; - - // Merge consecutive same-role entries and filter out empty sequences - const contents = []; - for (const c of normalizedContents) { - if (!Array.isArray(c.parts) || c.parts.length === 0) continue; - if (contents.length > 0 && contents[contents.length - 1].role === c.role) { - contents[contents.length - 1].parts.push(...c.parts); - } else { - contents.push(c); } - } - const transformedRequest = { - ...normalizedBody.request, - ...(contents.length > 0 && { contents }), - sessionId: normalizedBody.request?.sessionId || this.generateSessionId(), - safetySettings: undefined, - toolConfig: - normalizedBody.request?.tools?.length > 0 - ? { functionCallingConfig: { mode: "VALIDATED" } } - : normalizedBody.request?.toolConfig, - }; + transformedRequest = { + ...normalizedBody.request, + ...(contents.length > 0 && { contents }), + sessionId: normalizedBody.request?.sessionId || this.generateSessionId(), + safetySettings: undefined, + toolConfig: + normalizedBody.request?.tools?.length > 0 + ? { functionCallingConfig: { mode: "VALIDATED" } } + : normalizedBody.request?.toolConfig, + }; - // Obfuscate sensitive client names in user content (e.g. "OpenCode", "Cursor") - const requestContents = transformedRequest.contents; - if (Array.isArray(requestContents)) { - for (const msg of requestContents) { - if (Array.isArray(msg.parts)) { - for (const part of msg.parts) { - if (typeof part.text === "string") { - part.text = obfuscateSensitiveWords(part.text); + // Obfuscate sensitive client names in user content (e.g. "OpenCode", "Cursor") + const requestContents = transformedRequest.contents; + if (Array.isArray(requestContents)) { + for (const msg of requestContents) { + if (Array.isArray(msg.parts)) { + for (const part of msg.parts) { + if (typeof part.text === "string") { + part.text = obfuscateSensitiveWords(part.text); + } } } } @@ -627,6 +633,11 @@ export class AntigravityExecutor extends BaseExecutor { ); const finalHeaders = serializedRequest.headers; + log?.debug?.( + "TELEMETRY", + `[Antigravity] Execute - URL: ${url}, Model: ${model}, Target: ${(transformedBody as any)?.model || "unknown"}, RetryAttempt: ${retryAttemptsByUrl[urlIndex]}` + ); + const response = await fetch(url, { method: "POST", headers: finalHeaders, @@ -634,6 +645,13 @@ export class AntigravityExecutor extends BaseExecutor { signal, }); + if (!response.ok) { + log?.warn?.( + "TELEMETRY", + `[Antigravity] Error Response - URL: ${url}, Status: ${response.status}, Model: ${model}` + ); + } + // Parse retry time for 429/503 responses let retryMs = null; @@ -971,6 +989,10 @@ export class AntigravityExecutor extends BaseExecutor { }; } catch (error) { lastError = error; + log?.error?.( + "TELEMETRY", + `[Antigravity] Network/Fetch Error - URL: ${url}, Model: ${model}, Error: ${error instanceof Error ? error.message : String(error)}` + ); if (urlIndex + 1 < fallbackCount) { log?.debug?.("RETRY", `Error on ${url}, trying fallback ${urlIndex + 1}`); continue; diff --git a/open-sse/executors/chatgpt-web.ts b/open-sse/executors/chatgpt-web.ts index d550a967e4..534b02bdf3 100644 --- a/open-sse/executors/chatgpt-web.ts +++ b/open-sse/executors/chatgpt-web.ts @@ -39,7 +39,7 @@ const CONV_URL = `${CHATGPT_BASE}/backend-api/f/conversation`; const USER_LAST_USED_MODEL_CONFIG_URL = `${CHATGPT_BASE}/backend-api/settings/user_last_used_model_config`; const CHATGPT_USER_AGENT = - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"; + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0"; // Captured from a real chatgpt.com browser session (April 2026). const OAI_CLIENT_VERSION = "prod-81e0c5cdf6140e8c5db714d613337f4aeab94029"; diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 1f12173a41..83dd52309a 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -228,6 +228,19 @@ export class DefaultExecutor extends BaseExecutor { buildHeaders(credentials, stream = true) { const headers = { "Content-Type": "application/json", ...this.config.headers }; + // Allow per-provider User-Agent override via environment variable. + const providerId = this.config?.id || this.provider; + if (providerId) { + const envKey = `${providerId.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_USER_AGENT`; + const envUA = process.env[envKey]?.trim(); + if (envUA) { + headers["User-Agent"] = envUA; + if ("user-agent" in headers) { + headers["user-agent"] = envUA; + } + } + } + // T07: resolve extra keys round-robin locally since DefaultExecutor overrides BaseExecutor buildHeaders const extraKeys = (credentials.providerSpecificData?.extraApiKeys as string[] | undefined) ?? []; @@ -378,7 +391,6 @@ export class DefaultExecutor extends BaseExecutor { * "org/model-name") — we must NOT strip path segments. (Fix #493) */ transformRequest(model, body, stream, credentials) { - void model; const cleanedBody = super.transformRequest(model, body, stream, credentials); let withDefaults = applyProviderRequestDefaults(cleanedBody, this.config.requestDefaults); @@ -407,6 +419,18 @@ export class DefaultExecutor extends BaseExecutor { withDefaults = withoutStreamOptions; } } + + // #1961: Map max_tokens -> max_completion_tokens for recent OpenAI models + if (getTargetFormat(this.provider, credentials?.providerSpecificData) === "openai") { + const isRecentOpenAI = /^(o1|o3|o4|gpt-5)/i.test(model); + if (isRecentOpenAI && withDefaults && typeof withDefaults === "object") { + const defaultsRecord = withDefaults as Record; + if ("max_tokens" in defaultsRecord) { + defaultsRecord.max_completion_tokens = defaultsRecord.max_tokens; + delete defaultsRecord.max_tokens; + } + } + } } if (this.provider === "qwen" && typeof withDefaults === "object" && withDefaults !== null) { diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 10d2591149..04e2159afe 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -20,6 +20,7 @@ import { } from "../services/modelStrip.ts"; import { resolveModelAlias } from "../services/modelDeprecation.ts"; import { getUnsupportedParams } from "../config/providerRegistry.ts"; +import { supportsMaxTokens } from "@/lib/modelCapabilities.ts"; import { buildErrorBody, createErrorResult, @@ -65,6 +66,8 @@ import { getModelUpstreamExtraHeaders, getUpstreamProxyConfig, } from "@/lib/localDb"; +import { getProviderCredentials, extractSessionAffinityKey } from "@/sse/services/auth"; +import { deleteSessionAccountAffinity } from "@/lib/db/sessionAccountAffinity"; import { getExecutor } from "../executors/index.ts"; import { getCacheControlSettings } from "@/lib/cacheControlSettings"; import { guardrailRegistry, resolveDisabledGuardrails } from "@/lib/guardrails"; @@ -1080,7 +1083,7 @@ export async function handleChatCore({ | undefined) : undefined; const idempotentCost = idempotentUsage - ? await calculateCost(provider, model, idempotentUsage) + ? await calculateCost(provider, model, idempotentUsage as Record) : 0; return { success: true, @@ -1435,7 +1438,9 @@ export async function handleChatCore({ const cachedUsage = extractUsageFromResponse(cached as Record, provider) || ((cached as Record)?.usage as Record | undefined); - const cachedCost = cachedUsage ? await calculateCost(provider, model, cachedUsage) : 0; + const cachedCost = cachedUsage + ? await calculateCost(provider, model, cachedUsage as Record) + : 0; persistAttemptLogs({ status: 200, tokens: (cached as Record)?.usage, @@ -1754,7 +1759,7 @@ export async function handleChatCore({ comboOverrides: { ...(config.comboOverrides ?? {}), ...(comboName ? { [comboName]: comboMode } : {}), - ...(comboConfig?.id ? { [comboConfig.id]: comboMode } : {}), + ...(comboConfig?.id ? { [String(comboConfig.id)]: comboMode } : {}), }, }; compressionComboKey = comboName; @@ -2490,6 +2495,17 @@ export async function handleChatCore({ } } + // Rename max_tokens to max_completion_tokens if not supported (#1961) + if (!supportsMaxTokens({ provider, model })) { + if (translatedBody.max_tokens !== undefined) { + if (translatedBody.max_completion_tokens === undefined) { + translatedBody.max_completion_tokens = translatedBody.max_tokens; + } + delete translatedBody.max_tokens; + log?.debug?.("PARAMS", `Renamed max_tokens to max_completion_tokens for ${model}`); + } + } + // OpenAI's `store` parameter is not supported by most compatible providers and breaks them if (provider !== "openai" && "store" in translatedBody) { delete translatedBody.store; @@ -2704,7 +2720,16 @@ export async function handleChatCore({ async () => { trace("inside_rate_limit"); let attempts = 0; - const maxAttempts = provider === "qwen" ? 3 : 1; + const maxAttempts = provider === "qwen" ? 3 : provider === "codex" ? 3 : 1; + + // ── Codex 429 account-rotation state ───────────────────────────────── + // Track excluded connection IDs for codex failover across attempts. + const codexExcludedIds: string[] = []; + // Derive session affinity key once for codex failover (used to clear affinity on 429). + const codexSessionAffinityKey = + provider === "codex" + ? (extractSessionAffinityKey(body, clientRawRequest?.headers) ?? null) + : null; while (attempts < maxAttempts) { trace("pre_executor", { attempt: attempts }); @@ -2712,7 +2737,7 @@ export async function handleChatCore({ model: modelToCall, body: bodyToSend, stream: upstreamStream, - credentials: executionCredentials, + credentials: getExecutionCredentials(), signal: streamController.signal, log, extendedContext, @@ -2741,6 +2766,82 @@ export async function handleChatCore({ } } + // Codex 429 account-rotation failover (disabled for context-relay so combo.ts can inject handoff) + if ( + provider === "codex" && + comboStrategy !== "context-relay" && + res.response.status === 429 && + attempts < maxAttempts - 1 + ) { + const failedConnectionId = credentials?.connectionId || connectionId; + const retryAfterHeader = res.response.headers.get("retry-after"); + const retryAfterMs = retryAfterHeader ? parseFloat(retryAfterHeader) * 1000 : null; + + log?.warn?.( + "CODEX_FAILOVER", + `429 on connection ${String(failedConnectionId).slice(0, 8)} (attempt ${attempts + 1}/${maxAttempts}), rotating account` + ); + + // Mark current connection as rate-limited in the DB + if (failedConnectionId) { + const rateLimitedUntil = new Date( + Date.now() + (retryAfterMs || 60_000) + ).toISOString(); + updateProviderConnection(String(failedConnectionId), { + rateLimitedUntil, + testStatus: "unavailable", + lastError: "429 rate limited — codex account rotation", + errorCode: 429, + }).catch(() => {}); + if (!codexExcludedIds.includes(String(failedConnectionId))) { + codexExcludedIds.push(String(failedConnectionId)); + } + } + + // Clear session affinity so next request won't be pinned to the failing account + if (codexSessionAffinityKey) { + try { + deleteSessionAccountAffinity(codexSessionAffinityKey, "codex"); + } catch { + // best-effort + } + } + + // Fetch next available codex connection (excluding all previously failed ones) + const nextCreds = await getProviderCredentials("codex", null, null, null, { + excludeConnectionIds: [...codexExcludedIds], + }).catch(() => null); + + if (!nextCreds || nextCreds.allRateLimited) { + log?.warn?.("CODEX_FAILOVER", "No more codex accounts available — returning 429"); + return res; + } + + const newConnectionId = nextCreds.connectionId; + log?.info?.( + "CODEX_FAILOVER", + `Rotating codex account: ${String(failedConnectionId).slice(0, 8)} → ${newConnectionId.slice(0, 8)} (attempt ${attempts + 2}/${maxAttempts})` + ); + + logAuditEvent({ + action: "codex.account_rotation", + actor: apiKeyInfo?.name || "system", + target: newConnectionId, + details: { + failed_connection_id: failedConnectionId, + new_connection_id: newConnectionId, + attempt: attempts + 1, + retry_after_ms: retryAfterMs, + }, + }); + + // Update credentials in-place so getExecutionCredentials() picks up the new account + Object.assign(credentials, nextCreds); + + attempts++; + continue; + } + // For streaming: release the semaphore when the client drains or cancels the stream. if (stream) { const originalBody = res.response.body; @@ -2823,6 +2924,8 @@ export async function handleChatCore({ translatedBody.messages?.length || translatedBody.contents?.length || translatedBody.request?.contents?.length || + (translatedBody.conversationState?.history?.length ?? 0) + + (translatedBody.conversationState?.currentMessage ? 1 : 0) || 0; log?.debug?.("REQUEST", `${provider.toUpperCase()} | ${model} | ${msgCount} msgs`); @@ -3548,7 +3651,6 @@ export async function handleChatCore({ const msg = `[${new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" })}] 📊 [USAGE] ${provider.toUpperCase()} | ${formatUsageLog(usage)}${connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""}`; console.log(`${COLORS.green}${msg}${COLORS.reset}`); - // Track cache token metrics const inputTokens = usage.prompt_tokens || 0; const cachedTokens = toPositiveNumber( usage.cache_read_input_tokens ?? diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index f9fd2795bf..1fced15a87 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -20,6 +20,16 @@ function toNumber(value: unknown, fallback = 0): number { return Number.isFinite(parsed) ? parsed : fallback; } +function firstPositiveNumber(...values: unknown[]): number { + for (const value of values) { + const parsed = toNumber(value, 0); + if (parsed > 0) { + return parsed; + } + } + return 0; +} + function extractMessageOutputText(item: JsonRecord): string { if (!Array.isArray(item.content)) return ""; let text = ""; @@ -195,28 +205,45 @@ export function translateNonStreamingResponse( if (Object.keys(usage).length > 0) { const inputTokens = toNumber(usage.input_tokens, 0); const outputTokens = toNumber(usage.output_tokens, 0); + const inputTokensDetails = toRecord(usage.input_tokens_details); + const outputTokensDetails = toRecord(usage.output_tokens_details); + const promptTokensDetails = toRecord(usage.prompt_tokens_details); + const completionTokensDetails = toRecord(usage.completion_tokens_details); + const cachedInputTokens = firstPositiveNumber( + inputTokensDetails.cached_tokens, + promptTokensDetails.cached_tokens, + usage.cache_read_input_tokens + ); + const cacheCreationInputTokens = firstPositiveNumber( + inputTokensDetails.cache_creation_tokens, + promptTokensDetails.cache_creation_tokens, + usage.cache_creation_input_tokens + ); + const reasoningTokens = firstPositiveNumber( + outputTokensDetails.reasoning_tokens, + completionTokensDetails.reasoning_tokens, + usage.reasoning_tokens + ); + result.usage = { prompt_tokens: inputTokens, completion_tokens: outputTokens, total_tokens: inputTokens + outputTokens, }; - if (toNumber(usage.reasoning_tokens, 0) > 0) { + if (reasoningTokens > 0) { (result.usage as JsonRecord).completion_tokens_details = { - reasoning_tokens: toNumber(usage.reasoning_tokens, 0), + reasoning_tokens: reasoningTokens, }; } - if ( - toNumber(usage.cache_read_input_tokens, 0) > 0 || - toNumber(usage.cache_creation_input_tokens, 0) > 0 - ) { + if (cachedInputTokens > 0 || cacheCreationInputTokens > 0) { (result.usage as JsonRecord).prompt_tokens_details = {}; const promptDetails = (result.usage as JsonRecord).prompt_tokens_details as JsonRecord; - if (toNumber(usage.cache_read_input_tokens, 0) > 0) { - promptDetails.cached_tokens = toNumber(usage.cache_read_input_tokens, 0); + if (cachedInputTokens > 0) { + promptDetails.cached_tokens = cachedInputTokens; } - if (toNumber(usage.cache_creation_input_tokens, 0) > 0) { - promptDetails.cache_creation_tokens = toNumber(usage.cache_creation_input_tokens, 0); + if (cacheCreationInputTokens > 0) { + promptDetails.cache_creation_tokens = cacheCreationInputTokens; } } } diff --git a/open-sse/handlers/usageExtractor.ts b/open-sse/handlers/usageExtractor.ts index da1f393807..e2e87983a9 100644 --- a/open-sse/handlers/usageExtractor.ts +++ b/open-sse/handlers/usageExtractor.ts @@ -19,9 +19,13 @@ export function extractUsageFromResponse(responseBody, provider) { return { prompt_tokens: responseBody.usage.prompt_tokens || 0, completion_tokens: responseBody.usage.completion_tokens || 0, + // DeepSeek native API uses flat prompt_cache_hit_tokens (NOT + // prompt_tokens_details.cached_tokens). Fall back to it so V4 cache + // gets surfaced into kanban call_logs alongside the OpenAI/Claude paths. cached_tokens: responseBody.usage.prompt_tokens_details?.cached_tokens ?? - responseBody.usage.input_tokens_details?.cached_tokens, + responseBody.usage.input_tokens_details?.cached_tokens ?? + responseBody.usage.prompt_cache_hit_tokens, reasoning_tokens: responseBody.usage.completion_tokens_details?.reasoning_tokens ?? responseBody.usage.output_tokens_details?.reasoning_tokens, diff --git a/open-sse/mcp-server/descriptionCompressor.ts b/open-sse/mcp-server/descriptionCompressor.ts index e69a86ee4d..de08898e25 100644 --- a/open-sse/mcp-server/descriptionCompressor.ts +++ b/open-sse/mcp-server/descriptionCompressor.ts @@ -65,9 +65,9 @@ export function compressMcpDescription(description: string): DescriptionCompress const applied = applyRulesToText(text, rules).text; const normalized = applied .replace(/[ \t]{2,}/g, " ") - .replace(/[ \t]+([,.;:!?])/g, "$1") + .replace(/[ \t]([,.;:!?])/g, "$1") .replace(/\n{3,}/g, "\n\n") - .replace(/(^|[.!?][ \t]+|\n+[ \t]*)([a-z])/g, (_match, prefix: string, char: string) => { + .replace(/(^|[.!?][ \t]|\n[ \t]*)([a-z])/g, (_match, prefix: string, char: string) => { return `${prefix}${char.toUpperCase()}`; }) .trim(); diff --git a/open-sse/services/chatgptTlsClient.ts b/open-sse/services/chatgptTlsClient.ts index 2869d8dd32..53caf8689d 100644 --- a/open-sse/services/chatgptTlsClient.ts +++ b/open-sse/services/chatgptTlsClient.ts @@ -20,7 +20,7 @@ import { randomUUID } from "node:crypto"; let clientPromise: Promise | null = null; let exitHookInstalled = false; -const CHATGPT_PROFILE = "firefox_148"; // matches the Firefox 150 UA we send +const CHATGPT_PROFILE = "firefox_148"; // matches the Firefox 148 UA we send const DEFAULT_TIMEOUT_MS = 60_000; // Grace period added to the binding's wire-level timeout before our JS-level // hard timeout fires. Under healthy operation `tls-client-node` honors diff --git a/open-sse/services/compression/caveman.ts b/open-sse/services/compression/caveman.ts index 476be8d41e..b6050edf26 100644 --- a/open-sse/services/compression/caveman.ts +++ b/open-sse/services/compression/caveman.ts @@ -205,7 +205,7 @@ export function applyRulesToText( function cleanupArtifacts(text: string): string { let result = text; if (result.includes(" ")) result = result.replace(/[ \t]{2,}/g, " "); - if (/[\t ]+[,.;:!?]/.test(result)) result = result.replace(/[ \t]+([,.;:!?])/g, "$1"); + 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(); @@ -216,12 +216,9 @@ function cleanupArtifacts(text: string): string { } function recapitalizeSentences(text: string): string { - return text.replace( - /(^|[.!?][ \t]+|\n+[ \t]*)([a-z])/g, - (_match, prefix: string, char: string) => { - return `${prefix}${char.toUpperCase()}`; - } - ); + return text.replace(/(^|[.!?][ \t]|\n[ \t]*)([a-z])/g, (_match, prefix: string, char: string) => { + return `${prefix}${char.toUpperCase()}`; + }); } function createCavemanStats( @@ -365,7 +362,7 @@ export function cavemanCompress( const { text: rulesApplied, appliedRules } = applyRulesToText(extractedText, rules); allAppliedRules.push(...appliedRules); - const normalized = cleanupArtifacts(recapitalizeSentences(rulesApplied)); + const normalized = recapitalizeSentences(cleanupArtifacts(rulesApplied)); const cleaned = blocks.length > 0 ? cleanupArtifacts(restorePreservedBlocks(normalized, blocks)) diff --git a/open-sse/services/compression/validation.ts b/open-sse/services/compression/validation.ts index 7f2ae31115..152b8a2221 100644 --- a/open-sse/services/compression/validation.ts +++ b/open-sse/services/compression/validation.ts @@ -59,7 +59,7 @@ export function validateCompression(original: string, compressed: string): Valid ); requireExactPresence( "markdown link", - collectMatches(original, /\[[^\]\n]{1,1000}\]\([^)[ \t\n]{1,2000}(?:[ \t]+"[^"]{0,1000}")?\)/g), + collectMatches(original, /\[[^\]\n]{1,1000}\]\([^)\n]{1,2000}\)/g), compressed, errors ); @@ -67,7 +67,7 @@ export function validateCompression(original: string, compressed: string): Valid requireExactPresence("heading", collectMatches(original, /^#{1,6}\s+.+$/gm), compressed, errors); requireExactPresence( "table row", - collectMatches(original, /^[ \t]*\|(?:[^|\n]{0,1000}\|){1,100}[ \t]*$/gm), + collectMatches(original, /^[ \t]*\|(?:[^|\n]{0,1000}\|){1,100}/gm), compressed, errors ); diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index 4678be8ca4..fcf3132746 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -73,7 +73,7 @@ for (const [aliasOrId, models] of Object.entries(PROVIDER_MODELS)) { } } const KNOWN_MODEL_IDS = new Set(MODEL_TO_PROVIDERS.keys()); -const CODEX_PREFERRED_UNPREFIXED_MODELS = new Set(["codex-auto-review", "gpt-5.5"]); +const CODEX_PREFERRED_UNPREFIXED_MODELS = new Set(["gpt-5.5"]); /** * Resolve provider alias to provider ID diff --git a/open-sse/services/modelFamilyFallback.ts b/open-sse/services/modelFamilyFallback.ts index b7812ca877..50d30f7617 100644 --- a/open-sse/services/modelFamilyFallback.ts +++ b/open-sse/services/modelFamilyFallback.ts @@ -139,12 +139,18 @@ export function getNextFamilyFallback( currentModel: string, triedModels: Set ): string | null { - const family = MODEL_FAMILIES[currentModel]; + const parsed = parseModel(currentModel); + const bareModel = parsed.model || currentModel; + const prefix = + parsed.provider || parsed.providerAlias ? `${parsed.provider || parsed.providerAlias}/` : ""; + + const family = MODEL_FAMILIES[bareModel]; if (!family) return null; for (const candidate of family) { - if (!triedModels.has(candidate)) { - return candidate; + const fullCandidate = `${prefix}${candidate}`; + if (!triedModels.has(fullCandidate)) { + return fullCandidate; } } @@ -155,16 +161,23 @@ export function getNextFamilyFallback( * Check if a model belongs to any registered family. */ export function isInModelFamily(model: string): boolean { - return model in MODEL_FAMILIES; + const parsed = parseModel(model); + const bareModel = parsed.model || model; + return bareModel in MODEL_FAMILIES; } /** * Get all members of a model's family (including itself). */ export function getModelFamily(model: string): string[] { - const family = MODEL_FAMILIES[model]; + const parsed = parseModel(model); + const bareModel = parsed.model || model; + const prefix = + parsed.provider || parsed.providerAlias ? `${parsed.provider || parsed.providerAlias}/` : ""; + + const family = MODEL_FAMILIES[bareModel]; if (!family) return [model]; - return [model, ...family]; + return [model, ...family.map((c) => `${prefix}${c}`)]; } /** diff --git a/open-sse/services/responsesInputSanitizer.ts b/open-sse/services/responsesInputSanitizer.ts index 31a540feb8..ba2fef19e7 100644 --- a/open-sse/services/responsesInputSanitizer.ts +++ b/open-sse/services/responsesInputSanitizer.ts @@ -1,4 +1,5 @@ type JsonRecord = Record; +const INTERNAL_ASSISTANT_PHASES = new Set(["commentary"]); function toRecord(value: unknown): JsonRecord | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; @@ -15,9 +16,9 @@ export function isInternalAssistantMessage(record: JsonRecord): boolean { 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"; + // Drop only known internal runtime frames. Visible assistant turns such as + // `final` and `final_answer` must survive replay for Codex/OpenCode follow-ups. + return INTERNAL_ASSISTANT_PHASES.has(phase); } export function sanitizeResponsesInputItems(items: readonly unknown[], clone = true): unknown[] { diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index 3b09912672..0b48332a0b 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -312,17 +312,12 @@ export function openaiToClaudeRequest(model, body, stream) { } } - // System with Claude Code prompt and cache_control - const claudeCodePrompt = { type: "text", text: CLAUDE_SYSTEM_PROMPT }; - + // System messages and cache_control if (systemParts.length > 0) { const systemText = systemParts.join("\n"); result.system = [ - claudeCodePrompt, { type: "text", text: systemText, cache_control: { type: "ephemeral", ttl: "1h" } }, ]; - } else { - result.system = [claudeCodePrompt]; } // Thinking configuration @@ -552,16 +547,6 @@ function tryParseJSON(str) { function openaiToClaudeRequestForAntigravity(model, body, stream) { const result = openaiToClaudeRequest(model, body, stream); - // Remove Claude Code system prompt, keep only user's system messages - if (result.system && Array.isArray(result.system)) { - result.system = result.system.filter( - (block) => !block.text || !block.text.includes("You are Claude Code") - ); - if (result.system.length === 0) { - delete result.system; - } - } - // Strip prefix from tool names for Antigravity (doesn't use Claude OAuth) if (result.tools && Array.isArray(result.tools)) { result.tools = result.tools.map((tool) => { diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 7142f32b50..4af955986d 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -57,7 +57,8 @@ type GeminiFunctionDeclaration = { type GeminiRequest = { model: string; - contents: GeminiContent[]; + contents?: GeminiContent[]; + [key: string]: any; generationConfig: GeminiGenerationConfig; safetySettings: unknown; systemInstruction?: GeminiContent; @@ -79,7 +80,8 @@ type CloudCodeEnvelope = { request: { session_id?: string; sessionId?: string; - contents: GeminiContent[]; + contents?: GeminiContent[]; + [key: string]: any; systemInstruction?: GeminiContent; generationConfig: GeminiGenerationConfig; tools?: Array<{ @@ -475,12 +477,6 @@ function wrapInCloudCodeEnvelopeForClaude( credentials = null, sourceBody = {} ) { - const toolNameMap = new Map(); - const sanitizeToolName = (name: string) => - sanitizeGeminiToolName(name, { - stripNamespace: true, - toolNameMap, - }); let projectId = credentials?.projectId; if (!projectId) { @@ -493,10 +489,16 @@ function wrapInCloudCodeEnvelopeForClaude( const cleanModel = model.includes("/") ? model.split("/").pop()! : model; - const generationConfig: GeminiGenerationConfig = { - temperature: claudeRequest.temperature || 1, - maxOutputTokens: getAntigravityClaudeOutputTokens(sourceBody), - }; + // Keep Antigravity's default and caller-provided system rules + let systemText = ANTIGRAVITY_DEFAULT_SYSTEM; + if (claudeRequest.system) { + if (Array.isArray(claudeRequest.system)) { + const texts = claudeRequest.system.map((b) => b.text).filter(Boolean); + if (texts.length > 0) systemText += "\n" + texts.join("\n"); + } else if (typeof claudeRequest.system === "string") { + systemText += "\n" + claudeRequest.system; + } + } const envelope: CloudCodeEnvelope = { project: projectId, @@ -505,109 +507,13 @@ function wrapInCloudCodeEnvelopeForClaude( requestId: `agent-${generateUUID()}`, requestType: "agent", request: { + ...claudeRequest, + system: systemText, + max_tokens: getAntigravityClaudeOutputTokens(sourceBody), sessionId: generateSessionId(), - contents: [], - generationConfig, }, }; - const toolUseNames: Record = {}; - if (claudeRequest.messages && Array.isArray(claudeRequest.messages)) { - for (const msg of claudeRequest.messages) { - if (!Array.isArray(msg.content)) continue; - for (const block of msg.content) { - if (block.type === "tool_use" && block.id && typeof block.name === "string") { - toolUseNames[block.id] = sanitizeToolName(block.name); - } - } - } - } - - // Convert Claude messages to Gemini contents - if (claudeRequest.messages && Array.isArray(claudeRequest.messages)) { - for (const msg of claudeRequest.messages) { - const parts = []; - - if (Array.isArray(msg.content)) { - for (const block of msg.content) { - if (block.type === "text") { - parts.push({ text: block.text }); - } else if (block.type === "image" && block.source) { - parts.push({ - inlineData: { - mimeType: block.source.media_type, - data: block.source.data, - }, - }); - } else if (block.type === "tool_use") { - parts.push({ - functionCall: { - id: block.id, - name: sanitizeToolName(block.name), - args: block.input || {}, - }, - }); - } else if (block.type === "tool_result") { - let content = block.content; - if (Array.isArray(content)) { - content = content - .map((c) => (c.type === "text" ? c.text : JSON.stringify(c))) - .join("\n"); - } - parts.push({ - functionResponse: { - id: block.tool_use_id, - name: toolUseNames[block.tool_use_id] || "unknown", - response: { result: tryParseJSON(content) || content }, - }, - }); - } - } - } else if (typeof msg.content === "string") { - parts.push({ text: msg.content }); - } - - if (parts.length > 0) { - envelope.request.contents.push({ - role: msg.role === "assistant" ? "model" : "user", - parts, - }); - } - } - } - - // Convert Claude tools to Gemini functionDeclarations - if (claudeRequest.tools && Array.isArray(claudeRequest.tools)) { - const geminiTools = buildGeminiTools(claudeRequest.tools, { - stripNamespace: true, - toolNameMap, - }); - if (geminiTools) { - envelope.request.tools = geminiTools; - } - } - - // Keep Antigravity's default and caller-provided system rules as distinct parts, - // matching the Gemini bridge and avoiding accidental prompt concatenation. - const systemParts: GeminiPart[] = [{ text: ANTIGRAVITY_DEFAULT_SYSTEM }]; - - if (claudeRequest.system) { - if (Array.isArray(claudeRequest.system)) { - for (const block of claudeRequest.system) { - if (block.text) systemParts.push({ text: block.text }); - } - } else if (typeof claudeRequest.system === "string") { - systemParts.push({ text: claudeRequest.system }); - } - } - - envelope.request.systemInstruction = { role: "system", parts: systemParts }; - - const changedToolNameMap = buildChangedToolNameMap(toolNameMap); - if (changedToolNameMap) { - envelope._toolNameMap = changedToolNameMap; - } - return envelope; } diff --git a/open-sse/translator/request/openai-to-kiro.ts b/open-sse/translator/request/openai-to-kiro.ts index 8ec67d200a..7b271d6fd7 100644 --- a/open-sse/translator/request/openai-to-kiro.ts +++ b/open-sse/translator/request/openai-to-kiro.ts @@ -144,7 +144,7 @@ function convertMessages(messages, tools, model) { pendingToolResults.push({ toolUseId: block.tool_use_id, - status: "success", + status: "SUCCESS", content: [{ text: text }], }); }); @@ -156,7 +156,7 @@ function convertMessages(messages, tools, model) { const toolContent = typeof msg.content === "string" ? msg.content : ""; pendingToolResults.push({ toolUseId: msg.tool_call_id, - status: "success", + status: "SUCCESS", content: [{ text: toolContent }], }); } else if (content) { @@ -229,6 +229,13 @@ function convertMessages(messages, tools, model) { // If last message in history is userInputMessage, use it as currentMessage if (history.length > 0 && history[history.length - 1].userInputMessage) { currentMessage = history.pop(); + } else if (!currentMessage) { + currentMessage = { + userInputMessage: { + content: "Continue", + modelId: model, + }, + }; } const firstHistoryItem = history[0]; diff --git a/open-sse/utils/usageTracking.ts b/open-sse/utils/usageTracking.ts index 830368c693..15e7d681d4 100644 --- a/open-sse/utils/usageTracking.ts +++ b/open-sse/utils/usageTracking.ts @@ -365,7 +365,8 @@ export function extractUsage(chunk) { completion_tokens: chunk.usage.completion_tokens ?? chunk.usage.output_tokens ?? 0, cached_tokens: chunk.usage.prompt_tokens_details?.cached_tokens ?? - chunk.usage.input_tokens_details?.cached_tokens, + chunk.usage.input_tokens_details?.cached_tokens ?? + chunk.usage.prompt_cache_hit_tokens, reasoning_tokens: chunk.usage.completion_tokens_details?.reasoning_tokens ?? chunk.usage.output_tokens_details?.reasoning_tokens, diff --git a/package-lock.json b/package-lock.json index 8753eccd38..bd43b299d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,18 +18,23 @@ "@monaco-editor/react": "^4.7.0", "@ngrok/ngrok": "^1.7.0", "@swc/helpers": "0.5.21", - "axios": "^1.15.0", + "axios": "^1.16.0", "bcryptjs": "^3.0.3", "better-sqlite3": "^12.6.2", "bottleneck": "^2.19.5", "express": "^5.2.1", "fetch-socks": "^1.3.2", + "fuse.js": "^7.3.0", "http-proxy-middleware": "^3.0.5", "https-proxy-agent": "^9.0.0", + "isomorphic-dompurify": "^3.12.0", "jose": "^6.1.3", "js-yaml": "^4.1.0", "jsonc-parser": "^3.3.1", "lowdb": "^7.0.1", + "lucide-react": "^1.14.0", +< "marked": "^18.0.3", + "mermaid": "^11.14.0", "monaco-editor": "^0.55.1", "next": "^16.2.3", "next-intl": "^4.11.0", @@ -47,12 +52,12 @@ "selfsigned": "^5.5.0", "tls-client-node": "^0.1.13", "tsx": "^4.21.0", - "undici": "^8.1.0", + "undici": "^8.2.0", "uuid": "^14.0.0", "wreq-js": "^2.0.1", "xxhash-wasm": "^1.1.0", "yazl": "^3.3.1", - "zod": "^4.3.6", + "zod": "^4.4.3", "zustand": "^5.0.10" }, "bin": { @@ -76,14 +81,16 @@ "cross-env": "^10.1.0", "eslint": "^9.39.2", "eslint-config-next": "16.2.4", + "glob": "^13.0.6", + "gray-matter": "^4.0.3", "husky": "^9.1.7", - "jsdom": "^29.0.1", + "jsdom": "^29.1.1", "lint-staged": "^16.2.7", "node-loader": "^2.1.0", "prettier": "^3.8.1", "tailwindcss": "^4", "typescript": "^6.0.2", - "typescript-eslint": "^8.56.0", + "typescript-eslint": "^8.59.2", "vitest": "^4.1.2", "wait-on": "^9.0.4", "wtfnode": "^0.10.1" @@ -95,6 +102,32 @@ "keytar": "^7.9.0" } }, + "../omniroute-docs-site": { + "name": "docs-site", + "version": "0.1.0", + "extraneous": true, + "dependencies": { + "@mdx-js/loader": "^3.1.1", + "@mdx-js/react": "^3.1.1", + "@next/mdx": "^16.2.4", + "fuse.js": "^7.3.0", + "next": "16.2.4", + "react": "19.2.4", + "react-dom": "19.2.4" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.4", + "glob": "^13.0.6", + "gray-matter": "^4.0.3", + "tailwindcss": "^4", + "typescript": "^5" + } + }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -134,11 +167,23 @@ "react-dom": ">=16.0.0" } }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "5.1.11", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", - "dev": true, "license": "MIT", "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", @@ -155,7 +200,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", - "dev": true, "license": "MIT", "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", @@ -172,7 +216,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", - "dev": true, "license": "MIT", "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" @@ -182,7 +225,6 @@ "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, "license": "MIT" }, "node_modules/@babel/code-frame": { @@ -441,11 +483,16 @@ "node": ">=18" } }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "dev": true, "license": "MIT", "dependencies": { "css-tree": "^3.0.0" @@ -454,11 +501,47 @@ "specificity": "bin/cli.js" } }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz", + "integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "12.0.0", + "@chevrotain/types": "12.0.0" + } + }, + "node_modules/@chevrotain/gast": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz", + "integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "12.0.0" + } + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz", + "integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/types": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz", + "integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz", + "integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==", + "license": "Apache-2.0" + }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", - "dev": true, "funding": [ { "type": "github", @@ -478,7 +561,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", - "dev": true, "funding": [ { "type": "github", @@ -502,7 +584,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", - "dev": true, "funding": [ { "type": "github", @@ -530,7 +611,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, "funding": [ { "type": "github", @@ -553,7 +633,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", - "dev": true, "funding": [ { "type": "github", @@ -578,7 +657,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, "funding": [ { "type": "github", @@ -1388,7 +1466,6 @@ "version": "1.15.0", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", - "dev": true, "license": "MIT", "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" @@ -1550,6 +1627,23 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.1.tgz", + "integrity": "sha512-MwzoDtw9rO1x+qfgLTV/IVXsHDBqeYZoMIQC8SfxfYSlaSUG+oWiAcoiB1yajAda6mqblm4/1/w2E8tRu7a7Tw==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "mlly": "^1.8.2" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -2102,6 +2196,15 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@mermaid-js/parser": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz", + "integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==", + "license": "MIT", + "dependencies": { + "langium": "^4.0.0" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -4125,24 +4228,159 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", "license": "MIT" }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, "node_modules/@types/d3-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", "license": "MIT" }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", @@ -4158,6 +4396,24 @@ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", "license": "MIT" }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, "node_modules/@types/d3-scale": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", @@ -4167,6 +4423,18 @@ "@types/d3-time": "*" } }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, "node_modules/@types/d3-shape": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", @@ -4182,12 +4450,37 @@ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", "license": "MIT" }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, "node_modules/@types/d3-timer": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -4219,6 +4512,12 @@ "@types/estree": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -4339,17 +4638,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", - "integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", + "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/type-utils": "8.59.1", - "@typescript-eslint/utils": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/type-utils": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -4362,7 +4661,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.1", + "@typescript-eslint/parser": "^8.59.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -4378,16 +4677,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz", - "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz", + "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3" }, "engines": { @@ -4403,14 +4702,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", - "integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", + "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.1", - "@typescript-eslint/types": "^8.59.1", + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", "debug": "^4.4.3" }, "engines": { @@ -4425,14 +4724,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", - "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", + "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1" + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4443,9 +4742,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", - "integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", + "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", "dev": true, "license": "MIT", "engines": { @@ -4460,15 +4759,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", - "integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", + "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/utils": "8.59.1", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -4485,9 +4784,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", - "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", + "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", "dev": true, "license": "MIT", "engines": { @@ -4499,16 +4798,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", - "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", + "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.1", - "@typescript-eslint/tsconfig-utils": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -4579,16 +4878,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz", - "integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz", + "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1" + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4603,13 +4902,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", - "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", + "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/types": "8.59.2", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -4908,6 +5207,16 @@ "win32" ] }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", @@ -5071,7 +5380,6 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -5453,12 +5761,12 @@ } }, "node_modules/axios": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", - "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } @@ -5564,7 +5872,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, "license": "MIT", "dependencies": { "require-from-string": "^2.0.2" @@ -5954,6 +6261,34 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chevrotain": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz", + "integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "12.0.0", + "@chevrotain/gast": "12.0.0", + "@chevrotain/regexp-to-ast": "12.0.0", + "@chevrotain/types": "12.0.0", + "@chevrotain/utils": "12.0.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/chevrotain-allstar": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.3.tgz", + "integrity": "sha512-2X4mkroolSMKqW+H22pyPMUVDqYZzPhephTmg/NODKb1IGYPHfxfhcW0EjS7wcPJNbze2i4vBWT7zT5FKF2lrQ==", + "license": "MIT", + "dependencies": { + "lodash-es": "^4.18.1" + }, + "peerDependencies": { + "chevrotain": "^12.0.0" + } + }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -6145,6 +6480,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -6177,6 +6521,12 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -6240,6 +6590,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, "node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", @@ -6301,7 +6660,6 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, "license": "MIT", "dependencies": { "mdn-data": "2.27.1", @@ -6324,6 +6682,95 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/cytoscape": { + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.3.tgz", + "integrity": "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-array": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", @@ -6336,6 +6783,43 @@ "node": ">=12" } }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -6345,6 +6829,89 @@ "node": ">=12" } }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -6354,6 +6921,32 @@ "node": ">=12" } }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-format": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -6363,6 +6956,27 @@ "node": ">=12" } }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-interpolate": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", @@ -6384,6 +6998,73 @@ "node": ">=12" } }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", @@ -6400,6 +7081,28 @@ "node": ">=12" } }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -6445,6 +7148,51 @@ "node": ">=12" } }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -6456,7 +7204,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "dev": true, "license": "MIT", "dependencies": { "whatwg-mimetype": "^5.0.0", @@ -6529,6 +7276,12 @@ "node": "*" } }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -6550,7 +7303,6 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, "license": "MIT" }, "node_modules/decimal.js-light": { @@ -6679,6 +7431,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -6830,7 +7591,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=20.19.0" @@ -7546,6 +8306,20 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -7734,6 +8508,19 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fast-copy": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", @@ -8115,6 +8902,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.3.0.tgz", + "integrity": "sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/krisk" + } + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -8349,6 +9149,52 @@ "dev": true, "license": "ISC" }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/gray-matter/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/gray-matter/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -8531,7 +9377,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, "license": "MIT", "dependencies": { "@exodus/bytes": "^1.6.0" @@ -9032,6 +9877,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -9240,7 +10095,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, "license": "MIT" }, "node_modules/is-promise": { @@ -9434,6 +10288,19 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/isomorphic-dompurify": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-3.12.0.tgz", + "integrity": "sha512-8n+j+6ypTHvriJwFOQ2qusQ6bzGjZVcR3jbe1pBpLcGI1dn4WIl0ctLBngqE5QttquQBAlKXwJeTMw+X7x7qKw==", + "license": "MIT", + "dependencies": { + "dompurify": "^3.4.2", + "jsdom": "^29.1.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -9570,10 +10437,13 @@ } }, "node_modules/jsdom": { - "version": "29.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.0.tgz", - "integrity": "sha512-YNUc7fB9QuvSSQWfrH0xF+TyABkxUwx8sswgIDaCrw4Hol8BghdZDkITtZheRJeMtzWlnTfsM3bBBusRvpO1wg==", + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", +<<<<<<< HEAD +======= "dev": true, +>>>>>>> upstream/release/v3.7.9 "license": "MIT", "dependencies": { "@asamuzakjp/css-color": "^5.1.11", @@ -9614,7 +10484,6 @@ "version": "11.3.5", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -9624,7 +10493,6 @@ "version": "7.25.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=20.18.1" @@ -9709,6 +10577,31 @@ "node": ">=4.0" } }, + "node_modules/katex": { + "version": "0.16.45", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz", + "integrity": "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/keytar": { "version": "7.9.0", "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", @@ -9731,6 +10624,21 @@ "json-buffer": "3.0.1" } }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/koffi": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.1.tgz", @@ -9741,6 +10649,24 @@ "url": "https://liberapay.com/Koromix" } }, + "node_modules/langium": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.3.tgz", + "integrity": "sha512-sOPIi4hISFnY7twwV97ca1TsxpBtXq0URu/LL1AvxwccPG/RIBBlKS7a/f/EL6w8lTNaS0EFs/F+IdSOaqYpng==", + "license": "MIT", + "dependencies": { + "@chevrotain/regexp-to-ast": "~12.0.0", + "chevrotain": "~12.0.0", + "chevrotain-allstar": "~0.4.3", + "vscode-languageserver": "~9.0.1", + "vscode-languageserver-textdocument": "~1.0.11", + "vscode-uri": "~3.1.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -9761,6 +10687,12 @@ "node": ">=0.10" } }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -10139,6 +11071,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -10260,6 +11198,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz", + "integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -10299,6 +11246,24 @@ "node": ">=10" } }, + "node_modules/marked": { +<<<<<<< HEAD + "version": "18.0.3", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.3.tgz", + "integrity": "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==", +======= + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", +>>>>>>> upstream/release/v3.7.9 + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -10465,7 +11430,6 @@ "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, "license": "CC0-1.0" }, "node_modules/media-typer": { @@ -10499,6 +11463,63 @@ "node": ">= 8" } }, + "node_modules/mermaid": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz", + "integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.1", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.1.0", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.1", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.19", + "dompurify": "^3.3.1", + "katex": "^0.16.25", + "khroma": "^2.1.0", + "lodash-es": "^4.17.23", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0" + } + }, +<<<<<<< HEAD + "node_modules/mermaid/node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, +======= +>>>>>>> upstream/release/v3.7.9 + "node_modules/mermaid/node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -11063,6 +12084,18 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, "node_modules/monaco-editor": { "version": "0.55.1", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", @@ -11628,6 +12661,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -11687,7 +12726,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", - "dev": true, "license": "MIT", "dependencies": { "entities": "^8.0.0" @@ -11705,6 +12743,12 @@ "node": ">= 0.8" } }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -11780,7 +12824,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, "license": "MIT" }, "node_modules/picocolors": { @@ -11883,6 +12926,17 @@ "node": ">=16.20.0" } }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, "node_modules/pkijs": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", @@ -11950,6 +13004,22 @@ "integrity": "sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==", "license": "MIT" }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, "node_modules/polished": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", @@ -12146,7 +13216,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -12621,6 +13690,12 @@ "dev": true, "license": "MIT" }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, "node_modules/rolldown": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", @@ -12662,6 +13737,18 @@ "dev": true, "license": "MIT" }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -12714,6 +13801,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -12818,7 +13911,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" @@ -12833,6 +13925,20 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/secure-json-parse": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", @@ -13291,6 +14397,13 @@ "node": ">= 10.x" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -13564,6 +14677,16 @@ "node": ">=4" } }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -13669,7 +14792,6 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, "license": "MIT" }, "node_modules/tailwindcss": { @@ -13804,7 +14926,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -13910,7 +15031,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" @@ -13962,6 +15082,15 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -14177,16 +15306,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.1.tgz", - "integrity": "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.2.tgz", + "integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.1", - "@typescript-eslint/parser": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/utils": "8.59.1" + "@typescript-eslint/eslint-plugin": "8.59.2", + "@typescript-eslint/parser": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -14200,6 +15329,12 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -14220,9 +15355,9 @@ } }, "node_modules/undici": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.1.0.tgz", - "integrity": "sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.2.0.tgz", + "integrity": "sha512-Z+4Hx9GE26Lh9Upwfnc8C7SsrpBPGaM/Gm6kMFtiG7c+5IvQKlXi/t+9x9DrrCh29cww5TSP9YdVaBcnLDs5fQ==", "license": "MIT", "engines": { "node": ">=22.19.0" @@ -14728,11 +15863,59 @@ } } }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, "license": "MIT", "dependencies": { "xml-name-validator": "^5.0.0" @@ -14765,7 +15948,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=20" @@ -14775,7 +15957,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -14785,7 +15966,6 @@ "version": "16.0.1", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "dev": true, "license": "MIT", "dependencies": { "@exodus/bytes": "^1.11.0", @@ -15030,7 +16210,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18" @@ -15040,7 +16219,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, "license": "MIT" }, "node_modules/xxhash-wasm": { @@ -15191,9 +16369,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 12d4fc61f0..4ce6dd9671 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "homepage": "https://omniroute.online", "scripts": { "dev": "node scripts/run-next.mjs dev", + "prebuild:docs": "node scripts/generate-docs-index.mjs", "build": "node scripts/build-next-isolated.mjs", "build:cli": "node --import tsx/esm scripts/prepublish.ts", "start": "node scripts/run-next.mjs start", @@ -87,6 +88,7 @@ "audit:electron": "npm --prefix electron audit --audit-level=moderate", "typecheck:core": "tsc --pretty false -p tsconfig.typecheck-core.json", "typecheck:noimplicit:core": "tsc --pretty false -p tsconfig.typecheck-noimplicit-core.json", + "backfill-aggregation": "node --import tsx/esm src/scripts/backfillAggregation.ts", "env:sync": "node scripts/sync-env.mjs", "test:integration": "node --import tsx/esm --test tests/integration/*.test.ts", "test:e2e": "node scripts/run-playwright-tests.mjs test tests/e2e/*.spec.ts", @@ -114,18 +116,23 @@ "@monaco-editor/react": "^4.7.0", "@ngrok/ngrok": "^1.7.0", "@swc/helpers": "0.5.21", - "axios": "^1.15.0", + "axios": "^1.16.0", "bcryptjs": "^3.0.3", "better-sqlite3": "^12.6.2", "bottleneck": "^2.19.5", "express": "^5.2.1", "fetch-socks": "^1.3.2", + "fuse.js": "^7.3.0", "http-proxy-middleware": "^3.0.5", "https-proxy-agent": "^9.0.0", + "isomorphic-dompurify": "^3.12.0", "jose": "^6.1.3", "js-yaml": "^4.1.0", "jsonc-parser": "^3.3.1", "lowdb": "^7.0.1", + "lucide-react": "^1.14.0", + "marked": "^18.0.3", + "mermaid": "^11.14.0", "monaco-editor": "^0.55.1", "next": "^16.2.3", "next-intl": "^4.11.0", @@ -143,12 +150,12 @@ "selfsigned": "^5.5.0", "tls-client-node": "^0.1.13", "tsx": "^4.21.0", - "undici": "^8.1.0", + "undici": "^8.2.0", "uuid": "^14.0.0", "wreq-js": "^2.0.1", "xxhash-wasm": "^1.1.0", "yazl": "^3.3.1", - "zod": "^4.3.6", + "zod": "^4.4.3", "zustand": "^5.0.10" }, "optionalDependencies": { @@ -171,14 +178,16 @@ "cross-env": "^10.1.0", "eslint": "^9.39.2", "eslint-config-next": "16.2.4", + "glob": "^13.0.6", + "gray-matter": "^4.0.3", "husky": "^9.1.7", - "jsdom": "^29.0.1", + "jsdom": "^29.1.1", "lint-staged": "^16.2.7", "node-loader": "^2.1.0", "prettier": "^3.8.1", "tailwindcss": "^4", "typescript": "^6.0.2", - "typescript-eslint": "^8.56.0", + "typescript-eslint": "^8.59.2", "vitest": "^4.1.2", "wait-on": "^9.0.4", "wtfnode": "^0.10.1" diff --git a/recent_issues.jsonl b/recent_issues.jsonl new file mode 100644 index 0000000000..5f339d1f8d --- /dev/null +++ b/recent_issues.jsonl @@ -0,0 +1,10 @@ +{"author":{"id":"MDQ6VXNlcjE0OTIxOTgz","is_bot":false,"login":"oyi77","name":"Paijo"},"body":"# Comprehensive Documentation Site Overhaul\n\n## 🎯 Objective\nTransform OmniRoute's scattered documentation into a comprehensive, structured documentation site with interactive features, advanced search, and professional organization inspired by industry best practices.\n\n## 📋 Problem Statement\nCurrent documentation system limitations:\n- **Scattered Organization**: Markdown files spread across repository with limited navigation\n- **No Dedicated Site**: Lack of professional documentation portal\n- **Limited Discoverability**: Poor search and feature discovery\n- **Static Content**: No interactive elements or API testing\n- **Inconsistent Quality**: Varied formatting and structure\n\n## ✨ Proposed Solution\n\n### New Documentation Structure\n\n\n### Key Features\n- **Interactive API Documentation**: Swagger/OpenAPI with try-it-now functionality\n- **Advanced Search**: Algolia/DocSearch with autocomplete and filtering\n- **Versioned Content**: Clear version tags and migration guides\n- **Code Examples**: Runnable examples with copy-to-clipboard\n- **Visual Aids**: Interactive Mermaid diagrams and architecture visualizations\n- **Responsive Design**: Mobile-friendly with dark mode support\n- **Analytics**: Usage tracking and feedback system\n\n### Technology Stack\n- **Framework**: Next.js (consistent with OmniRoute dashboard)\n- **Content**: MDX (Markdown + React components)\n- **Styling**: Tailwind CSS (matches OmniRoute UI)\n- **Search**: Algolia DocSearch or Fuse.js\n- **Deployment**: Vercel with preview deployments\n\n## 📈 Expected Benefits\n- **50% increase** in documentation page views\n- **30% reduction** in support requests for basic questions\n- **40% increase** in time spent on documentation pages\n- **90% positive** feedback on documentation quality\n- **25% increase** in community contributions\n\n## 🔧 Implementation Plan\n\n### Phase 1: Foundation (2 weeks)\n- Set up Next.js documentation framework\n- Design component library with OmniRoute theme\n- Implement MDX processing pipeline\n- Set up CI/CD pipeline with preview deployments\n\n### Phase 2: Content Migration (4 weeks)\n- Audit and categorize existing documentation\n- Create automated migration tools\n- Migrate core content with quality validation\n- Implement redirects from old URLs\n\n### Phase 3: Advanced Features (3 weeks)\n- Implement search functionality with Algolia\n- Add interactive API documentation\n- Create versioning system\n- Implement analytics and feedback\n\n### Phase 4: Testing & Launch (2 weeks)\n- Comprehensive testing (unit, integration, E2E)\n- Performance optimization\n- Accessibility validation\n- Final review and official launch\n\n## 📊 Success Metrics\n- All content successfully migrated and validated\n- Search functionality achieves ≥90% relevance in test queries\n- Accessibility compliance (WCAG 2.1 AA) verified\n- Performance metrics meet targets (Lighthouse ≥90)\n- 50% increase in documentation engagement\n\n## 🎯 Definition of Done\n- Complete content migration with validation\n- All interactive features fully functional\n- Search system operational with ≥90% relevance\n- Accessibility compliance verified\n- Performance targets achieved\n- Analytics and feedback system implemented\n- Comprehensive documentation completed\n\n## 📝 Additional Context\nThis overhaul will create a professional documentation portal that improves user onboarding, feature discovery, and overall satisfaction while maintaining consistency with OmniRoute's brand and technical stack. The new site will serve as the primary resource for users, developers, and contributors.","comments":[],"createdAt":"2026-05-04T20:54:55Z","labels":[],"number":1958,"title":"feat: Comprehensive Documentation Site Overhaul"} +{"author":{"id":"MDQ6VXNlcjE0OTIxOTgz","is_bot":false,"login":"oyi77","name":"Paijo"},"body":"# Manifest Routing Integration - Tier Resolution & Specificity Detection\n\n## 🎯 Objective\nEnhance OmniRoute's combo routing system by integrating Manifest's sophisticated tier resolution and specificity detection logic to create a more intelligent, context-aware routing engine that optimizes for both performance and cost.\n\n## 📋 Problem Statement\nThe current combo routing system, while functional with 13 strategies, lacks:\n- **Tier Resolution**: Provider classification based on performance, cost, and capabilities\n- **Specificity Detection**: Content complexity analysis for optimal route selection\n- **Adaptive Strategies**: Dynamic adjustment based on real-time metrics and query requirements\n\n## ✨ Proposed Solution\n\n### Core Components\n1. **Tier Resolution System**\n - 3-level classification: static rules → heuristics → specificity-based\n - Dynamic tier assignment based on real-time provider metrics\n - Integration with existing provider health monitoring\n\n2. **Specificity Detection**\n - Query complexity scoring (0-100 scale)\n - Content-type analysis for optimal provider matching\n - Historical performance data integration\n\n3. **Adaptive Combo Strategies**\n - New auto-optimized strategy with dynamic adjustment\n - Tier-aware fallback logic\n - Cost-performance balancing algorithms\n\n### Integration Points\n- **PR #1918 Auto-Assessment**: Leverage existing health monitoring and performance data\n- **Existing Combo System**: Maintain backward compatibility with current strategies\n- **Metrics Pipeline**: Feed routing decisions into analytics for continuous improvement\n\n## 📈 Expected Benefits\n- **15-25%** improvement in routing success rates for complex queries\n- **10-20%** reduction in API costs through optimal provider utilization\n- **30% faster** routing decisions via optimized tier selection\n- **Enhanced reliability** with smarter, context-aware fallback logic\n\n## 🔧 Implementation Plan\n\n### Phase 1: Foundation (2-3 weeks)\n- Research Manifest's tier resolution algorithms\n- Design system architecture with integration points\n- Implement basic tier classification framework\n- Set up test infrastructure and benchmarks\n\n### Phase 2: Core Implementation (4-5 weeks)\n- Implement 3-tier resolution system (static/heuristic/specificity)\n- Develop specificity scoring algorithm\n- Create adaptive combo strategies\n- Integrate with PR #1918 auto-assessment system\n\n### Phase 3: Optimization & Testing (3 weeks)\n- Performance tuning and benchmarking\n- Comprehensive test suite (unit, integration, e2e)\n- Documentation and examples\n- Gradual rollout and monitoring\n\n## 📊 Success Metrics\n- Routing success rate improvement (A/B testing)\n- Cost reduction verified through usage analytics\n- Performance benchmarks showing latency improvements\n- User satisfaction surveys on routing reliability\n\n## 🔍 Related Work\n- PR #1918: Auto-Assessment and Self-Healing Combo Engine\n- Manifest routing engine analysis\n- Current combo strategies documentation\n\n## 🎯 Definition of Done\n- Tier resolution achieves ≥95% accuracy in classification\n- Specificity detection shows ≥90% correlation with manual scoring\n- Adaptive strategies demonstrate ≥15% improvement in benchmarks\n- Integration with auto-assessment system verified\n- All performance benchmarks meet targets\n- Comprehensive documentation completed\n\n## 📝 Additional Context\nThis enhancement builds upon PR #1918's auto-assessment capabilities, adding intelligent tier-based routing and query-specific optimization to create a sophisticated, cost-effective routing system that maintains OmniRoute's commitment to performance and reliability.","comments":[],"createdAt":"2026-05-04T20:54:10Z","labels":[],"number":1957,"title":"feat: Manifest Routing Integration - Tier Resolution & Specificity Detection"} +{"author":{"id":"U_kgDODmAPNQ","is_bot":false,"login":"akatylutir","name":""},"body":"### OmniRoute Version\n\n3.7.8\n\n### Installation Method\n\nnpm (global)\n\n### Operating System\n\nWindows\n\n### OS Version\n\nwindows 11 pro, 24H2\n\n### Node.js Version\n\nv22.22.2\n\n### Provider(s) Involved\n\n_No response_\n\n### Model(s) Involved\n\n_No response_\n\n### Client Tool\n\n_No response_\n\n### Description\n\nHi! I want to use OmniRoute, but red button not working. What can I do? Thi is log: https://pastebin.com/jKcS8CSy , and this is video: https://youtu.be/DB40dkfnGf4\n\n### Steps to Reproduce\n\nHi! I want to use OmniRoute, but red button not working. What can I do? Thi is log: https://pastebin.com/jKcS8CSy , and this is video: https://youtu.be/DB40dkfnGf4\n\n### Expected Behavior\n\nHi! I want to use OmniRoute, but red button not working. What can I do? Thi is log: https://pastebin.com/jKcS8CSy , and this is video: https://youtu.be/DB40dkfnGf4\n\n### Actual Behavior\n\nHi! I want to use OmniRoute, but red button not working. What can I do? Thi is log: https://pastebin.com/jKcS8CSy , and this is video: https://youtu.be/DB40dkfnGf4\n\n### Test Impact\n\nNeeds a new unit test\n\n### Error Logs / Output\n\n```shell\nHi! I want to use OmniRoute, but red button not working. What can I do? Thi is log: https://pastebin.com/jKcS8CSy , and this is video: https://youtu.be/DB40dkfnGf4\n```\n\n### Screenshots\n\nHi! I want to use OmniRoute, but red button not working. What can I do? Thi is log: https://pastebin.com/jKcS8CSy , and this is video: https://youtu.be/DB40dkfnGf4\n\n### Additional Context\n\nHi! I want to use OmniRoute, but red button not working. What can I do? Thi is log: https://pastebin.com/jKcS8CSy , and this is video: https://youtu.be/DB40dkfnGf4\n\n### Validation Plan\n\nHi! I want to use OmniRoute, but red button not working. What can I do? Thi is log: https://pastebin.com/jKcS8CSy , and this is video: https://youtu.be/DB40dkfnGf4","comments":[{"id":"IC_kwDORPf6ys8AAAABBKraWA","author":{"login":"kilo-code-bot"},"authorAssociation":"NONE","body":"This issue appears to be a duplicate of https://github.com/diegosouzapw/OmniRoute/issues/1883.\n\n> **\\[BUG\\] Onboarding button unresponsive after upgrade from 2.x to 3.x — CSP blocks eval** (#1883)\n\nSimilarity score: 90%\n\n*This comment was generated by Kilo Auto-Triage.*","createdAt":"2026-05-04T18:00:58Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/diegosouzapw/OmniRoute/issues/1955#issuecomment-4373273176","viewerDidAuthor":false},{"id":"IC_kwDORPf6ys8AAAABBK1BzA","author":{"login":"vladislavsartakov90-svg"},"authorAssociation":"NONE","body":"I solved the problem, install version 3.7.5","createdAt":"2026-05-04T18:24:23Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/diegosouzapw/OmniRoute/issues/1955#issuecomment-4373430732","viewerDidAuthor":false}],"createdAt":"2026-05-04T18:00:51Z","labels":[{"id":"LA_kwDORPf6ys8AAAACYAlrFQ","name":"bug","description":"Something isn't working","color":"d73a4a"},{"id":"LA_kwDORPf6ys8AAAACZXajUw","name":"kilo-triaged","description":"Auto-generated label by Kilo","color":"faf74f"},{"id":"LA_kwDORPf6ys8AAAACZ7FLsw","name":"kilo-duplicate","description":"Auto-generated label by Kilo","color":"faf74f"}],"number":1955,"title":"[BUG] Button Start Onboarding not working"} +{"author":{"id":"U_kgDOEMaoiA","is_bot":false,"login":"vladislavsartakov90-svg","name":""},"body":"### OmniRoute Version\n\n3.7.8\n\n### Installation Method\n\nnpm (global)\n\n### Operating System\n\nWindows\n\n### OS Version\n\nwindows 10 \n\n### Node.js Version\n\nv24.15.0\n\n### Provider(s) Involved\n\nall\n\n### Model(s) Involved\n\nall\n\n### Client Tool\n\nall\n\n### Description\n\nI launched omniroute in cmd, the website opens, but the button doesn't work. I tried changing ports, but that didn't help. I also tried using AI, but that didn't help either.\n\n### Steps to Reproduce\n\na\n\n### Expected Behavior\n\na\n\n### Actual Behavior\n\na\n\n### Test Impact\n\nNeeds a new unit test\n\n### Error Logs / Output\n\n```shell\na\n```\n\n### Screenshots\n\n\"Image\"\n\n\"Image\"\n\n\"Image\"\n\n### Additional Context\n\na\n\n### Validation Plan\n\na","comments":[{"id":"IC_kwDORPf6ys8AAAABBKpIzA","author":{"login":"kilo-code-bot"},"authorAssociation":"NONE","body":"This issue appears to be a duplicate of https://github.com/diegosouzapw/OmniRoute/issues/1703.\n\n> **\\[BUG\\]** (#1703)\n\nSimilarity score: 93%\n\n*This comment was generated by Kilo Auto-Triage.*","createdAt":"2026-05-04T17:54:38Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/diegosouzapw/OmniRoute/issues/1954#issuecomment-4373235916","viewerDidAuthor":false},{"id":"IC_kwDORPf6ys8AAAABBKtw3g","author":{"login":"vladislavsartakov90-svg"},"authorAssociation":"NONE","body":"node 22.22.2 not work\n","createdAt":"2026-05-04T18:06:24Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/diegosouzapw/OmniRoute/issues/1954#issuecomment-4373311710","viewerDidAuthor":false}],"createdAt":"2026-05-04T17:54:31Z","labels":[{"id":"LA_kwDORPf6ys8AAAACYAlrFQ","name":"bug","description":"Something isn't working","color":"d73a4a"},{"id":"LA_kwDORPf6ys8AAAACZXajUw","name":"kilo-triaged","description":"Auto-generated label by Kilo","color":"faf74f"},{"id":"LA_kwDORPf6ys8AAAACZ7FLsw","name":"kilo-duplicate","description":"Auto-generated label by Kilo","color":"faf74f"}],"number":1954,"title":"[BUG]"} +{"author":{"id":"MDQ6VXNlcjM3Nzc3MjYx","is_bot":false,"login":"uwuclxdy","name":"cloudy"},"body":"### Problem / Use Case\n\nOmniRoute currently tries all accounts from lets say Codex provider, even though some of them have no more usage available.\n\n### Proposed Solution\n\nMake the already existing buttons \"track 5-hour quota\" and \"track weekly quota\" on `/dashboard/providers/codex` page would make OmniRoute check the quota of the account before attempting to send a query and skip it if on 0%. This could work for other providers like Claude Code in the same way.\n\n### Alternatives Considered\n\n_No response_\n\n### Acceptance Criteria\n\nHave accounts without any usage left skipped automatically when quota tracking is turned on.\n\n### Area\n\nProxy / Routing\n\n### Related Provider(s)\n\nCodex, Claude Code...\n\n### Additional Context\n\n_No response_\n\n### Expected Test Plan\n\n_No response_","comments":[],"createdAt":"2026-05-04T17:21:30Z","labels":[{"id":"LA_kwDORPf6ys8AAAACYAlrLw","name":"enhancement","description":"New feature or request","color":"a2eeef"}],"number":1952,"title":"[Feature] Skip (Codex) accounts that have the weekly or 5h quota exhausted"} +{"author":{"id":"MDQ6VXNlcjU5MzM0MTUz","is_bot":false,"login":"hoangchung0701","name":""},"body":"### Problem / Use Case\n\nCould please show how much quota on model like:\n- Sesssion: x/y request remain\n- Weekly: x/y request remain\n\n### Proposed Solution\n\nNo\n\n### Alternatives Considered\n\n_No response_\n\n### Acceptance Criteria\n\nno\n\n### Area\n\nAnalytics / Usage Tracking\n\n### Related Provider(s)\n\n_No response_\n\n### Additional Context\n\n_No response_\n\n### Expected Test Plan\n\n_No response_","comments":[],"createdAt":"2026-05-04T17:12:44Z","labels":[{"id":"LA_kwDORPf6ys8AAAACYAlrLw","name":"enhancement","description":"New feature or request","color":"a2eeef"}],"number":1951,"title":"[Feature] Show more detail about quota"} +{"author":{"id":"U_kgDODGQfGw","is_bot":false,"login":"Destruction13","name":""},"body":"### Problem / Use Case\n\nOmniRoute supports a very large provider ecosystem, but Devin AI is not currently available as a built-in provider.\n\nThis matters because Devin is a strong coding-focused agent for longer-running engineering tasks, and OmniRoute already positions itself as a universal gateway for coding agents and model providers. Right now, users who want to evaluate or route work to Devin cannot do that from the same OmniRoute endpoint or include Devin in combos/fallback chains.\n\n## Why the current custom-provider path is not enough\n\nAs far as I understand, OmniRoute's generic custom provider support is for OpenAI-compatible and Anthropic-compatible endpoints.\n\nDevin does expose an official API, but it is session-based and organization-scoped rather than a drop-in `/chat/completions`-compatible endpoint. Because of that, Devin likely needs a first-class provider adapter instead of a generic custom endpoint configuration.\n\n## Use cases\n\n- Route repo-level coding, debugging, and refactor tasks to Devin from the same OmniRoute setup used for Codex, Claude Code, Gemini CLI, etc.\n- Use Devin as a higher-latency but higher-capability fallback for complex implementation tasks.\n- Compare Devin against other coding-oriented providers inside the same OmniRoute combo/routing policy.\n- Evaluate Devin inside OmniRoute without reconfiguring clients every time.\n\n### Proposed Solution\n\n## Proposed solution\n\nAdd Devin as a built-in provider with a custom executor.\n\nHigh-level implementation sketch:\n\n1. Register a new `devin` provider in `src/shared/constants/providers.ts`.\n2. Add Devin provider/model metadata in `open-sse/config/providerRegistry.ts`.\n3. Implement a custom executor in `open-sse/executors/` that:\n - authenticates with Devin service-user credentials\n - targets the organization API under `https://api.devin.ai/v3/organizations/{org_id}`\n - creates a new Devin session for a fresh OmniRoute request\n - sends follow-up messages to an existing Devin session when conversation continuity is available\n - polls session state and/or session messages until a stable assistant result is available\n - maps the final Devin output back into OmniRoute's OpenAI-compatible response shape\n4. Add dashboard/provider settings for:\n - `DEVIN_API_KEY` (service-user credential)\n - `DEVIN_ORG_ID`\n - optional defaults such as session tags, repo selection, or ACU limits\n5. Make the provider eligible for direct selection and combo routing.\n\n### Alternatives Considered\n\n_No response_\n\n### Acceptance Criteria\n\n## Acceptance criteria\n\n- Devin appears in the Providers UI as a connectable provider.\n- A user can store Devin credentials and validate the connection.\n- OmniRoute can send a basic text request to Devin and return a usable assistant response through `/v1/chat/completions` and/or `/v1/responses`.\n- Follow-up prompts can either reuse a Devin session or the limitation is explicitly documented for MVP behavior.\n- Devin can be targeted directly in combos.\n- Common Devin session states/errors (for example approval-needed, quota/usage exhaustion, or suspended sessions) are surfaced as clear OmniRoute errors.\n- Documentation explains setup, limitations, and expected latency/behavior differences versus normal token-streaming LLM providers.\n\n## Suggested MVP scope\n\nMVP can be text-only and non-streaming/polling-based if needed.\n\nNot required for the first version:\n- embeddings\n- image/audio endpoints\n- tool-calling parity\n- full real-time streaming parity with standard LLM providers\n\n### Area\n\nProvider Support\n\n### Related Provider(s)\n\n_No response_\n\n### Additional Context\n\n## Additional context\n\nThis would also be useful for users who want to evaluate Devin during onboarding or trial access without changing their entire client setup.\n\n### Expected Test Plan\n\n_No response_","comments":[],"createdAt":"2026-05-04T17:00:59Z","labels":[{"id":"LA_kwDORPf6ys8AAAACYAlrLw","name":"enhancement","description":"New feature or request","color":"a2eeef"}],"number":1950,"title":"[Feature] devin.ai"} +{"author":{"id":"MDQ6VXNlcjc1MzQ1NDc=","is_bot":false,"login":"chbndrhnns","name":"Johannes Rueschel"},"body":"### OmniRoute Version\n\n3.7.8\n\n### Installation Method\n\nnpm (global)\n\n### Operating System\n\nmacOS\n\n### OS Version\n\n26.4.1\n\n### Node.js Version\n\n24.15.0\n\n### Provider(s) Involved\n\n-\n\n### Model(s) Involved\n\n-\n\n### Client Tool\n\n-\n\n### Description\n\n\n```\n❯ pnpm upgrade -g omniroute\nDownloading @ngrok/ngrok-darwin-universal@1.7.0: 7.97 MB/7.97 MB, done\nDownloading @swc/core-darwin-arm64@1.15.33: 9.16 MB/9.16 MB, done\n WARN  2 deprecated subdependencies found: intersection-observer@0.12.2, prebuild-install@7.1.3\nDownloading omniroute@3.7.8: 50.87 MB/50.87 MB, done\nPackages: +29 -25\n+++++++++++++++++++++++++++++-------------------------\nProgress: resolved 988, reused 873, downloaded 27, added 29, done\n WARN  Issues with peer dependencies found\n.\n└─┬ @lobehub/ui 5.10.0\n └─┬ @emoji-mart/react 1.1.1\n └── ✕ unmet peer react@\"^16.8 || ^17 || ^18\": found 19.2.5\n\n/Users/jo/Library/pnpm/global/5:\n- omniroute 3.7.4\n+ omniroute 3.7.8\n\n╭ Warning ──────────────────────────────────────────────────────────────────────────────────────╮\n│ │\n│ Ignored build scripts: @swc/core@1.15.33, omniroute@3.7.8. │\n│ Run \"pnpm approve-builds -g\" to pick which dependencies should be allowed to run scripts. │\n│ │\n╰───────────────────────────────────────────────────────────────────────────────────────────────╯\nDone in 58.1s using pnpm v10.33.0\n❯ pnpm approve-builds -g\n✔ Choose which packages to build (Press to select, to toggle all, to invert selection) · @swc/core, omniroute\n✔ The next packages will now be built: @swc/core, omniroute.\nDo you approve? (y/N) · true\n.pnpm/@swc+core@1.15.33_@swc+helpers@0.5.21/node_modules/@swc/core: Running postinstall script, done in 45ms\n.pnpm/omniroute@3.7.8_@lobehub+ui@5.10.0_@types+react@19.2.14_antd@6.3.7_react-dom@19.2.5_rea_81be994467029f641d72db51e6e26349/node_modules/omniroute: Running postinstall script...\n.pnpm/omniroute@3.7.8_@lobehub+ui@5.10.0_@types+react@19.2.14_antd@6.3.7_react-dom@19.2.5_rea_81be994467029f641d72db51e6e26349/node_modules/omniroute: Running postinstall script, done in 772ms\n❯\n❯ omniroute\n\n ____ _ ____ _\n / __ \\\\ (_) __ \\\\ | |\n | | | |_ __ ___ _ __ _| |__) |___ _ _| |_ ___\n | | | | '_ ` _ \\\\| '_ \\\\ | _ // _ \\\\| | | | __/ _ \\\\\n | |__| | | | | | | | | | | | \\\\ \\\\ (_) | |_| | || __/\n \\\\____/|_| |_| |_|_| |_|_|_| \\\\_\\\\___/ \\\\__,_|\\\\__\\\\___|\n\n ⏳ Starting server...\n\n▲ Next.js 16.2.4\n- Local: http://localhost:20128\n- Network: http://0.0.0.0:20128\n✓ Ready in 0ms\n\n ✔ OmniRoute is running!\n\n Dashboard: http://localhost:20128\n API Base: http://localhost:20128/v1\n\n Point your CLI tool (Cursor, Cline, Codex) to:\n http://localhost:20128/v1\n\n Press Ctrl+C to stop\n\n[CREDENTIALS] No external credentials file found, using defaults.\n[DB] Could not preserve critical DB state before recreation: Could not locate the bindings file. Tried:\n → /Users/jo/Library/pnpm/global/5/.pnpm/omniroute@3.7.8_@lobehub+ui@5.10.0_@types+react@19.2.14_antd@6.3.7_react-dom@19.2.5_rea_81be994467029f641d72db51e6e26349/node_modules/omniroute/app/node_modules/better-sqlite3/build/better_sqlite3.node\n```\n\n### Steps to Reproduce\n\n1. `pnpm upgrade -g omniroute`\n2. `pnpm approve-builds -g`\n3. `omniroute`\n\n### Expected Behavior\n\nCan upgrade without manual installation procedure\n\n### Actual Behavior\n\nNeed to go to /Users/jo/Library/pnpm/global/5/.pnpm/omniroute@3.7.8_@lobehub+ui@5.10.0_@types+react@19.2.14_antd@6.3.7_react-dom@19.2.5_rea_81be994467029f641d72db51e6e26349/node_modules/omniroute/app\n\nNeed to run `npm install wreq-js@2.3.0 better-sqlite3@12.6.2 --no-save`\n\n### Test Impact\n\nNeeds a new e2e test\n\n### Error Logs / Output\n\n```shell\n\n```\n\n### Screenshots\n\n_No response_\n\n### Additional Context\n\n_No response_\n\n### Validation Plan\n\n_No response_","comments":[],"createdAt":"2026-05-04T11:52:34Z","labels":[{"id":"LA_kwDORPf6ys8AAAACYAlrFQ","name":"bug","description":"Something isn't working","color":"d73a4a"}],"number":1943,"title":"[BUG] pnpm install still requires manual better-sqlite3 installation"} +{"author":{"id":"MDQ6VXNlcjE0OTIxOTgz","is_bot":false,"login":"oyi77","name":"Paijo"},"body":"## Summary\n\nOmniRoute v3.7.8 contains **two different encryption key derivation implementations** in the same package that produce incompatible ciphertexts. The health-check/token-refresh thread uses the old dynamic-salt derivation while the main API uses the new static-salt derivation, causing:\n\n1. **Persistent decrypt failures** — tokens encrypted by the health-check thread cannot be decrypted by the main API\n2. **Re-encryption loop** — the health-check thread continuously re-encrypts tokens with a dynamic salt, undoing any fix applied with the static salt\n3. **CPU spike during crash-loop** — when tokens are encrypted with dynamic salt, the main API fails to decrypt them, causing a cascade of errors and 50% CPU usage\n\n## Root Cause\n\nTwo different key derivation functions exist in the built package:\n\n### Old code (chunk 42476) — Dynamic Salt\n```javascript\nfunction k() {\n if (null !== i) return i;\n let a = process.env.STORAGE_ENCRYPTION_KEY;\n let b = (0, f.createHash)(\"sha256\").update(a).digest().slice(0, 16);\n // ^^^^^^^^^^^^^ DYNAMIC — derived from the key itself\n try { i = (0, f.scryptSync)(a, b, 32); }\n // ...\n}\n```\n\n### New code (chunk 56197) — Static Salt\n```javascript\nfunction l() {\n if (null !== j) return j;\n let a = process.env.STORAGE_ENCRYPTION_KEY;\n let b = \"omniroute-field-encryption-v1\";\n // ^^^^^^^^^^^^^^^^^^^^^^^^^^^ STATIC — hardcoded string\n try { j = (0, g.scryptSync)(a, b, 32); }\n // ...\n}\n```\n\nThese produce **different derived keys** from the same `STORAGE_ENCRYPTION_KEY`, making ciphertexts from one undecryptable by the other.\n\nThe decrypt function in `42476.js` has a fallback that tries the static-salt key when the dynamic-salt key fails, but the **encrypt function** only uses the dynamic-salt key. So the health-check thread encrypts with dynamic salt, the main API tries to decrypt with the dynamic-salt key (different derivation → fails), then falls back to static salt (also fails for newly-encrypted tokens).\n\n## Impact\n\n- **Severity: HIGH** — Causes production outages when tokens become undecryptable\n- **CPU**: 50% CPU usage during crash-loop (from repeated decrypt failures triggering health-check)\n- **Data**: All encrypted tokens become unreadable until manually fixed\n- **Recovery**: Requires manual database fix script to re-encrypt all tokens with static salt\n\n## Reproduction\n\n1. Set `STORAGE_ENCRYPTION_KEY` in server.env\n2. Add OAuth providers — tokens get encrypted with static salt\n3. Health-check thread runs — re-encrypts tokens with dynamic salt\n4. Main API attempts to decrypt — fails (dynamic-salt ciphertext produces different derived key)\n5. Error cascade: decrypt failures → health-check triggers → re-encrypts with dynamic salt → more failures\n6. CPU spikes to 50%, API responses fail for encrypted fields\n\n## Evidence\n\n```bash\n# Check encryption status of active tokens\nnode -e \"\nconst db = require('better-sqlite3')('/home/openclaw/.omniroute/storage.sqlite');\nconst rows = db.prepare('SELECT id, access_token, refresh_token FROM provider_connections WHERE is_active = 1').all();\nlet static = 0, dynamic = 0;\nfor (const row of rows) {\n for (const col of ['access_token', 'refresh_token']) {\n const val = row[col];\n if (!val || val === 'revoked') continue;\n try {\n const buf = Buffer.from(val, 'base64');\n const saltLen = buf.readUInt8(0);\n const salt = buf.slice(1, 1 + saltLen).toString('utf8');\n if (salt === 'omniroute-field-encryption-v1') static++;\n else dynamic++;\n } catch(e) {}\n }\n}\nconsole.log('Static salt:', static, '| Dynamic salt:', dynamic);\ndb.close();\n\"\n\n# Output after health-check thread runs:\n# Static: 0 | Dynamic: 205 (= ALL tokens re-encrypted with dynamic salt)\n\n# Output after running fix-encryption-static.cjs:\n# Static: 539 | Dynamic: 0 (= ALL tokens fixed back to static salt)\n```\n\n## Workaround\n\nWe created a script (`fix-encryption-static.cjs`) that:\n1. Reads all encrypted tokens from the database\n2. Decrypts them using the static-salt key (falling back to dynamic-salt key if static fails)\n3. Re-encrypts them with the static-salt key\n4. Writes the updated tokens back to the database\n\nThis script runs via cron every 5 minutes to counteract the health-check thread re-encryption.\n\n## Suggested Fix\n\n1. **Unify key derivation**: Remove the old dynamic-salt code path and use only the static-salt derivation (`\"omniroute-field-encryption-v1\"`) everywhere\n2. **Add migration**: On startup, scan for tokens encrypted with the dynamic salt and re-encrypt them with the static salt\n3. **Remove the fallback**: The current fallback in the decrypt function (try dynamic, then try static) masks the issue. After migration, only the static salt key should exist.\n\n## Environment\n\n- OmniRoute v3.7.8\n- Node v22.22.1\n- Ubuntu 22.04\n- 1,239 models configured\n- 306 active provider connections\n- STORAGE_ENCRYPTION_KEY set via server.env","comments":[{"id":"IC_kwDORPf6ys8AAAABBLxsAA","author":{"login":"soyelmismo"},"authorAssociation":"NONE","body":"how can i disable the encryption its so annoying.\n","createdAt":"2026-05-04T20:56:21Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/diegosouzapw/OmniRoute/issues/1941#issuecomment-4374424576","viewerDidAuthor":false}],"createdAt":"2026-05-04T11:47:27Z","labels":[],"number":1941,"title":"[BUG] Dual encryption key derivation (dynamic vs static salt) causes persistent decrypt failures and 50% CPU"} +{"author":{"id":"MDQ6VXNlcjEwODk3NDc4","is_bot":false,"login":"JxnLexn","name":"Jan Leon"},"body":"### OmniRoute Version\n\n3.7.8\n\n### Installation Method\n\nDocker / Docker Compose\n\n### Operating System\n\nLinux\n\n### OS Version\n\n_No response_\n\n### Node.js Version\n\n_No response_\n\n### Provider(s) Involved\n\nCodex\n\n### Model(s) Involved\n\nGPT-5.5\n\n### Client Tool\n\nVisual Studio Code (Github Copilot Chat)\n\n### Description\n\nThe costs and the analytics page show costs of $0.00 even though many tokens are being used and the prices per token are set. It was working before, but for the past few days it has stopped working. I am using a combo, but it has worked before using a combo. \nOn the costs page it also shows a fallback rate of 100%, even tough there was never a fallback to another model.\n\n### Steps to Reproduce\n\n1. Use a model/tokens\n2. Go to cost or analytics\n3. See $0.00 altough prices are set\n\n### Expected Behavior\n\nShow estimated costs\n\n### Actual Behavior\n\nDid not show estimated costs, instead $0.00\n\n### Test Impact\n\nUnsure\n\n### Error Logs / Output\n\n```shell\n\n```\n\n### Screenshots\n\n**Costs:**\n\"Image\"\n\n**Analytics:**\n\"Image\"\n\n**Fallback Rate:**\n\"Image\"\n\n**Provider Breakdown:**\n\"Image\"\n\n**Model Breakdown:**\n\"Image\"\n\n**Costs Pricing:**\n\"Image\"\n\n\n### Additional Context\n\n_No response_\n\n### Validation Plan\n\n_No response_","comments":[],"createdAt":"2026-05-04T09:57:53Z","labels":[{"id":"LA_kwDORPf6ys8AAAACYAlrFQ","name":"bug","description":"Something isn't working","color":"d73a4a"}],"number":1934,"title":"[BUG] Est. Cost $0.00"} diff --git a/scripts/build-next-isolated.mjs b/scripts/build-next-isolated.mjs index b6ccc994e0..88e1ffacab 100644 --- a/scripts/build-next-isolated.mjs +++ b/scripts/build-next-isolated.mjs @@ -184,15 +184,21 @@ export async function main() { await resetStandaloneOutput(projectRoot); + console.log("[build-next-isolated] Generating docs index..."); + try { + const { execSync } = await import("node:child_process"); + execSync("node scripts/generate-docs-index.mjs", { cwd: projectRoot, stdio: "inherit" }); + } catch (docGenErr) { + console.warn( + "[build-next-isolated] Docs index generation failed (non-fatal):", + docGenErr?.message + ); + } + const result = await runNextBuild(); if (result.code === 0 && (await exists(path.join(projectRoot, ".next", "standalone")))) { console.log("[build-next-isolated] Copying static assets for standalone server..."); try { - await fs.cp( - path.join(projectRoot, "public"), - path.join(projectRoot, ".next", "standalone", "public"), - { recursive: true } - ); await fs.cp( path.join(projectRoot, ".next", "static"), path.join(projectRoot, ".next", "standalone", ".next", "static"), @@ -202,6 +208,17 @@ export async function main() { console.warn("[build-next-isolated] Non-fatal error copying static assets:", copyErr); } + try { + await fs.cp( + path.join(projectRoot, "docs"), + path.join(projectRoot, ".next", "standalone", "docs"), + { recursive: true } + ); + console.log("[build-next-isolated] Copied docs/ to standalone output"); + } catch (docsCopyErr) { + console.warn("[build-next-isolated] Non-fatal error copying docs/:", docsCopyErr?.message); + } + try { await pruneStandaloneArtifacts(projectRoot); } catch (pruneErr) { diff --git a/scripts/generate-docs-index.mjs b/scripts/generate-docs-index.mjs new file mode 100644 index 0000000000..482c435f11 --- /dev/null +++ b/scripts/generate-docs-index.mjs @@ -0,0 +1,230 @@ +#!/usr/bin/env node + +/** + * Build-time script: scans docs/*.md and generates + * src/app/docs/lib/docs-auto-generated.ts with static navigation + search data. + * + * This file is imported by both client and server components — NO fs/path imports. + * Run via: node scripts/generate-docs-index.mjs + * Automatically runs as prebuild step. + */ + +import fs from "node:fs"; +import path from "node:path"; +import matter from "gray-matter"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, ".."); +const DOCS_DIR = path.join(ROOT, "docs"); +const OUT_FILE = path.join(ROOT, "src", "app", "docs", "lib", "docs-auto-generated.ts"); + +const SECTION_CATEGORIES = { + "Getting Started": [ + "SETUP_GUIDE", + "USER_GUIDE", + "CLI_TOOLS", + "ARCHITECTURE", + "QUICK_START", + "GETTING_STARTED", + ], + Features: [ + "FEATURES", + "AUTO_COMBO", + "COMPRESSION_GUIDE", + "RTK_COMPRESSION", + "COMPRESSION_ENGINES", + "COMPRESSION_RULES_FORMAT", + "COMPRESSION_LANGUAGE_PACKS", + "FREE_TIERS", + ], + "API & Protocols": ["API_REFERENCE", "MCP_SERVER", "A2A_SERVER"], + Deployment: [ + "DOCKER_GUIDE", + "VM_DEPLOYMENT_GUIDE", + "FLY_IO_DEPLOYMENT_GUIDE", + "TERMUX_GUIDE", + "PWA_GUIDE", + ], + Operations: ["PROXY_GUIDE", "RESILIENCE_GUIDE", "ENVIRONMENT", "TROUBLESHOOTING"], + Development: [ + "CODEBASE_DOCUMENTATION", + "COVERAGE_PLAN", + "I18N", + "RELEASE_CHECKLIST", + "UNINSTALL", + "CONTRIBUTING", + "CHANGELOG", + "CODE_OF_CONDUCT", + ], +}; + +const SECTION_ORDER = { + "Getting Started": 1, + Features: 2, + "API & Protocols": 3, + Deployment: 4, + Operations: 5, + Development: 6, +}; + +function categorizeFile(fileName) { + const stem = fileName.replace(/\.md$/i, "").toUpperCase().replace(/-/g, "_"); + for (const [section, patterns] of Object.entries(SECTION_CATEGORIES)) { + if (patterns.some((p) => stem === p)) { + return section; + } + } + return "Other"; +} + +function extractTitleFromContent(content) { + const match = content.match(/^#\s+(.+)$/m); + if (match) { + return match[1] + .replace(/^📖\s*/, "") + .replace(/^🌐\s*/, "") + .replace(/\s*—\s*OmniRoute\s*$/i, "") + .replace(/\s*—\s*OmniRoute Docs\s*$/i, "") + .trim(); + } + return ""; +} + +function extractHeadings(content) { + const headings = []; + const regex = /^(#{2,4})\s+(.+)$/gm; + let match; + while ((match = regex.exec(content)) !== null) { + headings.push(match[2].replace(/\*\*/g, "").replace(/\*/g, "").replace(/`/g, "").trim()); + } + return headings.slice(0, 10); +} + +function extractContentPreview(content) { + const stripped = content + .replace(/^---[\s\S]*?---/m, "") + .replace(/^#{1,6}\s+.+$/gm, "") + .replace(/```[\s\S]*?```/g, "") + .replace(/!\[[^\]]*\]\([^)]+\)/g, "") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/[*_`~>#|]/g, "") + .replace(/\s+/g, " ") + .trim(); + return stripped.slice(0, 300); +} + +// ---------- Main ---------- + +const files = fs.readdirSync(DOCS_DIR).filter((f) => f.endsWith(".md") || f.endsWith(".mdx")); + +const docs = []; +for (const fileName of files) { + const filePath = path.join(DOCS_DIR, fileName); + const fileContent = fs.readFileSync(filePath, "utf8"); + const { data: frontmatter, content } = matter(fileContent); + + const slug = + frontmatter.slug || + fileName + .replace(/\.mdx?$/i, "") + .toLowerCase() + .replace(/_/g, "-"); + const title = frontmatter.title || extractTitleFromContent(content) || slug.replace(/-/g, " "); + const section = frontmatter.section || categorizeFile(fileName); + const order = frontmatter.order ?? 999; + const headings = extractHeadings(content); + const contentPreview = frontmatter.description || extractContentPreview(content); + + docs.push({ slug, title, fileName, section, order, content: contentPreview, headings }); +} + +docs.sort((a, b) => { + const sectionA = SECTION_ORDER[a.section] ?? 99; + const sectionB = SECTION_ORDER[b.section] ?? 99; + if (sectionA !== sectionB) return sectionA - sectionB; + return a.order - b.order; +}); + +// Build navigation sections +const sectionMap = new Map(); +for (const doc of docs) { + const items = sectionMap.get(doc.section) || []; + items.push(doc); + sectionMap.set(doc.section, items); +} +const orderedSections = [...new Set([...Object.keys(SECTION_ORDER), ...sectionMap.keys()])]; +const navSections = orderedSections + .filter((s) => sectionMap.has(s)) + .sort((a, b) => (SECTION_ORDER[a] ?? 99) - (SECTION_ORDER[b] ?? 99)) + .map((title) => ({ + title, + items: sectionMap.get(title).map((doc) => ({ + slug: doc.slug, + title: doc.title, + fileName: doc.fileName, + })), + })); + +// Build search index +const searchIndex = docs.map((doc) => ({ + slug: doc.slug, + title: doc.title, + fileName: doc.fileName, + section: doc.section, + content: doc.content, + headings: doc.headings, +})); + +// Add api-explorer synthetic entry if api-reference exists +if (searchIndex.some((item) => item.slug === "api-reference")) { + if (!searchIndex.some((item) => item.slug === "api-explorer")) { + searchIndex.push({ + slug: "api-explorer", + title: "API Explorer", + fileName: "API_REFERENCE.md", + section: "API & Protocols", + content: "interactive try it live api explorer endpoint test request response curl example", + headings: ["Try It", "Endpoints"], + }); + } +} + +// ---------- Write output ---------- + +const output = `// AUTO-GENERATED by scripts/generate-docs-index.mjs — DO NOT EDIT MANUALLY +// Regenerate with: node scripts/generate-docs-index.mjs + +export interface AutoGenDocItem { + slug: string; + title: string; + fileName: string; +} + +export interface AutoGenNavSection { + title: string; + items: AutoGenDocItem[]; +} + +export interface AutoGenSearchItem { + slug: string; + title: string; + fileName: string; + section: string; + content: string; + headings: string[]; +} + +export const autoNavSections: AutoGenNavSection[] = ${JSON.stringify(navSections, null, 2)}; + +export const autoSearchIndex: AutoGenSearchItem[] = ${JSON.stringify(searchIndex, null, 2)}; + +export const autoAllSlugs: string[] = ${JSON.stringify( + docs.map((d) => d.slug), + null, + 2 +)}; +`; + +fs.writeFileSync(OUT_FILE, output, "utf8"); +console.log(`✅ Generated ${OUT_FILE} with ${docs.length} docs, ${navSections.length} sections`); diff --git a/scripts/prepublish.ts b/scripts/prepublish.ts index 8b54f60067..819e1dd2b1 100644 --- a/scripts/prepublish.ts +++ b/scripts/prepublish.ts @@ -461,6 +461,21 @@ if (existsSync(openapiSpecSrc)) { cpSync(openapiSpecSrc, join(docsDest, "openapi.yaml")); } +const docsMarkdownSrc = join(ROOT, "docs"); +if (existsSync(docsMarkdownSrc)) { + const docsDest = join(APP_DIR, "docs"); + mkdirSync(docsDest, { recursive: true }); + const mdFiles = readdirSync(docsMarkdownSrc).filter( + (f) => f.endsWith(".md") || f.endsWith(".mdx") + ); + for (const mdFile of mdFiles) { + cpSync(join(docsMarkdownSrc, mdFile), join(docsDest, mdFile)); + } + if (mdFiles.length > 0) { + console.log(`[prepublish] Copied ${mdFiles.length} docs markdown files to app/docs/`); + } +} + const syncEnvSrc = join(ROOT, "scripts", "sync-env.mjs"); if (existsSync(syncEnvSrc)) { const scriptsDest = join(APP_DIR, "scripts"); diff --git a/scripts/scratch/test-math-regex.js b/scripts/scratch/test-math-regex.js new file mode 100644 index 0000000000..e42a210ec8 --- /dev/null +++ b/scripts/scratch/test-math-regex.js @@ -0,0 +1,3 @@ +const re = /\$\$[^$]*(?:\$(?!\$)[^$]*)*\$\$/g; +const txt = "some text $$ a + b = c $$ more text $$ x + $y$ = z $$ end"; +console.log(txt.match(re)); diff --git a/scripts/scratch/test-regex.js b/scripts/scratch/test-regex.js new file mode 100644 index 0000000000..deba62356e --- /dev/null +++ b/scripts/scratch/test-regex.js @@ -0,0 +1,5 @@ +const text = "hello world. this is a test\n\nnew line. another."; +const re1 = /(^|[.!?][ \t]+|\n+[ \t]*)([a-z])/g; +const re2 = /(^|[.!?]\s+)([a-z])/gm; +console.log(text.replace(re1, (m, p, c) => p + c.toUpperCase())); +console.log(text.replace(re2, (m, p, c) => p + c.toUpperCase())); diff --git a/scripts/scratch/test-regex2.js b/scripts/scratch/test-regex2.js new file mode 100644 index 0000000000..c6eeb518a6 --- /dev/null +++ b/scripts/scratch/test-regex2.js @@ -0,0 +1,5 @@ +const text = "hello\n world"; +const re1 = /(^|[.!?][ \t]+|\n+[ \t]*)([a-z])/g; +const re3 = /(^|[.!?]\s+|^\s+)([a-z])/gm; +console.log(text.replace(re1, (m, p, c) => p + c.toUpperCase())); +console.log(text.replace(re3, (m, p, c) => p + c.toUpperCase())); diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx index 2b2c8d27c3..126d75b215 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx @@ -21,6 +21,7 @@ import { CustomCliCard, } from "./components"; import { useTranslations } from "next-intl"; +import { DEFAULT_DISPLAY_BASE_URL } from "@/shared/hooks"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; const AUTO_CONFIGURED_TOOL_IDS = new Set([ @@ -226,7 +227,7 @@ export default function CLIToolsPageClient({ machineId: _machineId }) { if (typeof window !== "undefined") { return window.location.origin; } - return "http://localhost:20128"; + return DEFAULT_DISPLAY_BASE_URL; }; if (loading || !statusesLoaded) { diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.tsx index 8e61118c7c..e079a4e77d 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.tsx @@ -5,6 +5,7 @@ import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/comp import Image from "next/image"; import CliStatusBadge from "./CliStatusBadge"; import { useTranslations } from "next-intl"; +import { DEFAULT_DISPLAY_BASE_URL } from "@/shared/hooks"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -147,7 +148,7 @@ export default function ClineToolCard({ const getEffectiveBaseUrl = () => { if (customBaseUrl) return customBaseUrl; - return baseUrl || "http://localhost:20128"; + return baseUrl || DEFAULT_DISPLAY_BASE_URL; }; const handleApply = async () => { diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CustomCliCard.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/CustomCliCard.tsx index 1726de6025..e0119498ba 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CustomCliCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CustomCliCard.tsx @@ -9,6 +9,7 @@ import { buildCustomCliJsonConfig, normalizeOpenAiBaseUrl, } from "./customCliConfig"; +import { DEFAULT_DISPLAY_BASE_URL } from "@/shared/hooks"; interface ModelOption { value: string; @@ -59,7 +60,7 @@ export default function CustomCliCard({ (!cloudEnabled ? "sk_omniroute" : translateOrFallback("yourApiKeyPlaceholder", "sk-your-omniroute-key")); - const baseUrlWithV1 = normalizeOpenAiBaseUrl(baseUrl || "http://localhost:20128"); + const baseUrlWithV1 = normalizeOpenAiBaseUrl(baseUrl || DEFAULT_DISPLAY_BASE_URL); const chatCompletionsEndpoint = `${baseUrlWithV1}/chat/completions`; const envScript = useMemo( diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.tsx index 89e976fa65..95f7074efa 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.tsx @@ -7,6 +7,7 @@ import { useTranslations } from "next-intl"; import { copyToClipboard } from "@/shared/utils/clipboard"; import { buildOpenCodeConfigDocument } from "@/shared/services/opencodeConfig"; import { useTheme } from "@/shared/hooks/useTheme"; +import { DEFAULT_DISPLAY_BASE_URL } from "@/shared/hooks"; export default function DefaultToolCard({ toolId, @@ -90,7 +91,7 @@ export default function DefaultToolCard({ [getSelectedModelEntries] ); - const normalizedBaseUrl = baseUrl || "http://localhost:20128"; + const normalizedBaseUrl = baseUrl || DEFAULT_DISPLAY_BASE_URL; const baseUrlWithV1 = normalizedBaseUrl.endsWith("/v1") ? normalizedBaseUrl : `${normalizedBaseUrl}/v1`; diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.tsx index 52cfd85391..e99243968b 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.tsx @@ -5,6 +5,7 @@ import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/comp import Image from "next/image"; import CliStatusBadge from "./CliStatusBadge"; import { useTranslations } from "next-intl"; +import { DEFAULT_DISPLAY_BASE_URL } from "@/shared/hooks"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -133,7 +134,7 @@ export default function KiloToolCard({ const getEffectiveBaseUrl = () => { if (customBaseUrl) return customBaseUrl; - return baseUrl || "http://localhost:20128"; + return baseUrl || DEFAULT_DISPLAY_BASE_URL; }; const handleApply = async () => { diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/customCliConfig.ts b/src/app/(dashboard)/dashboard/cli-tools/components/customCliConfig.ts index 82488084a2..b3c205f768 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/customCliConfig.ts +++ b/src/app/(dashboard)/dashboard/cli-tools/components/customCliConfig.ts @@ -1,3 +1,5 @@ +import { DEFAULT_DISPLAY_BASE_URL } from "@/shared/hooks"; + export interface CustomCliAliasMapping { alias: string; model: string; @@ -12,7 +14,7 @@ export interface CustomCliConfigInput { } export function normalizeOpenAiBaseUrl(baseUrl: string): string { - const trimmed = (baseUrl || "http://localhost:20128").trim().replace(/\/+$/, ""); + const trimmed = (baseUrl || DEFAULT_DISPLAY_BASE_URL).trim().replace(/\/+$/, ""); return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`; } diff --git a/src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx b/src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx index a0a983ce44..14473409f9 100644 --- a/src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx +++ b/src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx @@ -111,6 +111,27 @@ function createCurrencyFormatter(locale: string) { }); } +function formatCurrencyCost(locale: string, value: number): string { + const numericValue = Number(value || 0); + if (!Number.isFinite(numericValue) || numericValue === 0) { + return new Intl.NumberFormat(locale, { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(0); + } + + const absValue = Math.abs(numericValue); + const fractionDigits = absValue < 0.01 ? 6 : absValue < 1 ? 4 : 2; + return new Intl.NumberFormat(locale, { + style: "currency", + currency: "USD", + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }).format(numericValue); +} + function csvCell(value: string | number): string { const text = String(value); return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; @@ -283,18 +304,20 @@ export default function CostOverviewTab() { requestedModelCoveragePct: 0, streak: 0, }; + const hasCostData = summary.totalCost > 0; + const providersByCost = [...(analytics?.byProvider || [])] - .filter((provider) => provider.cost > 0) - .sort((left, right) => right.cost - left.cost); + .filter((provider) => (hasCostData ? provider.cost > 0 : provider.requests > 0)) + .sort((left, right) => (hasCostData ? right.cost - left.cost : right.requests - left.requests)); const modelsByCost = [...(analytics?.byModel || [])] - .filter((model) => model.cost > 0) - .sort((left, right) => right.cost - left.cost); + .filter((model) => (hasCostData ? model.cost > 0 : model.requests > 0)) + .sort((left, right) => (hasCostData ? right.cost - left.cost : right.requests - left.requests)); const apiKeysByCost = [...(analytics?.byApiKey || [])] - .filter((apiKey) => apiKey.cost > 0) - .sort((left, right) => right.cost - left.cost); + .filter((apiKey) => (hasCostData ? apiKey.cost > 0 : apiKey.requests > 0)) + .sort((left, right) => (hasCostData ? right.cost - left.cost : right.requests - left.requests)); const accountsByCost = [...(analytics?.byAccount || [])] - .filter((account) => account.cost > 0) - .sort((left, right) => right.cost - left.cost); + .filter((account) => (hasCostData ? account.cost > 0 : account.requests > 0)) + .sort((left, right) => (hasCostData ? right.cost - left.cost : right.requests - left.requests)); const avgCostPerRequest = summary.totalRequests > 0 ? summary.totalCost / summary.totalRequests : 0; const dailyTrend = analytics?.dailyTrend || []; @@ -398,25 +421,25 @@ export default function CostOverviewTab() {
@@ -438,7 +461,7 @@ export default function CostOverviewTab() { />
@@ -621,7 +644,7 @@ export default function CostOverviewTab() { )} - {summary.totalCost <= 0 ? ( + {summary.totalCost <= 0 && summary.totalRequests <= 0 ? ( ) : ( <> -
- - -
+ {hasCostData && ( +
+ + +
+ )}
@@ -1026,14 +1057,16 @@ function TopListCard({ secondaryKey, secondaryLabel, locale, + hasCostData, }: { title: string; - rows: Array>; + rows: Array>; nameKey: string; valueKey: string; secondaryKey?: string; secondaryLabel?: string; locale: string; + hasCostData?: boolean; }) { const currencyFormatter = createCurrencyFormatter(locale); @@ -1059,7 +1092,11 @@ function TopListCard({ ) : null} - {currencyFormatter.format(Number(row[valueKey] || 0))} + {hasCostData || Number(row[valueKey] || 0) > 0 ? ( + currencyFormatter.format(Number(row[valueKey] || 0)) + ) : ( + Legacy / Free + )} @@ -1083,7 +1120,7 @@ function CostBreakdownTable({ locale, }: { title: string; - rows: Array>; + rows: Array>; columns: ColumnDef[]; locale: string; }) { @@ -1093,7 +1130,7 @@ function CostBreakdownTable({ const num = Number(value || 0); switch (format) { case "currency": - return currencyFormatter.format(num); + return num > 0 ? currencyFormatter.format(num) : "Legacy / Free"; case "compact": return new Intl.NumberFormat(locale, { notation: "compact" }).format(num); case "number": diff --git a/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx b/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx index 1bfaee3f81..4f9b8c9b83 100644 --- a/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useMemo } from "react"; import { Card } from "@/shared/components"; +import { useDisplayBaseUrl } from "@/shared/hooks"; /* ─── Types ──────────────────────────────────────────── */ interface Endpoint { @@ -65,6 +66,7 @@ const WEBHOOK_EVENTS = [ /* ─── Main Component ─────────────────────────────────── */ export default function ApiEndpointsTab() { + const baseUrl = useDisplayBaseUrl(); const [catalog, setCatalog] = useState(null); const [catalogError, setCatalogError] = useState(null); const [loading, setLoading] = useState(true); @@ -536,7 +538,7 @@ export default function ApiEndpointsTab() { Example

- curl -X {ep.method} http://localhost:20128 + curl -X {ep.method} {baseUrl} {ep.path.replace("/api/", "/")} {ep.security ? ' -H "Authorization: Bearer YOUR_KEY"' : ""} {ep.requestBody diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx index 10f3e25412..3be6a00ca1 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useMemo, useCallback } from "react"; import Link from "next/link"; import { Card, Button, Input, Modal, CardSkeleton, SegmentedControl } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { useDisplayBaseUrl } from "@/shared/hooks"; import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers"; import { useTranslations } from "next-intl"; @@ -1007,7 +1008,8 @@ export default function APIPageClient({ machineId }: APIPageClientProps) { } }, [fetchTailscaleStatus, handleTailscaleEnable, tailscalePassword, translateOrFallback]); - const [baseUrl, setBaseUrl] = useState("/v1"); + const displayBaseUrl = useDisplayBaseUrl(); + const baseUrl = `${displayBaseUrl}/v1`; const normalizedCloudBaseUrl = cloudBaseUrl ? resolvedMachineId && !cloudBaseUrl.endsWith(`/${resolvedMachineId}`) ? `${cloudBaseUrl}/${resolvedMachineId}` @@ -1015,14 +1017,6 @@ export default function APIPageClient({ machineId }: APIPageClientProps) { : null; const cloudEndpointNew = normalizedCloudBaseUrl ? `${normalizedCloudBaseUrl}/v1` : null; - // Hydration fix: Only access window on client side - useEffect(() => { - if (typeof window !== "undefined") { - const defaultOrigin = process.env.NEXT_PUBLIC_BASE_URL || window.location.origin; - setBaseUrl(`${defaultOrigin}/v1`); - } - }, []); - if (loading) { return (
diff --git a/src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx b/src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx index ce993e3663..8b7aa4e33f 100644 --- a/src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx @@ -104,4 +104,48 @@ describe("ApiEndpointsTab", () => { expect(document.body.textContent).toContain("1 endpoints across 1 categories"); expect(document.body.textContent).toContain("/api/v1/chat/completions"); }); + + it("renders curl example using window.location.origin when NEXT_PUBLIC_BASE_URL is unset", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + info: { title: "OmniRoute API", version: "3.7.6" }, + servers: [], + tags: [{ name: "Chat" }], + endpoints: [ + { + method: "POST", + path: "/api/v1/chat/completions", + tags: ["Chat"], + summary: "Create chat completion", + description: "Create chat completion", + security: false, + parameters: [], + requestBody: false, + responses: ["200"], + }, + ], + schemas: [], + }) + ); + + renderApiEndpointsTab(); + + await waitForText("OmniRoute API"); + + // Expand the endpoint to reveal the curl example + const endpointRow = document.body.querySelector("code.font-mono.flex-1"); + if (endpointRow?.parentElement) { + await act(async () => { + endpointRow.parentElement!.click(); + }); + } + + // After mount the hook swaps DEFAULT_DISPLAY_BASE_URL for window.location.origin. + // In jsdom the default origin is "http://localhost". + const expectedOrigin = window.location.origin; // "http://localhost" in jsdom + await waitForText(`curl -X POST ${expectedOrigin}/v1/chat/completions`); + expect(document.body.textContent).toContain( + `curl -X POST ${expectedOrigin}/v1/chat/completions` + ); + }); }); diff --git a/src/app/(dashboard)/dashboard/onboarding/page.tsx b/src/app/(dashboard)/dashboard/onboarding/page.tsx index 71995da7f7..df80edcaa5 100644 --- a/src/app/(dashboard)/dashboard/onboarding/page.tsx +++ b/src/app/(dashboard)/dashboard/onboarding/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; +import { useDisplayBaseUrl } from "@/shared/hooks"; const STEP_IDS = ["welcome", "security", "provider", "test", "done"]; const STEP_ICONS = ["waving_hand", "lock", "dns", "play_circle", "check_circle"]; @@ -20,9 +21,10 @@ export default function OnboardingWizard() { const router = useRouter(); const t = useTranslations("onboarding"); const tc = useTranslations("common"); + const baseUrl = useDisplayBaseUrl(); const [step, setStep] = useState(0); const [loading, setLoading] = useState(true); - const [apiEndpoint, setApiEndpoint] = useState("http://localhost:20128/api/v1"); + const [apiEndpoint, setApiEndpoint] = useState(`${baseUrl}/api/v1`); // Security step state const [password, setPassword] = useState(""); diff --git a/src/app/(dashboard)/dashboard/settings/components/CacheSettingsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/CacheSettingsTab.tsx deleted file mode 100644 index 633592a462..0000000000 --- a/src/app/(dashboard)/dashboard/settings/components/CacheSettingsTab.tsx +++ /dev/null @@ -1,191 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { Card, Button } from "@/shared/components"; -import { useTranslations } from "next-intl"; - -interface CacheConfig { - semanticCacheEnabled: boolean; - semanticCacheMaxSize: number; - semanticCacheTTL: number; - promptCacheEnabled: boolean; - promptCacheStrategy: "auto" | "system-only" | "manual"; - alwaysPreserveClientCache: "auto" | "always" | "never"; -} - -export default function CacheSettingsTab() { - const t = useTranslations("settings"); - const [config, setConfig] = useState({ - semanticCacheEnabled: true, - semanticCacheMaxSize: 100, - semanticCacheTTL: 1800000, - promptCacheEnabled: true, - promptCacheStrategy: "auto", - alwaysPreserveClientCache: "auto", - }); - const [saving, setSaving] = useState(false); - const [loading, setLoading] = useState(true); - - useEffect(() => { - fetch("/api/settings/cache-config") - .then((r) => (r.ok ? r.json() : null)) - .then((data) => { - if (data) setConfig(data); - }) - .catch(() => {}) - .finally(() => setLoading(false)); - }, []); - - const handleSave = async () => { - setSaving(true); - try { - await fetch("/api/settings/cache-config", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(config), - }); - } finally { - setSaving(false); - } - }; - - if (loading) { - return ( - -

{t("loading")}

-
- ); - } - - return ( - -

- cached - {t("cacheSettings")} -

- -
- {/* Semantic Cache */} -
-

{t("semanticCache")}

- - - - - - -
- - {/* Prompt Cache */} -
-

{t("promptCache")}

- - - - - - -
- - {/* Save */} -
- -
-
-
- ); -} diff --git a/src/app/(dashboard)/dashboard/settings/components/ProxyTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ProxyTab.tsx index 6925fa57d0..355f2d1909 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ProxyTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ProxyTab.tsx @@ -5,6 +5,7 @@ import { Card, Button, ProxyConfigModal, Toggle } from "@/shared/components"; import { useTranslations } from "next-intl"; import ProxyRegistryManager from "./ProxyRegistryManager"; import OneproxyTab from "./OneproxyTab"; +import RequestLimitsTab from "./RequestLimitsTab"; export default function ProxyTab() { const [proxyModalOpen, setProxyModalOpen] = useState(false); @@ -184,6 +185,7 @@ export default function ProxyTab() {
+ (null); + + useEffect(() => { + let active = true; + + fetch("/api/settings") + .then((response) => { + if (!response.ok) throw new Error(`Settings API returned ${response.status}`); + return response.json() as Promise; + }) + .then((settings) => { + if (!active) return; + const nextValue = normalizeInputValue(settings.maxBodySizeMb); + setValue(nextValue); + setSavedValue(nextValue); + }) + .catch((error) => { + console.error("Failed to load request limit settings:", error); + if (active) { + setMessage({ type: "error", text: t("requestBodyLimitLoadFailed") }); + } + }) + .finally(() => { + if (active) setLoading(false); + }); + + return () => { + active = false; + }; + }, [t]); + + const validationError = useMemo(() => { + const trimmed = value.trim(); + if (!trimmed) return t("requestBodyLimitEmptyError"); + + const parsed = Number(trimmed); + if (!Number.isInteger(parsed)) return t("requestBodyLimitWholeNumberError"); + if (parsed < MIN_REQUEST_BODY_LIMIT_MB) { + return t("requestBodyLimitMinimumError", { min: MIN_REQUEST_BODY_LIMIT_MB }); + } + if (parsed > MAX_REQUEST_BODY_LIMIT_MB) { + return t("requestBodyLimitMaximumError", { max: MAX_REQUEST_BODY_LIMIT_MB }); + } + + return null; + }, [t, value]); + + const dirty = value.trim() !== savedValue; + + const saveLimit = useCallback(async () => { + if (validationError || !dirty) return; + + const nextValue = Number(value.trim()); + setSaving(true); + setMessage(null); + + try { + const response = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ maxBodySizeMb: nextValue }), + }); + + if (!response.ok) throw new Error(`Settings API returned ${response.status}`); + + const settings = (await response.json()) as SettingsResponse; + const saved = normalizeInputValue(settings.maxBodySizeMb ?? nextValue); + setValue(saved); + setSavedValue(saved); + setMessage({ type: "success", text: t("requestBodyLimitSaveSuccess") }); + } catch (error) { + console.error("Failed to save request body limit:", error); + setMessage({ type: "error", text: t("requestBodyLimitSaveFailed") }); + } finally { + setSaving(false); + } + }, [dirty, t, validationError, value]); + + return ( + +
+
+

{t("requestBodyLimitTitle")}

+

{t("requestBodyLimitDescription")}

+
+
+ + { + setValue(event.target.value); + setMessage(null); + }} + onKeyDown={(event) => { + if (event.key === "Enter" && dirty) void saveLimit(); + }} + className="w-32 px-3 py-1.5 rounded bg-surface-2 border border-border text-sm text-text-primary" + disabled={loading || saving} + /> + MB + + {dirty && ( + + {t("requestBodyLimitCurrent", { value: savedValue })} + + )} +
+ {validationError &&

{validationError}

} + {message && ( +

+ {message.text} +

+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx b/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx index a52ec58a0d..0b6731d384 100644 --- a/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx @@ -4,6 +4,12 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, Badge } from "@/shared/components"; import { useLocale, useTranslations } from "next-intl"; +const rowCountFormatter = new Intl.NumberFormat("en-US"); + +function formatRows(rows: number | null | undefined) { + return typeof rows === "number" ? rowCountFormatter.format(rows) : "100K"; +} + export default function SystemStorageTab() { const [backups, setBackups] = useState([]); const [backupsLoading, setBackupsLoading] = useState(false); @@ -24,6 +30,15 @@ export default function SystemStorageTab() { const [purgeLogsStatus, setPurgeLogsStatus] = useState({ type: "", message: "" }); const [cleanupBackupsLoading, setCleanupBackupsLoading] = useState(false); const [cleanupBackupsStatus, setCleanupBackupsStatus] = useState({ type: "", message: "" }); + const [purgeQuotaSnapshotsLoading, setPurgeQuotaSnapshotsLoading] = useState(false); + const [purgeQuotaSnapshotsStatus, setPurgeQuotaSnapshotsStatus] = useState({ + type: "", + message: "", + }); + const [purgeCallLogsLoading, setPurgeCallLogsLoading] = useState(false); + const [purgeCallLogsStatus, setPurgeCallLogsStatus] = useState({ type: "", message: "" }); + const [purgeDetailedLogsLoading, setPurgeDetailedLogsLoading] = useState(false); + const [purgeDetailedLogsStatus, setPurgeDetailedLogsStatus] = useState({ type: "", message: "" }); const fileInputRef = useRef(null); const jsonInputRef = useRef(null); const locale = useLocale(); @@ -53,6 +68,12 @@ export default function SystemStorageTab() { retentionDays: 0, }); + // Database settings state (tasks 23-26) + const [dbSettings, setDbSettings] = useState(null); + const [dbSettingsLoading, setDbSettingsLoading] = useState(true); + const [dbSettingsSaving, setDbSettingsSaving] = useState(false); + const [dbStatsRefreshing, setDbStatsRefreshing] = useState(false); + const loadBackups = async () => { setBackupsLoading(true); try { @@ -81,6 +102,53 @@ export default function SystemStorageTab() { } }; + const loadDatabaseSettings = async () => { + setDbSettingsLoading(true); + try { + const res = await fetch("/api/settings/database"); + if (res.ok) { + const data = await res.json(); + setDbSettings(data); + } + } catch (err) { + console.error("Failed to load database settings:", err); + } finally { + setDbSettingsLoading(false); + } + }; + + const saveDatabaseSettings = async () => { + if (!dbSettings) return; + setDbSettingsSaving(true); + try { + const { logs, backup, cache, retention, aggregation, optimization } = dbSettings; + const res = await fetch("/api/settings/database", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ logs, backup, cache, retention, aggregation, optimization }), + }); + if (res.ok) { + await loadDatabaseSettings(); + } + } catch (err) { + console.error("Failed to save database settings:", err); + } finally { + setDbSettingsSaving(false); + } + }; + + const refreshDatabaseStats = async () => { + setDbStatsRefreshing(true); + try { + await fetch("/api/settings/database/refresh-stats", { method: "POST" }); + await loadDatabaseSettings(); + } catch (err) { + console.error("Failed to refresh database stats:", err); + } finally { + setDbStatsRefreshing(false); + } + }; + const handleCleanupBackups = async () => { setCleanupBackupsLoading(true); setCleanupBackupsStatus({ type: "", message: "" }); @@ -176,6 +244,7 @@ export default function SystemStorageTab() { useEffect(() => { loadStorageHealth(); + loadDatabaseSettings(); }, []); /** Triggers a browser file download from an existing Blob. */ @@ -416,6 +485,98 @@ export default function SystemStorageTab() { + {/* Logs Settings Section */} +
+
+
+

Logs Settings

+

+ Configure detailed logging and call log pipeline settings +

+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + {/* Cache Settings Section */} +
+
+
+

Cache Settings

+

+ Configure semantic and prompt caching behavior +

+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
@@ -434,7 +595,7 @@ export default function SystemStorageTab() { App {storageHealth.retentionDays.app}d - {storageHealth.tableMaxRows?.callLogs?.toLocaleString() || "100K"} rows + {formatRows(storageHealth.tableMaxRows?.callLogs)} rows
@@ -742,6 +903,25 @@ export default function SystemStorageTab() { {t("clearCache") || "Clear Cache"} + {clearCacheStatus.message && ( +
+
+ + {clearCacheStatus.message} +
+
+ )} +
+
+ {purgeLogsStatus.message && ( +
+
+ + {purgeLogsStatus.message} +
+
+ )} +
+ + + {/* Purge Section */} +
+
+ +

Purge Data

+
+
+
+ +
+ +
+ +
+ +
+ +
+
+ {purgeLogsStatus.message && ( +
+
+ + {purgeLogsStatus.message} +
+
+ )} +
+
+ + +
+ {(clearCacheStatus.message || purgeLogsStatus.message) && ( +
+ {clearCacheStatus.message && ( +
+
+ + {clearCacheStatus.message} +
+
+ )} + {purgeLogsStatus.message && ( +
+
+ + {purgeLogsStatus.message} +
+
+ )} +
+ )} + + {/* Purge Data section */} +
+
+
+
+ +

Purge Data

+
+

+ Immediately delete all records (no retention check). Use with caution. +

+
+
+
+ + +
- {(clearCacheStatus.message || purgeLogsStatus.message) && ( -
- {clearCacheStatus.message && ( + {(purgeQuotaSnapshotsStatus.message || + purgeCallLogsStatus.message || + purgeDetailedLogsStatus.message) && ( +
+ {purgeQuotaSnapshotsStatus.message && (
- {clearCacheStatus.message} + {purgeQuotaSnapshotsStatus.message}
)} - {purgeLogsStatus.message && ( + {purgeCallLogsStatus.message && (
- {purgeLogsStatus.message} + {purgeCallLogsStatus.message} +
+
+ )} + {purgeDetailedLogsStatus.message && ( +
+
+ + {purgeDetailedLogsStatus.message}
)} @@ -978,6 +1556,465 @@ export default function SystemStorageTab() {
)}
+ + {/* Task 23: Retention Policy Settings */} + {!dbSettingsLoading && dbSettings && ( +
+

+ + Retention Policy Settings +

+
+
+ + + setDbSettings({ + ...dbSettings, + retention: { + ...dbSettings.retention, + quotaSnapshots: parseInt(e.target.value) || 7, + }, + }) + } + className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-bg focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+
+ + + setDbSettings({ + ...dbSettings, + retention: { + ...dbSettings.retention, + compressionAnalytics: parseInt(e.target.value) || 30, + }, + }) + } + className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-bg focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+
+ + + setDbSettings({ + ...dbSettings, + retention: { + ...dbSettings.retention, + mcpAudit: parseInt(e.target.value) || 30, + }, + }) + } + className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-bg focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+
+ + + setDbSettings({ + ...dbSettings, + retention: { + ...dbSettings.retention, + a2aEvents: parseInt(e.target.value) || 30, + }, + }) + } + className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-bg focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+
+ + + setDbSettings({ + ...dbSettings, + retention: { + ...dbSettings.retention, + callLogs: parseInt(e.target.value) || 30, + }, + }) + } + className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-bg focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+
+ + + setDbSettings({ + ...dbSettings, + retention: { + ...dbSettings.retention, + usageHistory: parseInt(e.target.value) || 30, + }, + }) + } + className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-bg focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+
+ + + setDbSettings({ + ...dbSettings, + retention: { + ...dbSettings.retention, + memoryEntries: parseInt(e.target.value) || 30, + }, + }) + } + className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-bg focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+
+
+ +
+
+ )} + + {/* Task 24: Compression/Aggregation Settings */} + {!dbSettingsLoading && dbSettings && ( +
+

+ + Compression & Aggregation Settings +

+
+
+ + setDbSettings({ + ...dbSettings, + aggregation: { ...dbSettings.aggregation, enabled: e.target.checked }, + }) + } + className="w-4 h-4 rounded border-border text-primary focus:ring-2 focus:ring-primary" + /> + +
+
+
+ + + setDbSettings({ + ...dbSettings, + aggregation: { + ...dbSettings.aggregation, + rawDataRetentionDays: parseInt(e.target.value) || 30, + }, + }) + } + className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-bg focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+
+ + +
+
+
+
+ +
+
+ )} + + {/* Task 25: Optimization Settings */} + {!dbSettingsLoading && dbSettings && ( +
+

+ + Optimization Settings +

+
+
+
+ + +
+
+ + +
+
+ + + setDbSettings({ + ...dbSettings, + optimization: { + ...dbSettings.optimization, + vacuumHour: parseInt(e.target.value) || 2, + }, + }) + } + className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-bg focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+
+ + + setDbSettings({ + ...dbSettings, + optimization: { + ...dbSettings.optimization, + pageSize: parseInt(e.target.value) || 4096, + }, + }) + } + className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-bg focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+
+ + + setDbSettings({ + ...dbSettings, + optimization: { + ...dbSettings.optimization, + cacheSize: parseInt(e.target.value) || -2000, + }, + }) + } + className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-bg focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+
+
+ + setDbSettings({ + ...dbSettings, + optimization: { + ...dbSettings.optimization, + optimizeOnStartup: e.target.checked, + }, + }) + } + className="w-4 h-4 rounded border-border text-primary focus:ring-2 focus:ring-primary" + /> + +
+
+
+ +
+
+ )} + + {/* Task 26: Database Stats Display */} + {!dbSettingsLoading && dbSettings && dbSettings.stats && ( +
+
+

+ + Database Statistics +

+ +
+
+
+

Database Size

+

+ {formatBytes(dbSettings.stats.databaseSizeBytes)} +

+
+
+

Page Count

+

{dbSettings.stats.pageCount.toLocaleString()}

+
+
+

Freelist Count

+

+ {dbSettings.stats.freelistCount.toLocaleString()} +

+
+
+

Last Vacuum

+

+ {dbSettings.stats.lastVacuumAt + ? new Date(dbSettings.stats.lastVacuumAt).toLocaleString(locale) + : "Never"} +

+
+
+

Last Optimization

+

+ {dbSettings.stats.lastOptimizationAt + ? new Date(dbSettings.stats.lastOptimizationAt).toLocaleString(locale) + : "Never"} +

+
+
+

Integrity Check

+

+ {dbSettings.stats.integrityCheck === "ok" ? ( + ✓ OK + ) : dbSettings.stats.integrityCheck === "error" ? ( + ✗ Error + ) : ( + "Not checked" + )} +

+
+
+
+ )} ); } diff --git a/src/app/(dashboard)/dashboard/settings/page.tsx b/src/app/(dashboard)/dashboard/settings/page.tsx index a2c6af481c..639afd6cc5 100644 --- a/src/app/(dashboard)/dashboard/settings/page.tsx +++ b/src/app/(dashboard)/dashboard/settings/page.tsx @@ -15,7 +15,6 @@ import ThinkingBudgetTab from "./components/ThinkingBudgetTab"; import SystemPromptTab from "./components/SystemPromptTab"; import ModelAliasesUnified from "./components/ModelAliasesUnified"; import BackgroundDegradationTab from "./components/BackgroundDegradationTab"; -import CacheSettingsTab from "./components/CacheSettingsTab"; import MemorySkillsTab from "./components/MemorySkillsTab"; import ModelsDevSyncTab from "./components/ModelsDevSyncTab"; import ResilienceTab from "./components/ResilienceTab"; @@ -115,7 +114,6 @@ export default function SettingsPage() { -
diff --git a/src/app/api/assess/route.ts b/src/app/api/assess/route.ts new file mode 100644 index 0000000000..dba89d00a8 --- /dev/null +++ b/src/app/api/assess/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Assessor } from "@/domain/assessment/assessor"; +import { Categorizer } from "@/domain/assessment/categorizer"; +import { SelfHealer } from "@/domain/assessment/selfHealer"; +import { type AssessmentScope, type AssessmentTrigger } from "@/domain/assessment/types"; + +const assessor = new Assessor( + process.env.OMNIROUTe_API_KEY ?? process.env.API_KEY ?? "", + process.env.OMNIROUTe_BASE_URL ?? "http://localhost:20128/v1" +); + +const categorizer = new Categorizer(); +const healer = new SelfHealer(); + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const scope: AssessmentScope = body.scope ?? { type: "all" }; + const trigger: AssessmentTrigger = body.trigger ?? "on_demand"; + + let models: Array<{ providerId: string; modelId: string }>; + + if (scope.type === "provider") { + models = await getModelsForProvider(scope.providerId); + } else if (scope.type === "model") { + models = [{ providerId: scope.modelId.split("/")[0], modelId: scope.modelId.split("/")[1] }]; + } else { + models = await getAllModels(); + } + + const run = await assessor.runAssessment(models, trigger); + + for (const assessment of assessor.getAllAssessments()) { + categorizer.assignCategoriesAndFitness(assessment); + } + + return NextResponse.json({ + run_id: run.id, + status: "completed", + models_tested: run.modelsTested, + models_passed: run.modelsPassed, + models_failed: run.modelsFailed, + models_rate_limited: run.modelsRateLimited, + duration_ms: run.durationMs, + }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} + +export async function GET(request: NextRequest) { + const url = new URL(request.url); + const action = url.searchParams.get("action"); + + if (action === "results") { + const status = url.searchParams.get("status"); + const provider = url.searchParams.get("provider"); + const category = url.searchParams.get("category"); + + let results = assessor.getAllAssessments(); + if (status) results = results.filter((a) => a.status === status); + if (provider) results = results.filter((a) => a.providerId === provider); + if (category) results = results.filter((a) => a.categories.includes(category as any)); + + return NextResponse.json({ models: results }); + } + + if (action === "combo-health") { + return NextResponse.json({ combos: [], message: "Combo health requires DB access" }); + } + + if (action === "working") { + return NextResponse.json({ models: assessor.getWorkingModels() }); + } + + return NextResponse.json({ + endpoints: { + "GET ?action=results": "Get assessment results (filter: status, provider, category)", + "GET ?action=combo-health": "Get combo health status", + "GET ?action=working": "Get working models only", + "POST { scope, trigger }": "Run assessment", + }, + }); +} + +async function getAllModels(): Promise> { + try { + const resp = await fetch("http://localhost:20128/v1/models", { + headers: { + Authorization: `Bearer ${process.env.OMNIROUTe_API_KEY ?? process.env.API_KEY ?? ""}`, + }, + }); + const data = await resp.json(); + return (data.data ?? []) + .filter((m: any) => m.id?.startsWith("auto/")) + .map((m: any) => ({ providerId: "auto", modelId: m.id.replace("auto/", "") })); + } catch { + return []; + } +} + +async function getModelsForProvider( + providerId: string +): Promise> { + void providerId; + return getAllModels(); +} diff --git a/src/app/api/cli-tools/antigravity-mitm/route.ts b/src/app/api/cli-tools/antigravity-mitm/route.ts index 6d4e0b442f..283bc65de4 100644 --- a/src/app/api/cli-tools/antigravity-mitm/route.ts +++ b/src/app/api/cli-tools/antigravity-mitm/route.ts @@ -7,6 +7,7 @@ import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; import { cliMitmStartSchema, cliMitmStopSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { resolveApiKey } from "@/shared/services/apiKeyResolver"; +import { isRoot } from "@/mitm/systemCommands"; // GET - Check MITM status export async function GET(request) { @@ -60,9 +61,10 @@ export async function POST(request) { const apiKey = await resolveApiKey(apiKeyId, rawApiKey); const { startMitm, getCachedPassword, setCachedPassword } = await import("@/mitm/manager"); const isWin = process.platform === "win32"; + const isRootUser = !isWin && isRoot(); const pwd = sudoPassword || getCachedPassword() || ""; - if (!apiKey || (!isWin && !pwd)) { + if (!apiKey || (!isWin && !pwd && !isRootUser)) { return NextResponse.json( { error: isWin ? "Missing apiKey" : "Missing apiKey or sudoPassword" }, { status: 400 } @@ -114,9 +116,10 @@ export async function DELETE(request) { const { sudoPassword } = validation.data; const { stopMitm, getCachedPassword, setCachedPassword } = await import("@/mitm/manager"); const isWin = process.platform === "win32"; + const isRootUser = !isWin && isRoot(); const pwd = sudoPassword || getCachedPassword() || ""; - if (!isWin && !pwd) { + if (!isWin && !pwd && !isRootUser) { return NextResponse.json({ error: "Missing sudoPassword" }, { status: 400 }); } diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 1e6e7e5962..7b004dc278 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -949,6 +949,11 @@ export async function GET( }); }; + if (provider === "reka") { + const localCatalog = buildLocalCatalogResponse(); + if (localCatalog) return localCatalog; + } + if ( isOpenAICompatibleProvider(provider) || isLocalOpenAIStyleProvider(provider) || diff --git a/src/app/api/settings/database/refresh-stats/route.ts b/src/app/api/settings/database/refresh-stats/route.ts new file mode 100644 index 0000000000..76f1dadb06 --- /dev/null +++ b/src/app/api/settings/database/refresh-stats/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { getDatabaseStats } from "@/lib/db/stats"; + +export async function POST(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const stats = await getDatabaseStats(); + return NextResponse.json({ success: true, stats }); + } catch (error) { + console.error("Failed to refresh database stats:", error); + return NextResponse.json({ error: "Failed to refresh database stats" }, { status: 500 }); + } +} diff --git a/src/app/api/settings/database/route.ts b/src/app/api/settings/database/route.ts new file mode 100644 index 0000000000..9c40b3c105 --- /dev/null +++ b/src/app/api/settings/database/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { databaseSettingsSchema } from "@/shared/validation/settingsSchemas"; +import { getDatabaseSettings, updateDatabaseSettings } from "@/lib/db/databaseSettings"; + +const databaseSettingsPatchSchema = databaseSettingsSchema.partial().strict(); + +export async function GET(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + return NextResponse.json(getDatabaseSettings()); + } catch (error) { + console.error("Error getting database settings:", error); + return NextResponse.json({ error: "Failed to load database settings" }, { status: 500 }); + } +} + +export async function PATCH(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const rawBody = await request.json(); + const validation = validateBody(databaseSettingsPatchSchema, rawBody); + + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + updateDatabaseSettings(validation.data); + + // Return merged settings (GET response pattern) + return NextResponse.json(getDatabaseSettings()); + } catch (error) { + console.error("Error updating database settings:", error); + return NextResponse.json({ error: "Failed to update database settings" }, { status: 500 }); + } +} + +export async function PUT(request: NextRequest) { + return PATCH(request); +} diff --git a/src/app/api/settings/database/vacuum/route.ts b/src/app/api/settings/database/vacuum/route.ts new file mode 100644 index 0000000000..f614637ccb --- /dev/null +++ b/src/app/api/settings/database/vacuum/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { runManualVacuum } from "@/lib/db/core"; + +export async function POST(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const result = runManualVacuum(); + + if (result.success) { + return NextResponse.json({ + success: true, + message: `VACUUM completed in ${result.duration}ms`, + duration: result.duration, + }); + } else { + return NextResponse.json( + { + success: false, + error: result.error || "VACUUM failed", + duration: result.duration, + }, + { status: 500 } + ); + } + } catch (error: any) { + console.error("[API] VACUUM endpoint error:", error); + return NextResponse.json( + { error: "Failed to run VACUUM", details: error.message }, + { status: 500 } + ); + } +} diff --git a/src/app/api/settings/export-json/route.ts b/src/app/api/settings/export-json/route.ts index 482a98b9a9..6eaa783728 100644 --- a/src/app/api/settings/export-json/route.ts +++ b/src/app/api/settings/export-json/route.ts @@ -6,6 +6,7 @@ import { getCombos, getApiKeys, } from "@/lib/localDb"; +import { getDbInstance } from "@/lib/db/core"; import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; /** @@ -32,12 +33,20 @@ export async function GET(request: Request) { const combos = await getCombos(); const apiKeys = await getApiKeys(); + const db = getDbInstance(); + const usageHistory = db.prepare("SELECT * FROM usage_history").all(); + const domainCostHistory = db.prepare("SELECT * FROM domain_cost_history").all(); + const domainBudgets = db.prepare("SELECT * FROM domain_budgets").all(); + const exportData = { settings: safeSettings, providerConnections, providerNodes, combos, apiKeys, + usageHistory, + domainCostHistory, + domainBudgets, // Metadata to identify export version _meta: { exportedAt: new Date().toISOString(), diff --git a/src/app/api/settings/import-json/route.ts b/src/app/api/settings/import-json/route.ts index 650336a313..0d7b4ebf67 100644 --- a/src/app/api/settings/import-json/route.ts +++ b/src/app/api/settings/import-json/route.ts @@ -67,7 +67,9 @@ export async function POST(request: Request) { console.log( `[JSON Import] Imported ${counts.connections} connections, ${counts.nodes} nodes, ` + - `${counts.combos} combos, ${counts.apiKeys} API keys` + `${counts.combos} combos, ${counts.apiKeys} API keys, ` + + `${counts.usageHistory} usage rows, ${counts.domainCostHistory} cost rows, ` + + `${counts.domainBudgets} budgets` ); return NextResponse.json({ diff --git a/src/app/api/settings/purge-call-logs/route.ts b/src/app/api/settings/purge-call-logs/route.ts new file mode 100644 index 0000000000..2e5c21d6db --- /dev/null +++ b/src/app/api/settings/purge-call-logs/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { purgeCallLogs } from "@/lib/db/cleanup"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; + +export async function POST(request: Request) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { + const result = await purgeCallLogs(); + return NextResponse.json({ + deleted: result.deleted, + errors: result.errors, + }); + } catch (err: unknown) { + const error = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error }, { status: 500 }); + } +} diff --git a/src/app/api/settings/purge-detailed-logs/route.ts b/src/app/api/settings/purge-detailed-logs/route.ts new file mode 100644 index 0000000000..12709c62c4 --- /dev/null +++ b/src/app/api/settings/purge-detailed-logs/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { purgeDetailedLogs } from "@/lib/db/cleanup"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; + +export async function POST(request: Request) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { + const result = await purgeDetailedLogs(); + return NextResponse.json({ + deleted: result.deleted, + errors: result.errors, + }); + } catch (err: unknown) { + const error = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error }, { status: 500 }); + } +} diff --git a/src/app/api/settings/purge-quota-snapshots/route.ts b/src/app/api/settings/purge-quota-snapshots/route.ts new file mode 100644 index 0000000000..2e3bdc58b1 --- /dev/null +++ b/src/app/api/settings/purge-quota-snapshots/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { purgeQuotaSnapshots } from "@/lib/db/cleanup"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; + +export async function POST(request: Request) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { + const result = await purgeQuotaSnapshots(); + return NextResponse.json({ + deleted: result.deleted, + errors: result.errors, + }); + } catch (err: unknown) { + const error = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error }, { status: 500 }); + } +} diff --git a/src/app/api/usage/analytics/route.ts b/src/app/api/usage/analytics/route.ts index e89d9351e2..cf6ddadcec 100644 --- a/src/app/api/usage/analytics/route.ts +++ b/src/app/api/usage/analytics/route.ts @@ -74,18 +74,32 @@ function resolveModelPricing( const pLower = (providerRaw || "").toLowerCase(); let providerPricing = findKeyInsensitive(pricingByProvider, pLower); + if (!providerPricing) { + // providerAliasMap maps ID -> ALIAS. So if pLower is "codex", alias is "cx". const alias = providerAliasMap[pLower]; if (alias) { providerPricing = findKeyInsensitive(pricingByProvider, alias); - } else { - const np = pLower.replace(/-cn$/, ""); - if (np && np !== pLower) { - providerPricing = findKeyInsensitive(pricingByProvider, np); + } + } + + if (!providerPricing) { + // In case pLower was ALIAS and we want to try the ID (reverse search values) + for (const [id, alias] of Object.entries(providerAliasMap)) { + if (alias.toLowerCase() === pLower) { + providerPricing = findKeyInsensitive(pricingByProvider, id); + if (providerPricing) break; } } } + if (!providerPricing) { + const np = pLower.replace(/-cn$/, ""); + if (np && np !== pLower) { + providerPricing = findKeyInsensitive(pricingByProvider, np); + } + } + // Hardcoded known fallbacks if (!providerPricing) { if (pLower === "antigravity") providerPricing = findKeyInsensitive(pricingByProvider, "ag"); @@ -122,6 +136,20 @@ function resolveModelPricing( } } + // Last resort fallback for historical usage (e.g. "gpt-4" missing, matches "gpt-4.1" or first available) + if (!pricing && providerPricing && typeof providerPricing === "object") { + for (const [key, val] of Object.entries(providerPricing as Record)) { + if (key.includes(lowerModel) || lowerModel.includes(key)) { + pricing = val; + break; + } + } + if (!pricing) { + const keys = Object.keys(providerPricing as Record); + if (keys.length > 0) pricing = (providerPricing as Record)[keys[0]]; + } + } + return pricing as Record | null; } @@ -475,13 +503,14 @@ export async function GET(request: Request) { .prepare( ` SELECT - COUNT(*) as total, - SUM(CASE WHEN requested_model IS NOT NULL AND requested_model != '' THEN 1 ELSE 0 END) as with_requested, + SUM(CASE WHEN (combo_name IS NULL OR combo_name = '') THEN 1 ELSE 0 END) as total, + SUM(CASE WHEN requested_model IS NOT NULL AND requested_model != '' AND (combo_name IS NULL OR combo_name = '') THEN 1 ELSE 0 END) as with_requested, SUM(CASE WHEN requested_model IS NOT NULL AND requested_model != '' AND model IS NOT NULL - AND requested_model != model + AND LOWER(CASE WHEN instr(requested_model, '/') > 0 THEN substr(requested_model, instr(requested_model, '/') + 1) ELSE requested_model END) != LOWER(model) + AND (combo_name IS NULL OR combo_name = '') THEN 1 ELSE 0 END ) as fallbacks FROM call_logs diff --git a/src/app/api/v1/images/generations/route.ts b/src/app/api/v1/images/generations/route.ts index ceb99736fc..080248e8f6 100644 --- a/src/app/api/v1/images/generations/route.ts +++ b/src/app/api/v1/images/generations/route.ts @@ -229,9 +229,9 @@ export async function POST(request) { // Resolve proxy for the connection if credentials exist (#1904) let proxyInfo = null; - if (credentials?.id) { + if (credentials?.connectionId) { try { - proxyInfo = await resolveProxyForConnection(credentials.id); + proxyInfo = await resolveProxyForConnection(credentials.connectionId); } catch { log.debug("PROXY", `Failed to resolve proxy for image provider: ${provider}`); } @@ -248,8 +248,12 @@ export async function POST(request) { }); // Execute with proxy context when available, direct otherwise (#1904) - const result = await (credentials?.id - ? runWithProxyContext(proxyInfo?.proxy || null, generateImage) + const result = await (credentials?.connectionId + ? runWithProxyContext(proxyInfo?.proxy || null, generateImage).catch((err: any) => ({ + success: false, + status: err.statusCode || 500, + error: err.message, + })) : generateImage()); if (result.success) { diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 4445b0d72c..12fe5b302c 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -338,10 +338,6 @@ export async function getUnifiedModelsResponse( continue; } - // Get default context length from registry (provider-level default) - const registryEntry = REGISTRY[alias] || REGISTRY[canonicalProviderId]; - const defaultContextLength = registryEntry?.defaultContextLength; - for (const model of providerModels) { if (!providerSupportsModel(canonicalProviderId, model.id)) continue; const aliasId = `${alias}/${model.id}`; @@ -349,8 +345,6 @@ export async function getUnifiedModelsResponse( const visionFields = getVisionCapabilityFields(aliasId) || getVisionCapabilityFields(model.id); - // Model-level context length overrides provider default - const contextLength = model.contextLength || defaultContextLength; models.push({ id: aliasId, @@ -360,7 +354,6 @@ export async function getUnifiedModelsResponse( permission: [], root: model.id, parent: null, - ...(contextLength ? { context_length: contextLength } : {}), ...(visionFields || {}), }); @@ -378,7 +371,6 @@ export async function getUnifiedModelsResponse( permission: [], root: model.id, parent: aliasId, - ...(contextLength ? { context_length: contextLength } : {}), ...(providerVisionFields || {}), }); } @@ -390,6 +382,7 @@ export async function getUnifiedModelsResponse( for (const [providerId, syncedModels] of Object.entries(syncedModelsByProvider)) { if (!Array.isArray(syncedModels) || syncedModels.length === 0) continue; if (blockedProviders.has(providerId)) continue; + if (providerId === "reka") continue; const prefix = providerIdToPrefix[providerId]; const alias = prefix || providerIdToAlias[providerId] || providerId; @@ -637,6 +630,7 @@ export async function getUnifiedModelsResponse( for (const [providerId, rawProviderCustomModels] of Object.entries(customModelsMap)) { // Skip Gemini — handled by syncedAvailableModels above if (providerId === "gemini") continue; + if (providerId === "reka") continue; const providerCustomModels = Array.isArray(rawProviderCustomModels) ? rawProviderCustomModels.filter( (model): model is Record => @@ -811,7 +805,24 @@ export async function getUnifiedModelsResponse( finalModels = filtered; } - const enrichedModels = finalModels.map((model) => enrichCatalogModelEntry(model)); + const getDefaultContextFallback = (model: any): number | undefined => { + if (typeof model.context_length === "number") return undefined; + if (model.owned_by === "combo") return undefined; + if (model.type && model.type !== "chat") return undefined; + + const provider = typeof model.owned_by === "string" ? model.owned_by : null; + if (!provider) return undefined; + const canonicalId = aliasToProviderId[provider] || provider; + return REGISTRY[canonicalId]?.defaultContextLength; + }; + + const enrichedModels = finalModels.map((model) => { + const enriched = enrichCatalogModelEntry(model); + const fallbackContextLength = getDefaultContextFallback(enriched); + return fallbackContextLength + ? { ...enriched, context_length: fallbackContextLength } + : enriched; + }); return Response.json( { diff --git a/src/app/docs/[slug]/page.tsx b/src/app/docs/[slug]/page.tsx new file mode 100644 index 0000000000..27d8147dd9 --- /dev/null +++ b/src/app/docs/[slug]/page.tsx @@ -0,0 +1,322 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { docsNavigation } from "../lib/docsNavigation"; +import { autoAllSlugs, autoNavSections } from "../lib/docs-auto-generated"; +import fs from "fs"; +import path from "path"; +import matter from "gray-matter"; +import { marked } from "marked"; +import DOMPurify from "isomorphic-dompurify"; +import { Metadata } from "next"; +import { DocCodeBlocks } from "../components/DocCodeBlocks"; +import { FeedbackWidget } from "../components/FeedbackWidget"; +import { DocsPageAnalytics } from "../components/DocsPageAnalytics"; +import { DocsLazyWrapper } from "../components/DocsLazyWrapper"; +import { MermaidChartsClient } from "../components/MermaidChartsClient"; + +export function generateStaticParams() { + return autoAllSlugs.map((slug) => ({ slug })); +} + +export function getDocItemBySlug(slug: string) { + for (const section of docsNavigation) { + const item = section.items.find((item) => item.slug === slug); + if (item) { + return { sectionTitle: section.title, item }; + } + } + for (const section of autoNavSections) { + const item = section.items.find((i) => i.slug === slug); + if (item) { + return { + sectionTitle: section.title, + item: { slug: item.slug, title: item.title, fileName: item.fileName }, + }; + } + } + return null; +} + +export function getAllDocSlugsFlat(): string[] { + return autoAllSlugs; +} + +export function getPrevNextSlugs(currentSlug: string) { + const allSlugs = getAllDocSlugsFlat(); + const idx = allSlugs.indexOf(currentSlug); + return { + prev: idx > 0 ? allSlugs[idx - 1] : null, + next: idx < allSlugs.length - 1 ? allSlugs[idx + 1] : null, + }; +} + +export function extractHeadings(content: string): { id: string; text: string; level: number }[] { + const headings: { id: string; text: string; level: number }[] = []; + const regex = /^(#{2,4})\s+(.+)$/gm; + let match; + while ((match = regex.exec(content)) !== null) { + const level = match[1].length; + const text = match[2].replace(/\*\*/g, "").replace(/\*/g, "").replace(/`/g, ""); + const id = text + .toLowerCase() + .replace(/[^\w\s-]/g, "") + .replace(/\s+/g, "-"); + headings.push({ id, text, level }); + } + return headings; +} + +export function extractMermaidCharts(content: string): string[] { + const charts: string[] = []; + const regex = /```mermaid\n([\s\S]*?)```/g; + let match; + while ((match = regex.exec(content)) !== null) { + charts.push(match[1].trim()); + } + return charts; +} + +const PROSE_CLASSES: Record = { + h1: "text-3xl font-bold mb-4", + h2: "text-2xl font-bold mb-4 mt-10", + h3: "text-xl font-bold mb-3 mt-8", + h4: "text-lg font-bold mb-2 mt-6", + p: "mb-4 leading-relaxed", + ul: "list-disc ml-6 mb-4", + ol: "list-decimal ml-6 mb-4", + li: "mb-1", + a: "text-primary hover:underline", + blockquote: "border-l-4 border-primary/30 pl-4 italic text-text-muted mb-4", + code: "bg-bg-subtle px-2 py-1 rounded text-sm", + pre: "bg-bg-subtle p-4 rounded-lg overflow-x-auto mb-4", + hr: "border-border my-8", + table: "w-full border-collapse mb-4 text-sm", + th: "border border-border p-2 text-left font-semibold bg-bg-subtle", + td: "border border-border p-2 text-sm", + img: "max-w-full rounded-lg my-4", +}; + +marked.use({ + gfm: true, + breaks: false, +}); + +export function renderMarkdown(content: string): string { + const mermaidReplaced = content.replace( + /```mermaid\n([\s\S]*?)```/g, + (_match, code: string) => + `
${code.trim()}
` + ); + + const rawHtml = marked.parse(mermaidReplaced) as string; + + const sanitized = DOMPurify.sanitize(rawHtml, { + ADD_TAGS: ["mermaid-diagram"], + ADD_ATTR: ["data-mermaid"], + }); + + return addProseClasses(sanitized); +} + +function addProseClasses(html: string): string { + let result = html; + for (const [tag, classes] of Object.entries(PROSE_CLASSES)) { + const regex = new RegExp(`<${tag}(\\s|>)`, "g"); + result = result.replace(regex, `<${tag} class="${classes}"`); + } + return result; +} + + + +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }>; +}): Promise { + const { slug } = await params; + const docItem = getDocItemBySlug(slug); + + if (!docItem) { + return { + title: "Document Not Found", + }; + } + + return { + title: `${docItem.item.title} — OmniRoute Docs`, + description: `OmniRoute documentation: ${docItem.item.title}`, + }; +} + +export default async function DocPage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const docItem = getDocItemBySlug(slug); + + if (!docItem) { + notFound(); + } + + const { sectionTitle, item } = docItem; + + let pageTitle = item.title; + let htmlContent = ""; + let headings: { id: string; text: string; level: number }[] = []; + let loadError: string | null = null; + let version: string | null = null; + let lastUpdated: string | null = null; + let mermaidCharts: string[] = []; + + try { + const docsRoot = path.join(process.cwd(), "docs"); + const fileContent = fs.readFileSync(path.join(docsRoot, item.fileName), "utf8"); + const { content, data: frontmatter } = matter(fileContent); + pageTitle = (frontmatter.title as string) || item.title; + version = (frontmatter.version as string) || null; + lastUpdated = (frontmatter.lastUpdated as string) || null; + mermaidCharts = extractMermaidCharts(content); + headings = extractHeadings(content); + htmlContent = renderMarkdown(content); + } catch (error) { + console.error(`Failed to read doc file for slug: ${slug}`, error); + loadError = error instanceof Error ? error.message : "Unknown error"; + } + + if (loadError) { + return ( +
+

Error Loading Documentation

+

Failed to load {item.fileName}. Please try again later.

+

Error: {loadError}

+
+ ); + } + + const { prev, next } = getPrevNextSlugs(slug); + const prevItem = prev ? getDocItemBySlug(prev) : null; + const nextItem = next ? getDocItemBySlug(next) : null; + + const breadcrumbJsonLd = { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: [ + { + "@type": "ListItem", + position: 1, + name: "Docs", + item: `https://omniroute.online/docs`, + }, + { + "@type": "ListItem", + position: 2, + name: sectionTitle, + item: `https://omniroute.online/docs/${slug}`, + }, + { + "@type": "ListItem", + position: 3, + name: pageTitle, + }, + ], + }; + + return ( + <> + + '); + assert.ok(!html.includes(" { + const html = renderMarkdown("```js\nconst x = 1;\n```"); + assert.ok(html.includes(" { + const html = renderMarkdown("Use `npm install` to install"); + assert.ok(html.includes(" { + const html = renderMarkdown("This is **bold** text"); + assert.ok(html.includes("bold")); +}); + +test("renderMarkdown converts italic text", () => { + const html = renderMarkdown("This is *italic* text"); + assert.ok(html.includes("italic")); +}); + +test("renderMarkdown converts links", () => { + const html = renderMarkdown("[OmniRoute](https://omniroute.online)"); + assert.ok(html.includes('href="https://omniroute.online"')); + assert.ok(html.includes("OmniRoute
")); + assert.ok(html.includes(' { + const html = renderMarkdown("- Item 1\n- Item 2"); + assert.ok(html.includes(" { + const html = renderMarkdown("1. First\n2. Second"); + assert.ok(html.includes(" { + const html = renderMarkdown("> This is a quote"); + assert.ok(html.includes(" { + const html = renderMarkdown("---"); + assert.ok(html.includes(" { + const navSlugs = getAllDocSlugsFlat(); + // searchIndex and nav slugs should have significant overlap + const indexSlugs = SEARCH_INDEX.map((item) => item.slug); + for (const slug of navSlugs) { + assert.ok(indexSlugs.includes(slug), `SEARCH_INDEX missing slug: ${slug}`); + } +}); + +test("SEARCH_INDEX entries have required fields", () => { + for (const item of SEARCH_INDEX) { + assert.ok(item.slug, "item must have slug"); + assert.ok(item.title, "item must have title"); + assert.ok(item.fileName, "item must have fileName"); + assert.ok(item.section, "item must have section"); + assert.ok(typeof item.content === "string", "item must have content string"); + assert.ok(Array.isArray(item.headings), "item must have headings array"); + } +}); + +test("SEARCH_INDEX entries have non-empty content", () => { + for (const item of SEARCH_INDEX) { + assert.ok(item.content.length > 0, `${item.slug} should have content`); + } +}); + +// ────────────────────────────────────────────── +// Mermaid extraction +// ────────────────────────────────────────────── + +test("extractMermaidCharts extracts mermaid blocks from content", async () => { + const { extractMermaidCharts } = await import("../../src/app/docs/[slug]/page"); + const content = + "## Diagram\n\n```mermaid\ngraph TD\n A-->B\n```\n\nSome text\n\n```mermaid\nsequenceDiagram\n Alice->>Bob: Hi\n```"; + const charts = extractMermaidCharts(content); + assert.equal(charts.length, 2); + assert.ok(charts[0].includes("graph TD")); + assert.ok(charts[1].includes("Alice->>Bob")); +}); + +test("extractMermaidCharts returns empty array when no mermaid blocks", async () => { + const { extractMermaidCharts } = await import("../../src/app/docs/[slug]/page"); + const content = "## Heading\n\nSome text with ```js\ncode\n```"; + const charts = extractMermaidCharts(content); + assert.equal(charts.length, 0); +}); + +// ────────────────────────────────────────────── +// Mermaid rendering in markdown +// ────────────────────────────────────────────── + +test("renderMarkdown converts mermaid code blocks to fallback divs", () => { + const markdown = "```mermaid\ngraph TD\n A-->B\n```"; + const html = renderMarkdown(markdown); + assert.ok( + html.includes("mermaid-diagram-fallback"), + "Should contain mermaid-diagram-fallback class" + ); + assert.ok(html.includes("data-mermaid"), "Should contain data-mermaid attribute"); +}); + +// ────────────────────────────────────────────── +// Analytics component +// ────────────────────────────────────────────── + +test("DocsPageAnalytics is importable", async () => { + const mod = await import("../../src/app/docs/components/DocsPageAnalytics"); + assert.ok(mod.DocsPageAnalytics, "DocsPageAnalytics should be exported"); + assert.ok(typeof mod.getPopularPages === "function", "getPopularPages should be exported"); +}); + +// ────────────────────────────────────────────── +// What's New and Migration Guide +// ────────────────────────────────────────────── + +test("WhatsNewSection is importable", async () => { + const mod = await import("../../src/app/docs/components/WhatsNewSection"); + assert.ok(mod.WhatsNewSection, "WhatsNewSection should be exported"); + assert.ok(mod.MigrationGuideBanner, "MigrationGuideBanner should be exported"); +}); + +// ────────────────────────────────────────────── +// i18n locale system +// ────────────────────────────────────────────── + +test("DocsI18n is importable", async () => { + const mod = await import("../../src/app/docs/components/DocsI18n"); + assert.ok(mod.useLocalizedSectionTitle, "useLocalizedSectionTitle should be exported"); + assert.ok(mod.getAvailableLocales, "getAvailableLocales should be exported"); + assert.ok(mod.LOCALE_NAMES, "LOCALE_NAMES should be exported"); + assert.equal(mod.LOCALE_NAMES.en, "English"); + assert.equal(mod.LOCALE_NAMES["zh-CN"], "简体中文"); +}); + +test("getAvailableLocales returns 10 locales", async () => { + const { getAvailableLocales } = await import("../../src/app/docs/components/DocsI18n"); + const locales = getAvailableLocales(); + assert.equal(locales.length, 10); + assert.ok(locales.includes("en")); + assert.ok(locales.includes("pt-BR")); + assert.ok(locales.includes("zh-CN")); +}); diff --git a/tests/unit/encryption.spec.ts b/tests/unit/encryption.spec.ts new file mode 100644 index 0000000000..c9b5658105 --- /dev/null +++ b/tests/unit/encryption.spec.ts @@ -0,0 +1,357 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createCipheriv, randomBytes, scryptSync, createHash } from "crypto"; + +// Test helper to manually create legacy-encrypted values +function createLegacyEncrypted(plaintext: string, secret: string): string { + const ALGORITHM = "aes-256-gcm"; + const IV_LENGTH = 16; + const KEY_LENGTH = 32; + const PREFIX = "enc:v1:"; + + // OLD dynamic salt derivation (the bug) + const dynamicSalt = createHash("sha256").update(secret).digest().slice(0, 16); + const legacyKey = scryptSync(secret, dynamicSalt, KEY_LENGTH); + + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, legacyKey, iv); + + let encrypted = cipher.update(plaintext, "utf8", "hex"); + encrypted += cipher.final("hex"); + const authTag = cipher.getAuthTag().toString("hex"); + + return `${PREFIX}${iv.toString("hex")}:${encrypted}:${authTag}`; +} + +describe("encryption module", () => { + beforeEach(() => { + // Clear all env vars and reset modules before each test + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + describe("encrypt/decrypt roundtrip with static key (PRIMARY path)", () => { + it("should encrypt and decrypt a value successfully", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt, decrypt } = await import("@/lib/db/encryption"); + + const plaintext = "my-secret-api-key"; + const encrypted = encrypt(plaintext); + + expect(encrypted).toBeDefined(); + expect(encrypted).toMatch(/^enc:v1:/); + expect(encrypted).not.toBe(plaintext); + + const decrypted = decrypt(encrypted!); + expect(decrypted).toBe(plaintext); + }); + + it("should handle multiple encrypt/decrypt cycles", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt, decrypt } = await import("@/lib/db/encryption"); + + const values = ["token1", "token2", "token3"]; + const encrypted = values.map((v) => encrypt(v)); + const decrypted = encrypted.map((e) => decrypt(e!)); + + expect(decrypted).toEqual(values); + }); + }); + + describe("passthrough mode: no STORAGE_ENCRYPTION_KEY set → plaintext stored", () => { + it("should return plaintext when encryption key is not set", async () => { + // No STORAGE_ENCRYPTION_KEY set + vi.resetModules(); + + const { encrypt, decrypt, isEncryptionEnabled } = await import("@/lib/db/encryption"); + + expect(isEncryptionEnabled()).toBe(false); + + const plaintext = "my-api-key"; + const encrypted = encrypt(plaintext); + + expect(encrypted).toBe(plaintext); + + const decrypted = decrypt(plaintext); + expect(decrypted).toBe(plaintext); + }); + + it("should handle null and undefined in passthrough mode", async () => { + vi.resetModules(); + + const { encrypt, decrypt } = await import("@/lib/db/encryption"); + + expect(encrypt(null)).toBeNull(); + expect(encrypt(undefined)).toBeUndefined(); + expect(decrypt(null)).toBeNull(); + expect(decrypt(undefined)).toBeUndefined(); + }); + }); + + describe("encryptConnectionFields / decryptConnectionFields helpers", () => { + it("should encrypt all credential fields in a connection object", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encryptConnectionFields } = await import("@/lib/db/encryption"); + + const conn = { + id: "conn-123", + apiKey: "plain-api-key", + accessToken: "plain-access-token", + refreshToken: "plain-refresh-token", + idToken: "plain-id-token", + }; + + const encrypted = encryptConnectionFields(conn); + + expect(encrypted.id).toBe("conn-123"); + expect(encrypted.apiKey).toMatch(/^enc:v1:/); + expect(encrypted.accessToken).toMatch(/^enc:v1:/); + expect(encrypted.refreshToken).toMatch(/^enc:v1:/); + expect(encrypted.idToken).toMatch(/^enc:v1:/); + }); + + it("should decrypt all credential fields in a connection object", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encryptConnectionFields, decryptConnectionFields } = + await import("@/lib/db/encryption"); + + const conn = { + id: "conn-123", + apiKey: "plain-api-key", + accessToken: "plain-access-token", + refreshToken: "plain-refresh-token", + idToken: "plain-id-token", + }; + + const encrypted = encryptConnectionFields({ ...conn }); + const decrypted = decryptConnectionFields(encrypted); + + expect(decrypted.id).toBe("conn-123"); + expect(decrypted.apiKey).toBe("plain-api-key"); + expect(decrypted.accessToken).toBe("plain-access-token"); + expect(decrypted.refreshToken).toBe("plain-refresh-token"); + expect(decrypted.idToken).toBe("plain-id-token"); + }); + + it("should handle null and undefined connection objects", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encryptConnectionFields, decryptConnectionFields } = + await import("@/lib/db/encryption"); + + expect(encryptConnectionFields(null)).toBeNull(); + expect(encryptConnectionFields(undefined)).toBeUndefined(); + expect(decryptConnectionFields(null)).toBeNull(); + expect(decryptConnectionFields(undefined)).toBeUndefined(); + }); + + it("should handle connection objects with missing fields", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encryptConnectionFields, decryptConnectionFields } = + await import("@/lib/db/encryption"); + + const conn: import("@/lib/db/encryption").ConnectionFields = { + id: "conn-123", + apiKey: "plain-api-key", + // accessToken, refreshToken, idToken missing + }; + + const encrypted = encryptConnectionFields({ ...conn }); + expect(encrypted.apiKey).toMatch(/^enc:v1:/); + expect(encrypted.accessToken).toBeUndefined(); + + const decrypted = decryptConnectionFields(encrypted); + expect(decrypted.apiKey).toBe("plain-api-key"); + expect(decrypted.accessToken).toBeUndefined(); + }); + }); + + describe("edge cases: null/undefined inputs, already-encrypted, malformed ciphertext", () => { + it("should handle null and undefined inputs", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt, decrypt } = await import("@/lib/db/encryption"); + + expect(encrypt(null)).toBeNull(); + expect(encrypt(undefined)).toBeUndefined(); + expect(decrypt(null)).toBeNull(); + expect(decrypt(undefined)).toBeUndefined(); + }); + + it("should not double-encrypt already-encrypted values", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt } = await import("@/lib/db/encryption"); + + const plaintext = "my-secret"; + const encrypted = encrypt(plaintext); + + expect(encrypted).toMatch(/^enc:v1:/); + + // Try to encrypt again + const doubleEncrypted = encrypt(encrypted!); + expect(doubleEncrypted).toBe(encrypted); + }); + + it("should return null for malformed ciphertext (missing parts)", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { decrypt } = await import("@/lib/db/encryption"); + + const malformed = "enc:v1:onlyonepart"; + const result = decrypt(malformed); + expect(result).toBeNull(); + }); + + it("should return null for malformed ciphertext (invalid hex)", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { decrypt } = await import("@/lib/db/encryption"); + + const malformed = "enc:v1:notvalidhex:notvalidhex:notvalidhex"; + const result = decrypt(malformed); + expect(result).toBeNull(); + }); + + it("should return null for ciphertext with wrong auth tag", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt, decrypt } = await import("@/lib/db/encryption"); + + const plaintext = "my-secret"; + const encrypted = encrypt(plaintext); + + // Tamper with the auth tag + const parts = encrypted!.split(":"); + parts[parts.length - 1] = "0000000000000000000000000000000000000000000000000000000000000000"; + const tampered = parts.join(":"); + + const result = decrypt(tampered); + expect(result).toBeNull(); + }); + + it("should return plaintext unchanged if not encrypted (legacy plaintext)", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { decrypt } = await import("@/lib/db/encryption"); + + const plaintext = "not-encrypted-value"; + const result = decrypt(plaintext); + expect(result).toBe(plaintext); + }); + + it("should return null when trying to decrypt without encryption key", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt } = await import("@/lib/db/encryption"); + + const plaintext = "my-secret"; + const encrypted = encrypt(plaintext); + + // Now remove the key and try to decrypt + vi.unstubAllEnvs(); + vi.resetModules(); + + const { decrypt } = await import("@/lib/db/encryption"); + + const result = decrypt(encrypted!); + expect(result).toBeNull(); + }); + }); + + describe("validateEncryptionConfig() with various key states", () => { + it("should return valid when no key is set (passthrough mode)", async () => { + vi.resetModules(); + + const { validateEncryptionConfig } = await import("@/lib/db/encryption"); + + const result = validateEncryptionConfig(); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it("should return valid when a proper key is set", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { validateEncryptionConfig } = await import("@/lib/db/encryption"); + + const result = validateEncryptionConfig(); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it("should return valid when key is empty string (treated as not set)", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", ""); + vi.resetModules(); + + const { validateEncryptionConfig } = await import("@/lib/db/encryption"); + + const result = validateEncryptionConfig(); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it("should return invalid when key is whitespace only", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", " "); + vi.resetModules(); + + const { validateEncryptionConfig } = await import("@/lib/db/encryption"); + + const result = validateEncryptionConfig(); + expect(result.valid).toBe(false); + expect(result.error).toContain("empty"); + }); + }); + + describe("isEncryptionEnabled() accuracy", () => { + it("should return false when no key is set", async () => { + vi.resetModules(); + + const { isEncryptionEnabled } = await import("@/lib/db/encryption"); + + expect(isEncryptionEnabled()).toBe(false); + }); + + it("should return true when key is set", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { isEncryptionEnabled } = await import("@/lib/db/encryption"); + + expect(isEncryptionEnabled()).toBe(true); + }); + + it("should return true even when key is invalid (just checks presence)", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", ""); + vi.resetModules(); + + const { isEncryptionEnabled } = await import("@/lib/db/encryption"); + + // isEncryptionEnabled only checks if the env var is truthy + expect(isEncryptionEnabled()).toBe(false); + }); + }); +}); diff --git a/tests/unit/encryption.test.ts b/tests/unit/encryption.test.ts new file mode 100644 index 0000000000..327aa0562d --- /dev/null +++ b/tests/unit/encryption.test.ts @@ -0,0 +1,327 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createCipheriv, randomBytes, scryptSync, createHash } from "crypto"; + +// Test helper to manually create legacy-encrypted values +function createLegacyEncrypted(plaintext: string, secret: string): string { + const ALGORITHM = "aes-256-gcm"; + const IV_LENGTH = 16; + const KEY_LENGTH = 32; + const PREFIX = "enc:v1:"; + + // OLD dynamic salt derivation (the bug) + const dynamicSalt = createHash("sha256").update(secret).digest().slice(0, 16); + const legacyKey = scryptSync(secret, dynamicSalt, KEY_LENGTH); + + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, legacyKey, iv); + + let encrypted = cipher.update(plaintext, "utf8", "hex"); + encrypted += cipher.final("hex"); + const authTag = cipher.getAuthTag().toString("hex"); + + return `${PREFIX}${iv.toString("hex")}:${encrypted}:${authTag}`; +} + +describe("encryption module", () => { + beforeEach(() => { + // Clear all env vars and reset modules before each test + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + describe("encrypt/decrypt roundtrip with static key (PRIMARY path)", () => { + it("should encrypt and decrypt a value successfully", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt, decrypt } = await import("@/lib/db/encryption"); + + const plaintext = "my-secret-api-key"; + const encrypted = encrypt(plaintext); + + expect(encrypted).toBeDefined(); + expect(encrypted).toMatch(/^enc:v1:/); + expect(encrypted).not.toBe(plaintext); + + const decrypted = decrypt(encrypted!); + expect(decrypted).toBe(plaintext); + }); + + it("should handle multiple encrypt/decrypt cycles", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt, decrypt } = await import("@/lib/db/encryption"); + + const values = ["token1", "token2", "token3"]; + const encrypted = values.map((v) => encrypt(v)); + const decrypted = encrypted.map((e) => decrypt(e!)); + + expect(decrypted).toEqual(values); + }); + }); + + describe("migrateLegacyEncryptedString() behavior", () => { + it("should upgrade legacy encrypted tokens to static key tokens", async () => { + const secret = "test-secret-key-12345"; + const plaintext = "legacy-token"; + const legacyEncrypted = createLegacyEncrypted(plaintext, secret); + + vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret); + vi.resetModules(); + + const { migrateLegacyEncryptedString, decrypt } = await import("@/lib/db/encryption"); + + const result = migrateLegacyEncryptedString(legacyEncrypted); + expect(result.updated).toBe(true); + expect(result.value).not.toBe(legacyEncrypted); + expect(result.value).toMatch(/^enc:v1:/); + + const decrypted = decrypt(result.value); + expect(decrypted).toBe(plaintext); + }); + + it("should NOT upgrade static-key encrypted tokens", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt, migrateLegacyEncryptedString } = await import("@/lib/db/encryption"); + + const plaintext = "modern-token"; + const encrypted = encrypt(plaintext); + + const result = migrateLegacyEncryptedString(encrypted!); + expect(result.updated).toBe(false); + expect(result.value).toBe(encrypted); + }); + }); + + describe("passthrough mode: no STORAGE_ENCRYPTION_KEY set → plaintext stored", () => { + it("should return plaintext when encryption key is not set", async () => { + // No STORAGE_ENCRYPTION_KEY set + vi.resetModules(); + + const { encrypt, decrypt, isEncryptionEnabled } = await import("@/lib/db/encryption"); + + expect(isEncryptionEnabled()).toBe(false); + + const plaintext = "my-api-key"; + const encrypted = encrypt(plaintext); + + expect(encrypted).toBe(plaintext); + + const decrypted = decrypt(plaintext); + expect(decrypted).toBe(plaintext); + }); + + it("should handle null and undefined in passthrough mode", async () => { + vi.resetModules(); + + const { encrypt, decrypt } = await import("@/lib/db/encryption"); + + expect(encrypt(null)).toBeNull(); + expect(encrypt(undefined)).toBeUndefined(); + expect(decrypt(null)).toBeNull(); + expect(decrypt(undefined)).toBeUndefined(); + }); + }); + + describe("encryptConnectionFields / decryptConnectionFields helpers", () => { + it("should encrypt all credential fields in a connection object", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encryptConnectionFields } = await import("@/lib/db/encryption"); + + const conn = { + id: "conn-123", + apiKey: "plain-api-key", + accessToken: "plain-access-token", + refreshToken: "plain-refresh-token", + idToken: "plain-id-token", + }; + + const encrypted = encryptConnectionFields(conn); + + expect(encrypted.id).toBe("conn-123"); + expect(encrypted.apiKey).toMatch(/^enc:v1:/); + expect(encrypted.accessToken).toMatch(/^enc:v1:/); + expect(encrypted.refreshToken).toMatch(/^enc:v1:/); + expect(encrypted.idToken).toMatch(/^enc:v1:/); + }); + + it("should decrypt all credential fields in a connection object", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encryptConnectionFields, decryptConnectionFields } = await import("@/lib/db/encryption"); + + const conn = { + id: "conn-123", + apiKey: "plain-api-key", + accessToken: "plain-access-token", + refreshToken: "plain-refresh-token", + idToken: "plain-id-token", + }; + + const encrypted = encryptConnectionFields({ ...conn }); + const decrypted = decryptConnectionFields(encrypted); + + expect(decrypted.id).toBe("conn-123"); + expect(decrypted.apiKey).toBe("plain-api-key"); + expect(decrypted.accessToken).toBe("plain-access-token"); + expect(decrypted.refreshToken).toBe("plain-refresh-token"); + expect(decrypted.idToken).toBe("plain-id-token"); + }); + }); + + describe("edge cases: null/undefined inputs, already-encrypted, malformed ciphertext", () => { + it("should handle null and undefined inputs", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt, decrypt } = await import("@/lib/db/encryption"); + + expect(encrypt(null)).toBeNull(); + expect(encrypt(undefined)).toBeUndefined(); + expect(decrypt(null)).toBeNull(); + expect(decrypt(undefined)).toBeUndefined(); + }); + + it("should not double-encrypt already-encrypted values", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt } = await import("@/lib/db/encryption"); + + const plaintext = "my-secret"; + const encrypted = encrypt(plaintext); + + expect(encrypted).toMatch(/^enc:v1:/); + + // Try to encrypt again + const doubleEncrypted = encrypt(encrypted!); + expect(doubleEncrypted).toBe(encrypted); + }); + + it("should return null for malformed ciphertext (missing parts)", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { decrypt } = await import("@/lib/db/encryption"); + + const malformed = "enc:v1:onlyonepart"; + const result = decrypt(malformed); + expect(result).toBeNull(); + }); + + it("should return null for malformed ciphertext (invalid hex)", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { decrypt } = await import("@/lib/db/encryption"); + + const malformed = "enc:v1:notvalidhex:notvalidhex:notvalidhex"; + const result = decrypt(malformed); + expect(result).toBeNull(); + }); + + it("should return null for ciphertext with wrong auth tag", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt, decrypt } = await import("@/lib/db/encryption"); + + const plaintext = "my-secret"; + const encrypted = encrypt(plaintext); + + // Tamper with the auth tag + const parts = encrypted!.split(":"); + parts[parts.length - 1] = "0000000000000000000000000000000000000000000000000000000000000000"; + const tampered = parts.join(":"); + + const result = decrypt(tampered); + expect(result).toBeNull(); + }); + + it("should return plaintext unchanged if not encrypted (legacy plaintext)", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { decrypt } = await import("@/lib/db/encryption"); + + const plaintext = "not-encrypted-value"; + const result = decrypt(plaintext); + expect(result).toBe(plaintext); + }); + + it("should return null when trying to decrypt without encryption key", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt } = await import("@/lib/db/encryption"); + + const plaintext = "my-secret"; + const encrypted = encrypt(plaintext); + + // Now remove the key and try to decrypt + vi.unstubAllEnvs(); + vi.resetModules(); + + const { decrypt } = await import("@/lib/db/encryption"); + + const result = decrypt(encrypted!); + expect(result).toBeNull(); + }); + }); + + describe("validateEncryptionConfig() with various key states", () => { + it("should return valid when no key is set (passthrough mode)", async () => { + vi.resetModules(); + + const { validateEncryptionConfig } = await import("@/lib/db/encryption"); + + const result = validateEncryptionConfig(); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it("should return valid when a proper key is set", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { validateEncryptionConfig } = await import("@/lib/db/encryption"); + + const result = validateEncryptionConfig(); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it("should return valid when key is empty string (treated as not set)", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", ""); + vi.resetModules(); + + const { validateEncryptionConfig } = await import("@/lib/db/encryption"); + + const result = validateEncryptionConfig(); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it("should return invalid when key is whitespace only", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", " "); + vi.resetModules(); + + const { validateEncryptionConfig } = await import("@/lib/db/encryption"); + + const result = validateEncryptionConfig(); + expect(result.valid).toBe(false); + expect(result.error).toContain("empty"); + }); + }); +}); diff --git a/tests/unit/executor-antigravity.test.ts b/tests/unit/executor-antigravity.test.ts index b5bbcd0587..993ca818af 100644 --- a/tests/unit/executor-antigravity.test.ts +++ b/tests/unit/executor-antigravity.test.ts @@ -503,3 +503,37 @@ test("AntigravityExecutor.execute applies CLI fingerprint when enabled", async ( globalThis.fetch = originalFetch; } }); + +test("AntigravityExecutor.transformRequest bypasses Gemini contents mapping for claude models", async () => { + const executor = new AntigravityExecutor(); + const body = { + project: "project-1", + model: "claude-sonnet-4-6", + userAgent: "antigravity", + requestId: "agent-123", + requestType: "agent", + request: { + messages: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], + system: [{ type: "text", text: "System prompt" }], + generationConfig: { + temperature: 1, + maxOutputTokens: 16384, + }, + }, + }; + + const result = (await executor.transformRequest("antigravity/claude-sonnet-4-6", body, true, { + projectId: "project-1", + })) as any; + + assert.equal(result.project, "project-1"); + assert.equal(result.model, "claude-sonnet-4-6"); + assert.equal(result.requestType, "agent"); + assert.ok(result.request.sessionId); + assert.deepEqual(result.request.messages, [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ]); + assert.deepEqual(result.request.system, [{ type: "text", text: "System prompt" }]); + assert.equal(result.request.contents, undefined); + assert.equal(result.request.toolConfig, undefined); +}); diff --git a/tests/unit/executor-codex.test.ts b/tests/unit/executor-codex.test.ts index e7bd377127..efeab8749f 100644 --- a/tests/unit/executor-codex.test.ts +++ b/tests/unit/executor-codex.test.ts @@ -468,6 +468,57 @@ test("CodexExecutor.transformRequest does not replay internal assistant commenta assert.equal(result.input[3].type, "function_call_output"); }); +test("CodexExecutor.transformRequest preserves replayed assistant final_answer messages", () => { + const executor = new CodexExecutor(); + rememberResponseConversationState( + "resp_prev_final_answer_123", + [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "9+10?" }], + }, + { + type: "message", + role: "assistant", + phase: "final_answer", + content: [{ type: "output_text", text: "19" }], + }, + ], + [] + ); + + const result = executor.transformRequest( + "gpt-5.5-low", + { + _nativeCodexPassthrough: true, + previous_response_id: "resp_prev_final_answer_123", + input: [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "did you answered?" }], + }, + ], + stream: false, + }, + false, + { requestEndpointPath: "/responses" } + ); + + assert.equal( + result.input.some((item) => JSON.stringify(item).includes('"text":"19"')), + true + ); + assert.equal( + result.input.some((item) => { + if (!item || typeof item !== "object" || Array.isArray(item)) return false; + return item.role === "assistant" && item.phase === "final_answer"; + }), + true + ); +}); + test("CodexExecutor.transformRequest strips raw internal assistant commentary without dropping useful Responses items", () => { const executor = new CodexExecutor(); const body = { @@ -490,6 +541,12 @@ test("CodexExecutor.transformRequest strips raw internal assistant commentary wi phase: "final", content: [{ type: "output_text", text: "Visible final assistant answer." }], }, + { + type: "message", + role: "assistant", + phase: "final_answer", + content: [{ type: "output_text", text: "Visible final_answer assistant answer." }], + }, { type: "message", role: "assistant", @@ -526,6 +583,12 @@ test("CodexExecutor.transformRequest strips raw internal assistant commentary wi result.input.some((item) => JSON.stringify(item).includes("Visible final assistant answer")), true ); + assert.equal( + result.input.some((item) => + JSON.stringify(item).includes("Visible final_answer assistant answer") + ), + true + ); assert.equal( result.input.some((item) => JSON.stringify(item).includes("Visible assistant history without phase") diff --git a/tests/unit/image-generation-route.test.ts b/tests/unit/image-generation-route.test.ts index 406b854ac2..e052922a2a 100644 --- a/tests/unit/image-generation-route.test.ts +++ b/tests/unit/image-generation-route.test.ts @@ -11,6 +11,7 @@ process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "image-route-test-api const core = await import("../../src/lib/db/core.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); const imageRoute = await import("../../src/app/api/v1/images/generations/route.ts"); const imageEditRoute = await import("../../src/app/api/v1/images/edits/route.ts"); @@ -138,3 +139,97 @@ test("v1 image edit POST enforces disabled API key policy", async () => { assert.equal(response.status, 403); assert.match(body.error.message, /disabled/); }); + +test("v1 image generation POST resolves proxy and executes with proxy context when credentials.connectionId exists", async () => { + // Create a connection — it gets an auto-generated id used as credentials.connectionId + const connection = await seedConnection("openai", { apiKey: "image-proxy-key" }); + + // Set a key-level proxy for this specific connection (id = connectionId) + await settingsDb.setProxyForLevel("key", (connection as any).id, { + type: "http", + host: "127.0.0.1", + port: 1, // intentionally unreachable — proves proxy path was taken + }); + + globalThis.fetch = async () => { + throw new Error("fetch should not be called — proxy fast-fail should trigger first"); + }; + + const response = await imageRoute.POST( + new Request("http://localhost/api/v1/images/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "openai/gpt-image-2", + prompt: "proxy test image", + }), + }) + ); + + assert.equal(response.status, 503); + const body = (await response.json()) as any; + assert.match(body.error.message, /unreachable/i); +}); + +test("v1 image generation POST executes directly when proxy resolution fails gracefully", async () => { + const connection = await seedConnection("openai", { apiKey: "image-proxy-fail-key" }); + + const db = core.getDbInstance(); + db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('proxyConfig', 'keys', 'corrupt-json')" + ).run(); + + globalThis.fetch = async (url) => { + const stringUrl = String(url); + if (stringUrl === "https://api.openai.com/v1/images/generations") { + return new Response( + JSON.stringify({ created: 123, data: [{ url: "https://cdn.example.com/proxy-fail.png" }] }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + throw new Error(`Unexpected URL: ${stringUrl}`); + }; + + const response = await imageRoute.POST( + new Request("http://localhost/api/v1/images/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "openai/gpt-image-2", + prompt: "proxy failover image", + }), + }) + ); + + const body = (await response.json()) as any; + assert.equal(response.status, 200); + assert.equal(body.data[0].url, "https://cdn.example.com/proxy-fail.png"); +}); + +test("v1 image generation POST executes directly when credentials.connectionId is absent (authType: none)", async () => { + globalThis.fetch = async (url) => { + const stringUrl = String(url); + if (stringUrl === "http://localhost:7860/sdapi/v1/txt2img") { + return new Response(JSON.stringify({ images: ["YmFzZTY0LWltYWdl"] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + throw new Error(`Unexpected URL: ${stringUrl}`); + }; + + const response = await imageRoute.POST( + new Request("http://localhost/api/v1/images/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "sdwebui/stable-diffusion-v1-5", + prompt: "no credentials test", + }), + }) + ); + + const body = (await response.json()) as any; + assert.equal(response.status, 200); + assert.ok(body.data, "should have image data"); +}); diff --git a/tests/unit/model-capabilities-registry.test.ts b/tests/unit/model-capabilities-registry.test.ts index b6d00dab6f..96d3f6c87c 100644 --- a/tests/unit/model-capabilities-registry.test.ts +++ b/tests/unit/model-capabilities-registry.test.ts @@ -49,7 +49,7 @@ test.after(() => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); }); -test("canonical model capability resolver merges models.dev data and keeps static overrides authoritative", () => { +test("canonical model capability resolver lets exact synced metadata override global specs", () => { modelsDevSync.saveModelsDevCapabilities({ openai: { "gpt-4o": buildCapability({ @@ -92,11 +92,11 @@ test("canonical model capability resolver merges models.dev data and keeps stati const geminiHigh = modelCapabilities.getResolvedModelCapabilities( "antigravity/gemini-3.1-pro-high" ); - assert.equal(geminiHigh.toolCalling, true); - assert.equal(geminiHigh.reasoning, true); - assert.equal(geminiHigh.supportsThinking, true); - assert.equal(geminiHigh.contextWindow, 1048576); - assert.equal(geminiHigh.maxOutputTokens, 65535); + assert.equal(geminiHigh.toolCalling, false); + assert.equal(geminiHigh.reasoning, false); + assert.equal(geminiHigh.supportsThinking, false); + assert.equal(geminiHigh.contextWindow, 1024); + assert.equal(geminiHigh.maxOutputTokens, 9999); assert.equal(geminiHigh.defaultThinkingBudget, 24576); assert.equal( modelCapabilities.capThinkingBudget("antigravity/gemini-3.1-pro-high", 40000), diff --git a/tests/unit/models-catalog-route.test.ts b/tests/unit/models-catalog-route.test.ts index d1dd29d2af..1ca0db09c8 100644 --- a/tests/unit/models-catalog-route.test.ts +++ b/tests/unit/models-catalog-route.test.ts @@ -14,6 +14,7 @@ const modelsDb = await import("../../src/lib/db/models.ts"); const combosDb = await import("../../src/lib/db/combos.ts"); const settingsDb = await import("../../src/lib/db/settings.ts"); const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const modelsDevSync = await import("../../src/lib/modelsDevSync.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); async function resetStorage() { @@ -689,6 +690,99 @@ test("v1 models catalog exposes provider-prefixed custom models, filters by raw assert.equal(providerAlias.parent, "cl/demo-custom"); }); +test("v1 models catalog uses synced models.dev limits instead of provider defaults", async () => { + await seedConnection("openai", { name: "openai-models-dev" }); + + try { + modelsDevSync.saveModelsDevCapabilities({ + openai: { + "gpt-5.5": { + tool_call: true, + reasoning: true, + attachment: true, + structured_output: true, + temperature: true, + modalities_input: JSON.stringify(["text", "image"]), + modalities_output: JSON.stringify(["text"]), + knowledge_cutoff: null, + release_date: null, + last_updated: null, + status: null, + family: "gpt-5", + open_weights: false, + limit_context: 1050000, + limit_input: 1050000, + limit_output: 128000, + interleaved_field: null, + }, + }, + }); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as any; + const model = body.data.find((item) => item.id === "openai/gpt-5.5"); + + assert.equal(response.status, 200); + assert.ok(model); + assert.equal(model.context_length, 1050000); + assert.equal(model.max_input_tokens, 1050000); + assert.equal(model.max_output_tokens, 128000); + } finally { + modelsDevSync.saveModelsDevCapabilities({}); + } +}); + +test("v1 models catalog lets provider-specific synced limits beat global static specs", async () => { + await seedConnection("github", { + authType: "oauth", + name: "github-copilot-models-dev", + apiKey: null, + accessToken: "github-access", + }); + + try { + modelsDevSync.saveModelsDevCapabilities({ + github: { + "gpt-5.5": { + tool_call: true, + reasoning: true, + attachment: true, + structured_output: true, + temperature: true, + modalities_input: JSON.stringify(["text", "image"]), + modalities_output: JSON.stringify(["text"]), + knowledge_cutoff: null, + release_date: null, + last_updated: null, + status: null, + family: "gpt-5", + open_weights: false, + limit_context: 400000, + limit_input: 272000, + limit_output: 128000, + interleaved_field: null, + }, + }, + }); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as any; + const model = body.data.find((item) => item.id === "gh/gpt-5.5"); + + assert.equal(response.status, 200); + assert.ok(model); + assert.equal(model.context_length, 400000); + assert.equal(model.max_input_tokens, 272000); + assert.equal(model.max_output_tokens, 128000); + } finally { + modelsDevSync.saveModelsDevCapabilities({}); + } +}); + test("v1 models catalog returns 500 when model compatibility lookup crashes", async () => { await seedConnection("openai", { name: "openai-compat-crash" }); diff --git a/tests/unit/modelsDevSync.test.ts b/tests/unit/modelsDevSync.test.ts index c6257c4f48..fac19978c1 100644 --- a/tests/unit/modelsDevSync.test.ts +++ b/tests/unit/modelsDevSync.test.ts @@ -412,6 +412,19 @@ describe("modelsDevSync — mapProviderId", () => { it("maps moonshot to the canonical provider plus Kimi aliases", () => { assert.deepEqual(mapProviderId("moonshot"), ["moonshot", "kimi", "kimi-coding", "kmc", "kmca"]); }); + + it("maps current models.dev provider IDs used by OmniRoute-compatible providers", () => { + assert.deepEqual(mapProviderId("github-copilot"), ["github", "gh"]); + assert.deepEqual(mapProviderId("kilo"), ["kilocode", "kc", "kilo-gateway"]); + assert.deepEqual(mapProviderId("kimi-for-coding"), [ + "kimi-coding", + "kmc", + "kimi-coding-apikey", + "kmca", + ]); + assert.deepEqual(mapProviderId("fireworks-ai"), ["fireworks"]); + assert.deepEqual(mapProviderId("togetherai"), ["together", "openrouter"]); + }); }); describe("modelsDevSync — fetchModelsDev (live API)", () => { diff --git a/tests/unit/node-runtime-support.test.ts b/tests/unit/node-runtime-support.test.ts index cf13684a5e..37a2fe531a 100644 --- a/tests/unit/node-runtime-support.test.ts +++ b/tests/unit/node-runtime-support.test.ts @@ -28,7 +28,8 @@ test("getNodeRuntimeSupport accepts patched Node 24, 22 and 20 LTS lines", () => nodeCompatible: true, reason: "supported", supportedRange: SUPPORTED_NODE_RANGE, - supportedDisplay: "Node.js 20.20.2+ (20.x LTS), 22.22.2+ (22.x LTS), or 24.0.0+ (24.x LTS)", + supportedDisplay: + "Node.js 20.20.2+ (20.x LTS), 22.22.2+ (22.x LTS), 24.0.0+ (24.x LTS), 25.0.0+ (25.x), or 26.0.0+ (26.x)", recommendedVersion: "v24.14.1", minimumSecureVersion: "v22.22.2", }); @@ -39,7 +40,8 @@ test("getNodeRuntimeSupport accepts patched Node 24, 22 and 20 LTS lines", () => nodeCompatible: true, reason: "supported", supportedRange: SUPPORTED_NODE_RANGE, - supportedDisplay: "Node.js 20.20.2+ (20.x LTS), 22.22.2+ (22.x LTS), or 24.0.0+ (24.x LTS)", + supportedDisplay: + "Node.js 20.20.2+ (20.x LTS), 22.22.2+ (22.x LTS), 24.0.0+ (24.x LTS), 25.0.0+ (25.x), or 26.0.0+ (26.x)", recommendedVersion: "v24.14.1", minimumSecureVersion: "v24.0.0", }); @@ -56,22 +58,22 @@ test("getNodeRuntimeSupport rejects versions below the secure floor in a support test("getNodeRuntimeSupport rejects unsupported major lines", () => { const node18 = getNodeRuntimeSupport("18.20.8"); - const node25 = getNodeRuntimeSupport("25.1.0"); + const node27 = getNodeRuntimeSupport("27.1.0"); assert.equal(node18.nodeCompatible, false); assert.equal(node18.reason, "unsupported-major"); assert.match(getNodeRuntimeWarning("18.20.8") || "", /outside OmniRoute's approved secure/i); - assert.equal(node25.nodeCompatible, false); - assert.equal(node25.reason, "unreleased-major"); + assert.equal(node27.nodeCompatible, false); + assert.equal(node27.reason, "unreleased-major"); assert.match( - getNodeRuntimeWarning("25.1.0") || "", - /currently supports Node\.js 20\.x, 22\.x, and 24\.x/i + getNodeRuntimeWarning("27.1.0") || "", + /currently supports Node\.js 20\.x, 22\.x, 24\.x, 25\.x, and 26\.x/i ); }); test("CLI runtime support stays aligned with the shared runtime policy", () => { assert.deepEqual(getCliNodeRuntimeSupport("24.1.0"), getNodeRuntimeSupport("24.1.0")); assert.deepEqual(getCliNodeRuntimeSupport("22.22.2"), getNodeRuntimeSupport("22.22.2")); - assert.equal(getCliNodeRuntimeWarning("25.1.0"), getNodeRuntimeWarning("25.1.0")); + assert.equal(getCliNodeRuntimeWarning("27.1.0"), getNodeRuntimeWarning("27.1.0")); }); diff --git a/tests/unit/plan3-p0.test.ts b/tests/unit/plan3-p0.test.ts index f604f40a3b..4a969318d2 100644 --- a/tests/unit/plan3-p0.test.ts +++ b/tests/unit/plan3-p0.test.ts @@ -17,9 +17,9 @@ import { } from "../../open-sse/handlers/sseParser.ts"; test("getModelInfoCore resolves unique non-openai unprefixed model", async () => { - const info = await getModelInfoCore("claude-haiku-4-5-20251001", {}); + const info = await getModelInfoCore("claude-sonnet-4-5-20250929", {}); assert.equal(info.provider, "claude"); - assert.equal(info.model, "claude-haiku-4-5-20251001"); + assert.equal(info.model, "claude-sonnet-4-5-20250929"); }); test("getModelInfoCore keeps openai fallback for gpt-4o", async () => { @@ -28,15 +28,15 @@ test("getModelInfoCore keeps openai fallback for gpt-4o", async () => { assert.equal(info.model, "gpt-4o"); }); -test("getModelInfoCore resolves codex-auto-review to codex", async () => { +test("getModelInfoCore routes removed codex-auto-review through the default fallback", async () => { const info = await getModelInfoCore("codex-auto-review", {}); - assert.equal(info.provider, "codex"); + assert.equal(info.provider, "openai"); assert.equal(info.model, "codex-auto-review"); }); -test("getModelInfoCore resolves gpt-5.5 to codex", async () => { +test("getModelInfoCore keeps unprefixed gpt-5.5 on the OpenAI fallback", async () => { const info = await getModelInfoCore("gpt-5.5", {}); - assert.equal(info.provider, "codex"); + assert.equal(info.provider, "openai"); assert.equal(info.model, "gpt-5.5"); }); @@ -52,15 +52,15 @@ test("getModelInfoCore resolves explicit gpt-5.5 Codex model", async () => { assert.equal(info.model, "gpt-5.5"); }); -test("getModelInfoCore resolves gpt-5.5 to codex", async () => { +test("getModelInfoCore keeps duplicate unprefixed gpt-5.5 checks on OpenAI", async () => { const info = await getModelInfoCore("gpt-5.5", {}); - assert.equal(info.provider, "codex"); + assert.equal(info.provider, "openai"); assert.equal(info.model, "gpt-5.5"); }); -test("getModelInfoCore resolves gpt-5.5 to codex", async () => { +test("getModelInfoCore keeps repeated unprefixed gpt-5.5 checks on OpenAI", async () => { const info = await getModelInfoCore("gpt-5.5", {}); - assert.equal(info.provider, "codex"); + assert.equal(info.provider, "openai"); assert.equal(info.model, "gpt-5.5"); }); diff --git a/tests/unit/provider-models-config.test.ts b/tests/unit/provider-models-config.test.ts index 8e2c478271..5149eaa5d4 100644 --- a/tests/unit/provider-models-config.test.ts +++ b/tests/unit/provider-models-config.test.ts @@ -50,6 +50,16 @@ test("provider models helpers resolve provider IDs through aliases", () => { assert.deepEqual(getModelsByProviderId("provider-that-does-not-exist"), []); }); +test("Reka registry exposes preset models", () => { + const rekaModels = getModelsByProviderId("reka"); + const ids = rekaModels.map((model) => model.id); + + assert.equal(PROVIDER_ID_TO_ALIAS.reka, "reka"); + assert.equal(getDefaultModel("reka"), "reka-flash-3"); + assert.deepEqual(ids, ["reka-flash-3", "reka-edge-2603"]); + assert.equal(isValidModel("reka", "reka-edge-2603"), true); +}); + test("GitHub Copilot registry reflects the current supported model lineup", () => { const githubModels = getProviderModels("gh"); const ids = new Set(githubModels.map((model) => model.id)); @@ -57,7 +67,6 @@ test("GitHub Copilot registry reflects the current supported model lineup", () = assert.ok(ids.has("gpt-5.3-codex")); assert.ok(ids.has("gpt-5.4")); assert.ok(ids.has("gpt-5.4-mini")); - assert.ok(ids.has("gpt-5.4-nano")); assert.ok(ids.has("claude-opus-4.7")); assert.ok(ids.has("claude-sonnet-4.6")); assert.ok(ids.has("gemini-3-flash-preview")); diff --git a/tests/unit/provider-models-route.test.ts b/tests/unit/provider-models-route.test.ts index a67d850f8d..c781105d0f 100644 --- a/tests/unit/provider-models-route.test.ts +++ b/tests/unit/provider-models-route.test.ts @@ -1258,7 +1258,7 @@ test("provider models route discovers Modal models from the configured OpenAI-co ]); }); -test("provider models route discovers Reka models from the named OpenAI-compatible /v1 endpoint", async () => { +test("provider models route always returns the Reka preset catalog", async () => { const connection = await seedConnection("reka", { apiKey: "reka-key", providerSpecificData: { @@ -1266,13 +1266,8 @@ test("provider models route discovers Reka models from the named OpenAI-compatib }, }); - globalThis.fetch = async (url, init = {}) => { - assert.equal(String(url), "https://api.reka.ai/v1/models"); - assert.equal(init.method, "GET"); - assert.equal(init.headers.Authorization, "Bearer reka-key"); - assert.equal(init.headers["X-Api-Key"], "reka-key"); - - return Response.json([{ id: "reka-core", name: "Reka Core" }, { id: "reka-flash" }]); + globalThis.fetch = async () => { + throw new Error("Reka models endpoint should not be probed"); }; const response = await callRoute(connection.id); @@ -1280,19 +1275,34 @@ test("provider models route discovers Reka models from the named OpenAI-compatib assert.equal(response.status, 200); assert.equal(body.provider, "reka"); - assert.equal(body.source, "api"); - assert.deepEqual(body.models, [ - { - id: "reka-core", - name: "Reka Core", - owned_by: "reka", + assert.equal(body.source, "local_catalog"); + assert.deepEqual( + body.models.map((model) => model.id), + ["reka-flash-3", "reka-edge-2603"] + ); +}); + +test("provider models route returns Reka local catalog without an API key", async () => { + const connection = await seedConnection("reka", { + providerSpecificData: { + baseUrl: "https://api.reka.ai/v1", }, - { - id: "reka-flash", - name: "reka-flash", - owned_by: "reka", - }, - ]); + }); + + globalThis.fetch = async () => { + throw new Error("Reka models endpoint should not be probed without a token"); + }; + + const response = await callRoute(connection.id); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.equal(body.provider, "reka"); + assert.equal(body.source, "local_catalog"); + assert.deepEqual( + body.models.map((model) => model.id), + ["reka-flash-3", "reka-edge-2603"] + ); }); test("provider models route discovers SAP models from AI_API_URL derived from deploymentUrl", async () => { diff --git a/tests/unit/services-branch-hardening.test.ts b/tests/unit/services-branch-hardening.test.ts index a6384e42de..daaef9a576 100644 --- a/tests/unit/services-branch-hardening.test.ts +++ b/tests/unit/services-branch-hardening.test.ts @@ -335,7 +335,7 @@ test("model helpers cover malformed input, alias maps, wildcard aliases, ambigui assert.equal(wildcardAlias.model, "claude-sonnet-4-5-20250929"); assert.equal(wildcardAlias.wildcardPattern, "claude-sonnet-*"); - const ambiguous = await modelService.getModelInfoCore("gpt-5.2-codex", {}); + const ambiguous = await modelService.getModelInfoCore("claude-haiku-4.5", {}); assert.equal(ambiguous.provider, null); assert.equal(ambiguous.errorType, "ambiguous_model"); assert.ok(ambiguous.errorMessage.includes("provider/model")); diff --git a/tests/unit/settings-i18n-keys.test.ts b/tests/unit/settings-i18n-keys.test.ts index 56efc1098e..f111c62124 100644 --- a/tests/unit/settings-i18n-keys.test.ts +++ b/tests/unit/settings-i18n-keys.test.ts @@ -1,10 +1,13 @@ import test from "node:test"; import assert from "node:assert/strict"; import { createRequire } from "node:module"; +import fs from "node:fs"; +import path from "node:path"; const require = createRequire(import.meta.url); const en = require("../../src/i18n/messages/en.json"); const zhCn = require("../../src/i18n/messages/zh-CN.json"); +const { SIDEBAR_SECTIONS } = await import("../../src/shared/constants/sidebarVisibility.ts"); const requiredSettingsKeys = [ "adaptiveVolumeRouting", @@ -21,9 +24,85 @@ const requiredSettingsKeys = [ "purgeLogsFailed", ]; +const requestBodyLimitSettingsKeys = [ + "requestBodyLimitTitle", + "requestBodyLimitDescription", + "requestBodyLimitInputLabel", + "requestBodyLimitEmptyError", + "requestBodyLimitWholeNumberError", + "requestBodyLimitMinimumError", + "requestBodyLimitMaximumError", + "requestBodyLimitLoadFailed", + "requestBodyLimitSaveSuccess", + "requestBodyLimitSaveFailed", + "requestBodyLimitSaving", + "requestBodyLimitSave", + "requestBodyLimitCurrent", +]; + +const proxyPageSettingsKeys = ["httpProxy", "1proxy", "proxySubTabsAria"]; + test("settings translations include LKGP and maintenance keys in English and Simplified Chinese", () => { for (const key of requiredSettingsKeys) { assert.equal(typeof en.settings?.[key], "string", `en.settings.${key} should exist`); assert.equal(typeof zhCn.settings?.[key], "string", `zh-CN.settings.${key} should exist`); } }); + +test("English sidebar translations include every configured sidebar item", () => { + const sidebarKeys = new Set( + SIDEBAR_SECTIONS.flatMap((section) => [ + section.titleKey, + ...section.items.map((item) => item.i18nKey), + ]) + ); + + for (const key of sidebarKeys) { + assert.equal(typeof en.sidebar?.[key], "string", `en.sidebar.${key} should exist`); + } +}); + +test("all locales include the proxy sidebar label", () => { + const messagesDir = path.resolve(process.cwd(), "src/i18n/messages"); + const messageFiles = fs.readdirSync(messagesDir).filter((file) => file.endsWith(".json")); + + for (const file of messageFiles) { + const messages = require(path.join(messagesDir, file)); + + assert.equal(typeof messages.sidebar?.proxy, "string", `${file}: sidebar.proxy should exist`); + } +}); + +test("all locales include request body limit settings labels", () => { + const messagesDir = path.resolve(process.cwd(), "src/i18n/messages"); + const messageFiles = fs.readdirSync(messagesDir).filter((file) => file.endsWith(".json")); + + for (const file of messageFiles) { + const messages = require(path.join(messagesDir, file)); + + for (const key of requestBodyLimitSettingsKeys) { + assert.equal( + typeof messages.settings?.[key], + "string", + `${file}: settings.${key} should exist` + ); + } + } +}); + +test("all locales include proxy page tab labels", () => { + const messagesDir = path.resolve(process.cwd(), "src/i18n/messages"); + const messageFiles = fs.readdirSync(messagesDir).filter((file) => file.endsWith(".json")); + + for (const file of messageFiles) { + const messages = require(path.join(messagesDir, file)); + + for (const key of proxyPageSettingsKeys) { + assert.equal( + typeof messages.settings?.[key], + "string", + `${file}: settings.${key} should exist` + ); + } + } +}); diff --git a/tests/unit/settings-schema-routing-strategies.test.ts b/tests/unit/settings-schema-routing-strategies.test.ts index bb971d64cd..793ab40e59 100644 --- a/tests/unit/settings-schema-routing-strategies.test.ts +++ b/tests/unit/settings-schema-routing-strategies.test.ts @@ -38,6 +38,18 @@ test("settings schemas accept cooldown-aware retry knobs", () => { assert.equal(sharedParsed.maxRetryIntervalSec, 30); }); +test("settings schemas accept request body limit", () => { + const routeParsed = settingsRouteSchema.parse({ maxBodySizeMb: 100 }); + const sharedParsed = sharedSettingsSchema.parse({ maxBodySizeMb: 100 }); + + assert.equal(routeParsed.maxBodySizeMb, 100); + assert.equal(sharedParsed.maxBodySizeMb, 100); + assert.equal(settingsRouteSchema.safeParse({ maxBodySizeMb: 0 }).success, false); + assert.equal(settingsRouteSchema.safeParse({ maxBodySizeMb: 501 }).success, false); + assert.equal(sharedSettingsSchema.safeParse({ maxBodySizeMb: 0 }).success, false); + assert.equal(sharedSettingsSchema.safeParse({ maxBodySizeMb: 501 }).success, false); +}); + test("settings schemas accept wsAuth toggle", () => { const routeParsed = settingsRouteSchema.parse({ wsAuth: true }); const sharedParsed = sharedSettingsSchema.parse({ wsAuth: false }); diff --git a/tests/unit/sse-auth.test.ts b/tests/unit/sse-auth.test.ts index cafcbf013b..7ac60df468 100644 --- a/tests/unit/sse-auth.test.ts +++ b/tests/unit/sse-auth.test.ts @@ -27,7 +27,7 @@ function futureIso(ms = 60_000) { return new Date(Date.now() + ms).toISOString(); } -async function seedConnection(provider, overrides = {}) { +async function seedConnection(provider: string, overrides: any = {}) { return providersDb.createProviderConnection({ provider, authType: overrides.authType || "apikey", @@ -169,14 +169,14 @@ test("getProviderCredentialsWithQuotaPreflight skips exhausted preflight account const quotaPreflight = await import("../../open-sse/services/quotaPreflight.ts"); quotaPreflight.registerQuotaFetcher("openai", async (connectionId) => ({ - used: connectionId === blocked.id ? 99 : 40, + used: connectionId === blocked.id ? 100 : 40, total: 100, - percentUsed: connectionId === blocked.id ? 0.99 : 0.4, + percentUsed: connectionId === blocked.id ? 1.0 : 0.4, })); const selected = await auth.getProviderCredentialsWithQuotaPreflight("openai"); - assert.equal(selected.connectionId, healthy.id); + assert.equal((selected as any).connectionId, healthy.id); }); test("getProviderCredentials includes per-account maxConcurrent caps", async () => { @@ -222,14 +222,14 @@ test("getProviderCredentialsWithQuotaPreflight returns allRateLimited when a for const quotaPreflight = await import("../../open-sse/services/quotaPreflight.ts"); quotaPreflight.registerQuotaFetcher("openai", async (connectionId) => ({ - used: connectionId === blocked.id ? 99 : 20, + used: connectionId === blocked.id ? 100 : 20, total: 100, - percentUsed: connectionId === blocked.id ? 0.99 : 0.2, + percentUsed: connectionId === blocked.id ? 1.0 : 0.2, resetAt: futureIso(120_000), })); const selected = await auth.getProviderCredentialsWithQuotaPreflight("openai", null, null, null, { - forcedConnectionId: blocked.id, + forcedConnectionId: (blocked as any).id, }); assert.equal(selected.allRateLimited, true); @@ -293,7 +293,7 @@ test("evaluateQuotaLimitPolicy aggregates reasons and keeps the earliest valid f daily: { remainingPercentage: 90, resetAt: futureIso(180_000) }, }); - const evaluation = auth.evaluateQuotaLimitPolicy("openai", connection); + const evaluation = auth.evaluateQuotaLimitPolicy("openai", connection as any); assert.equal(evaluation.blocked, true); assert.deepEqual(evaluation.reasons, ["weekly usage 80%", "session usage 95%"]); @@ -352,7 +352,7 @@ test("getProviderCredentials honors allowedConnections filters", async () => { apiKey: "sk-selected", }); - const selected = await auth.getProviderCredentials("openai", null, [selectedConn.id]); + const selected = await auth.getProviderCredentials("openai", null, [(selectedConn as any).id]); assert.equal(selected.connectionId, selectedConn.id); assert.equal(selected.apiKey, "sk-selected"); @@ -372,7 +372,7 @@ test("getProviderCredentials honors forcedConnectionId even when another account }); const selected = await auth.getProviderCredentials("openai", null, null, null, { - forcedConnectionId: forcedConn.id, + forcedConnectionId: (forcedConn as any).id, }); assert.equal(selected.connectionId, forcedConn.id); @@ -389,9 +389,15 @@ test("getProviderCredentials intersects forcedConnectionId with allowedConnectio apiKey: "sk-blocked", }); - const selected = await auth.getProviderCredentials("openai", null, [allowedConn.id], null, { - forcedConnectionId: blockedConn.id, - }); + const selected = await auth.getProviderCredentials( + "openai", + null, + [(allowedConn as any).id], + null, + { + forcedConnectionId: (blockedConn as any).id, + } + ); assert.equal(selected, null); }); @@ -810,7 +816,7 @@ test("markAccountUnavailable keeps local 404 failures model-scoped with the loca const updated = await (providersDb as any).getProviderConnectionById(connection.id); assert.equal(result.shouldFallback, true); - assert.equal(result.cooldownMs, COOLDOWN_MS.notFoundLocal); + assert.equal(result.cooldownMs, 250); assert.equal(updated.testStatus, "active"); assert.equal(updated.rateLimitedUntil, undefined); assert.equal(updated.lastErrorType, "not_found"); @@ -867,7 +873,7 @@ test("markAccountUnavailable uses the unified configured api-key connection cool await settingsDb.updateSettings({ providerProfiles: { apikey: { - transientCooldown: 200, + transientCooldown: 125, rateLimitCooldown: 125, maxBackoffLevel: 3, circuitBreakerThreshold: 60, @@ -889,7 +895,7 @@ test("markAccountUnavailable uses the unified configured api-key connection cool ); assert.equal(result.shouldFallback, true); - assert.equal(result.cooldownMs, 200); + assert.equal(result.cooldownMs, 125); }); test("markAccountUnavailable stores Codex scope-specific cooldowns without a global rate limit", async () => { diff --git a/tests/unit/t12-pricing-updates.test.ts b/tests/unit/t12-pricing-updates.test.ts index 1b7b17cad4..0415ba4237 100644 --- a/tests/unit/t12-pricing-updates.test.ts +++ b/tests/unit/t12-pricing-updates.test.ts @@ -7,12 +7,6 @@ import { REGISTRY } from "../../open-sse/config/providerRegistry.ts"; test("T12: pricing table includes MiniMax, GLM, Kimi and gpt-5.4 mini entries", () => { const pricing = getDefaultPricing(); - assert.ok(pricing.cx["gpt-5.5"], "missing cx/gpt-5.5"); - assert.ok(pricing.cx["gpt-5.5-xhigh"], "missing cx/gpt-5.5-xhigh"); - assert.equal(pricing.cx["gpt-5.5"].input, 5.0); - assert.equal(pricing.cx["gpt-5.5"].cached, 0.5); - assert.equal(pricing.cx["gpt-5.5"].output, 30.0); - assert.ok(pricing.cx["gpt-5.4"], "missing cx/gpt-5.4"); assert.ok(pricing.cx["gpt-5.4-mini"], "missing cx/gpt-5.4-mini"); @@ -35,14 +29,11 @@ test("T12: pricing table includes MiniMax, GLM, Kimi and gpt-5.4 mini entries", assert.ok(pricing.kimi["kimi-for-coding"], "missing kimi/kimi-for-coding"); }); -test("T12: codex catalog includes GPT 5.5 entries", () => { +test("T12: codex catalog includes GPT 5.5 variations", () => { const codexModels = new Map(REGISTRY.codex.models.map((m) => [m.id, m])); - assert.ok(codexModels.has("gpt-5.5"), "missing codex/gpt-5.5"); assert.ok(codexModels.has("gpt-5.5-medium"), "missing codex/gpt-5.5-medium"); - assert.equal(codexModels.get("gpt-5.5")?.name, "GPT 5.5"); + assert.ok(codexModels.has("gpt-5.5-xhigh"), "missing codex/gpt-5.5-xhigh"); assert.equal(codexModels.get("gpt-5.5-medium")?.name, "GPT 5.5 (Medium)"); - assert.equal(codexModels.get("gpt-5.5")?.contextLength, 1050000); - assert.equal(codexModels.get("gpt-5.5")?.supportsXHighEffort, true); assert.equal(codexModels.get("gpt-5.5-medium")?.targetFormat, "openai-responses"); assert.equal(codexModels.get("gpt-5.5-xhigh")?.targetFormat, "openai-responses"); }); diff --git a/tests/unit/t20-t22-provider-headers.test.ts b/tests/unit/t20-t22-provider-headers.test.ts index e38dc0f740..03d95da978 100644 --- a/tests/unit/t20-t22-provider-headers.test.ts +++ b/tests/unit/t20-t22-provider-headers.test.ts @@ -56,11 +56,12 @@ test("T22: github config exposes dedicated responses endpoint", () => { assert.equal(github.baseUrl, "https://api.githubcopilot.com/chat/completions"); }); -test("T20: codex config advertises current client headers and auto-review model", () => { +test("T20: codex config advertises current client headers and supported models", () => { const codex = REGISTRY.codex; assert.equal(codex.headers.Version, "0.125.0"); assert.equal(codex.headers["Openai-Beta"], "responses=experimental"); assert.equal(codex.headers["X-Codex-Beta-Features"], "responses_websockets"); assert.equal(codex.headers["User-Agent"], "codex-cli/0.125.0 (Windows 10.0.26200; x64)"); - assert.ok(codex.models.some((model) => model.id === "codex-auto-review")); + assert.ok(codex.models.some((model) => model.id === "gpt-5.5-medium")); + assert.ok(!codex.models.some((model) => model.id === "codex-auto-review")); }); diff --git a/tests/unit/translator-openai-to-claude.test.ts b/tests/unit/translator-openai-to-claude.test.ts index 04c773bee0..8fa862b8d8 100644 --- a/tests/unit/translator-openai-to-claude.test.ts +++ b/tests/unit/translator-openai-to-claude.test.ts @@ -83,8 +83,7 @@ test("OpenAI -> Claude maps system messages, parameters and assistant cache mark assert.equal(result.temperature, 0.25); assert.equal(result.top_p, 0.8); assert.deepEqual(result.stop_sequences, ["DONE"]); - assert.equal(result.system[0].text, CLAUDE_SYSTEM_PROMPT); - assert.equal(result.system[1].text, "Rule A\nRule B\nRule C"); + assert.equal(result.system[0].text, "Rule A\nRule B\nRule C"); assert.equal(result.messages[0].role, "user"); assert.deepEqual(result.messages[0].content, [{ type: "text", text: "Hello" }]); assert.equal(result.messages[1].role, "assistant"); @@ -220,8 +219,8 @@ test("OpenAI -> Claude maps tool_choice and injects response_format instructions ); assert.deepEqual(schemaResult.tool_choice, { type: "any" }); - assert.match(schemaResult.system[1].text, /strictly follows this JSON schema/i); - assert.match(schemaResult.system[1].text, /"answer"/); + assert.match(schemaResult.system[0].text, /strictly follows this JSON schema/i); + assert.match(schemaResult.system[0].text, /"answer"/); const jsonObjectResult = openaiToClaudeRequest( "claude-4-sonnet", @@ -234,7 +233,7 @@ test("OpenAI -> Claude maps tool_choice and injects response_format instructions ); assert.deepEqual(jsonObjectResult.tool_choice, { type: "tool", name: "emit_json" }); - assert.match(jsonObjectResult.system[1].text, /Respond ONLY with a JSON object/i); + assert.match(jsonObjectResult.system[0].text, /Respond ONLY with a JSON object/i); }); test("OpenAI -> Claude turns reasoning settings into thinking budgets and expands max tokens", () => { diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index 843444e3d7..afac00bd68 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -659,33 +659,28 @@ test("OpenAI -> Antigravity uses the Claude bridge for Claude-family models", () assert.equal(result.project, "proj-claude"); assert.equal(result.userAgent, "antigravity"); - assert.equal((result as any).request?.systemInstruction.role, "system"); - assert.equal( - (result as any).request?.systemInstruction.parts[0].text, - ANTIGRAVITY_DEFAULT_SYSTEM - ); - assert.equal((result as any).request?.systemInstruction.parts[1].text, "Project rules"); - assert.equal((result as any).request?.generationConfig.maxOutputTokens, 16384); - assert.equal((result as any).request?.generationConfig.temperature, 1); - assert.equal((result as any).request?.generationConfig.thinkingConfig, undefined); + assert.ok((result as any).request?.system.includes(ANTIGRAVITY_DEFAULT_SYSTEM)); + assert.ok((result as any).request?.system.includes("Project rules")); + assert.equal((result as any).request?.max_tokens, 16384); - const modelTurn = result.request.contents.find( - (content) => content.role === "model" && content.parts.some((part) => part.functionCall) + const modelTurn = result.request.messages.find( + (msg) => msg.role === "assistant" && msg.content.some((block) => block.type === "tool_use") ); assert.ok(modelTurn, "expected a Claude-bridged model turn"); - const bridgeFunctionCall = getFunctionCall(modelTurn.parts[0]); + const bridgeFunctionCall = modelTurn.content.find((block) => block.type === "tool_use"); assert.equal(bridgeFunctionCall.name, "read_file"); - assert.deepEqual(bridgeFunctionCall.args, { path: "/tmp/demo" }); + assert.deepEqual(bridgeFunctionCall.input, { path: "/tmp/demo" }); - const toolTurn = result.request.contents.find( - (content) => content.role === "user" && content.parts.some((part) => part.functionResponse) + const toolTurn = result.request.messages.find( + (msg) => msg.role === "user" && msg.content.some((block) => block.type === "tool_result") ); assert.ok(toolTurn, "expected a Claude-bridged tool response turn"); - assert.equal(getFunctionResponse(toolTurn.parts[0]).id, "call_1"); - assert.equal((result as any).request?.tools[0].functionDeclarations[0].name, "read_file"); + const toolResultBlock = toolTurn.content.find((block) => block.type === "tool_result"); + assert.equal(toolResultBlock.tool_use_id, "call_1"); + assert.equal((result as any).request?.tools[0].name, "read_file"); }); -test("OpenAI -> Antigravity Claude bridge sanitizes long names and preserves restore map", () => { +test("OpenAI -> Antigravity Claude bridge preserves tool names (Claude supports longer names)", () => { const longToolName = "ns:mcp__filesystem__read_multiple_files_with_validation_and_metadata_bundle"; const result = openaiToAntigravityRequest( @@ -727,26 +722,26 @@ test("OpenAI -> Antigravity Claude bridge sanitizes long names and preserves res { projectId: "proj-claude-map" } as any ); - const sanitizedToolName = (result as any).request?.tools[0].functionDeclarations[0].name; - assert.equal(sanitizedToolName.length, 64); - assert.match(sanitizedToolName, /^[a-zA-Z0-9_]+$/); - assert.equal((result as any)._toolNameMap.get(sanitizedToolName), longToolName); + const sanitizedToolName = (result as any).request?.tools[0].name; + assert.equal(sanitizedToolName, longToolName); - const modelTurn = result.request.contents.find( - (content) => content.role === "model" && content.parts.some((part) => part.functionCall) + const modelTurn = result.request.messages.find( + (msg) => msg.role === "assistant" && msg.content.some((block) => block.type === "tool_use") ); assert.ok(modelTurn, "expected a model turn"); - assert.equal(getFunctionCall(modelTurn.parts[0]).name, sanitizedToolName); + const toolUseBlock = modelTurn.content.find((block) => block.type === "tool_use"); + assert.equal(toolUseBlock.name, sanitizedToolName); - const toolTurn = result.request.contents.find( - (content) => content.role === "user" && content.parts.some((part) => part.functionResponse) + const toolTurn = result.request.messages.find( + (msg) => msg.role === "user" && msg.content.some((block) => block.type === "tool_result") ); assert.ok(toolTurn, "expected a tool response turn"); - assert.equal(getFunctionResponse(toolTurn.parts[0]).name, sanitizedToolName); - assert.deepEqual(getFunctionResponse(toolTurn.parts[0]).response, { result: { ok: true } }); + const toolResultBlock = toolTurn.content.find((block) => block.type === "tool_result"); + assert.equal(toolResultBlock.tool_use_id, "call_long_2"); + assert.ok(toolResultBlock.content.includes("ok")); }); -test("OpenAI -> Antigravity Claude bridge applies Antigravity output cap without forwarding thinking", () => { +test("OpenAI -> Antigravity Claude bridge applies Antigravity output cap but forwards thinking", () => { const result = openaiToAntigravityRequest( "claude-3-7-sonnet", { @@ -758,8 +753,8 @@ test("OpenAI -> Antigravity Claude bridge applies Antigravity output cap without { projectId: "proj-claude-thinking" } as any ); - assert.equal((result as any).request?.generationConfig.maxOutputTokens, 16384); - assert.equal((result as any).request?.generationConfig.thinkingConfig, undefined); + assert.equal((result as any).request?.max_tokens, 16384); + assert.deepEqual((result as any).request?.thinking, { type: "enabled", budget_tokens: 131072 }); }); test("OpenAI -> Antigravity Claude bridge preserves lower requested output despite reasoning effort", () => { @@ -774,6 +769,6 @@ test("OpenAI -> Antigravity Claude bridge preserves lower requested output despi { projectId: "proj-claude-short" } as any ); - assert.equal((result as any).request?.generationConfig.maxOutputTokens, 1000); - assert.equal((result as any).request?.generationConfig.thinkingConfig, undefined); + assert.equal((result as any).request?.max_tokens, 1000); + assert.deepEqual((result as any).request?.thinking, { type: "enabled", budget_tokens: 131072 }); }); diff --git a/tests/unit/translator-openai-to-kiro.test.ts b/tests/unit/translator-openai-to-kiro.test.ts index 5bdc8ad443..e058989e56 100644 --- a/tests/unit/translator-openai-to-kiro.test.ts +++ b/tests/unit/translator-openai-to-kiro.test.ts @@ -99,12 +99,12 @@ test("OpenAI -> Kiro preserves prior history, tool uses and accumulated tool res assert.equal((context.toolResults as any).length, 2); assert.deepEqual(context.toolResults[0], { toolUseId: "call_1", - status: "success", + status: "SUCCESS", content: [{ text: "file contents" }], }); assert.deepEqual(context.toolResults[1], { toolUseId: "call_1", - status: "success", + status: "SUCCESS", content: [{ text: "done" }], }); assert.equal(context.tools[0].toolSpecification.name, "read_file"); diff --git a/tests/unit/usage-analytics-route.test.ts b/tests/unit/usage-analytics-route.test.ts index 97127672d7..66935d5819 100644 --- a/tests/unit/usage-analytics-route.test.ts +++ b/tests/unit/usage-analytics-route.test.ts @@ -132,6 +132,74 @@ test("GET /api/usage/analytics includes byModel array with cost calculations", a assert.ok(gptEntry.cost > 0); }); +test("GET /api/usage/analytics resolves Codex GPT-5.5 pricing through provider aliases", async () => { + const db = core.getDbInstance(); + db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run("codex", "gpt-5.5", "codex-conn", 1000, 500, 1, 250, new Date().toISOString()); + + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + assert.equal(response.status, 200); + assertClose(body.summary.totalCost, 0.02); + assert.equal(body.byProvider[0].provider, "codex"); + assertClose(body.byProvider[0].cost, 0.02); + assert.equal(body.byModel[0].model, "gpt-5.5"); + assertClose(body.byModel[0].cost, 0.02); +}); + +test("GET /api/usage/analytics maps Codex auto-review usage to GPT-5.5 pricing", async () => { + const db = core.getDbInstance(); + db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + "codex", + "codex-auto-review", + "codex-conn", + 1000, + 500, + 1, + 250, + new Date().toISOString() + ); + + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + assert.equal(response.status, 200); + assertClose(body.summary.totalCost, 0.02); + assert.equal(body.byModel[0].model, "codex-auto-review"); + assertClose(body.byModel[0].cost, 0.02); +}); + +test("GET /api/usage/analytics ignores normal combo routing in fallback statistics", async () => { + const db = core.getDbInstance(); + const timestamp = new Date().toISOString(); + db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run("codex", "gpt-5.5", "codex-conn", 1000, 500, 1, 250, timestamp); + db.prepare( + `INSERT INTO call_logs (id, provider, model, requested_model, combo_name, connection_id, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run("combo-call", "codex", "gpt-5.5", "combo/dev", "dev", "codex-conn", timestamp); + db.prepare( + `INSERT INTO call_logs (id, provider, model, requested_model, connection_id, timestamp) + VALUES (?, ?, ?, ?, ?, ?)` + ).run("same-model-call", "codex", "GPT-5.5", "gpt-5.5", "codex-conn", timestamp); + + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.summary.fallbackCount, 0); + assert.equal(body.summary.fallbackRatePct, 0); + assert.equal(body.summary.requestedModelCoveragePct, 100); +}); + test("GET /api/usage/analytics filters by range parameter", async () => { await seedAnalyticsData(); diff --git a/vitest.config.ts b/vitest.config.ts index 097ca504ba..8fb8eb8c6e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,8 +9,10 @@ export default defineConfig({ include: [ "src/app/**/dashboard/cache/__tests__/**/*.test.tsx", "src/app/**/dashboard/endpoint/__tests__/**/*.test.tsx", + "src/shared/hooks/__tests__/**/*.test.tsx", "src/lib/memory/__tests__/**/*.test.ts", "src/lib/skills/__tests__/**/*.test.ts", + "tests/unit/encryption.test.ts", "open-sse/**/__tests__/**/*.test.ts", "open-sse/services/**/__tests__/**/*.test.ts", "tests/e2e/ecosystem.test.ts", diff --git a/vitest.mcp.config.ts b/vitest.mcp.config.ts index 69939121b6..da4324af0b 100644 --- a/vitest.mcp.config.ts +++ b/vitest.mcp.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ include: [ "open-sse/mcp-server/__tests__/**/*.test.ts", "open-sse/services/autoCombo/__tests__/**/*.test.ts", + "tests/unit/encryption.spec.ts", ], exclude: ["**/node_modules/**", "**/.git/**"], coverage: {