Merge pull request #1959 from diegosouzapw/release/v3.7.9

Release v3.7.9
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-05-05 15:09:51 -03:00
committed by GitHub
224 changed files with 14241 additions and 1522 deletions

View File

@@ -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-<NUMBER>`).
2. Push your branch to the main repo (`git push origin fix-pr-<NUMBER>`).
3. Create a Pull Request targeting the contributor's repository and branch (use `gh pr create --repo <contributor-repo> --base <contributor-branch> --head diegosouzapw:fix-pr-<NUMBER>`).
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 <email>"` 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 <NUMBER> --repo <owner>/<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 <NUMBER> --repo <owner>/<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

View File

@@ -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

View File

@@ -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).

View File

@@ -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.`;

BIN
docs-overview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

518
docs/RFC-AUTO-ASSESSMENT.md Normal file
View File

@@ -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:<id>" | "model:<id>", "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?

View File

@@ -131,11 +131,7 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
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<string, ImageProviderConfig> = {
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"],
},

View File

@@ -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<string, RegistryEntry> = {
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<string, RegistryEntry> = {
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<string, RegistryEntry> = {
{ 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<string, RegistryEntry> = {
{ 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 ───────────────────────────────────────────────────

View File

@@ -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;

View File

@@ -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";

View File

@@ -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<string, unknown>;
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) {

View File

@@ -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<string, number>)
: 0;
return {
success: true,
@@ -1435,7 +1438,9 @@ export async function handleChatCore({
const cachedUsage =
extractUsageFromResponse(cached as Record<string, unknown>, provider) ||
((cached as Record<string, unknown>)?.usage as Record<string, unknown> | undefined);
const cachedCost = cachedUsage ? await calculateCost(provider, model, cachedUsage) : 0;
const cachedCost = cachedUsage
? await calculateCost(provider, model, cachedUsage as Record<string, number>)
: 0;
persistAttemptLogs({
status: 200,
tokens: (cached as Record<string, unknown>)?.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 ??

View File

@@ -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;
}
}
}

View File

@@ -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,

View File

@@ -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();

View File

@@ -20,7 +20,7 @@ import { randomUUID } from "node:crypto";
let clientPromise: Promise<unknown> | 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

View File

@@ -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))

View File

@@ -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
);

View File

@@ -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

View File

@@ -139,12 +139,18 @@ export function getNextFamilyFallback(
currentModel: string,
triedModels: Set<string>
): 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}`)];
}
/**

View File

@@ -1,4 +1,5 @@
type JsonRecord = Record<string, unknown>;
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[] {

View File

@@ -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) => {

View File

@@ -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<string, string>();
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<string, string> = {};
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;
}

View File

@@ -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];

View File

@@ -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,

1408
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"

10
recent_issues.jsonl Normal file

File diff suppressed because one or more lines are too long

View File

@@ -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) {

View File

@@ -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`);

View File

@@ -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");

View File

@@ -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));

View File

@@ -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()));

View File

@@ -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()));

View File

@@ -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) {

View File

@@ -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 () => {

View File

@@ -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(

View File

@@ -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`;

View File

@@ -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 () => {

View File

@@ -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`;
}

View File

@@ -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() {
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<MetricCard
label={t("spendToday")}
value={currencyFormatter.format(presetCosts["1d"] || 0)}
value={formatCurrencyCost(locale, presetCosts["1d"] || 0)}
loading={summaryLoading}
color="text-emerald-400"
/>
<MetricCard
label={t("spend7d")}
value={currencyFormatter.format(presetCosts["7d"] || 0)}
value={formatCurrencyCost(locale, presetCosts["7d"] || 0)}
loading={summaryLoading}
color="text-sky-400"
/>
<MetricCard
label={t("spend30d")}
value={currencyFormatter.format(presetCosts["30d"] || 0)}
value={formatCurrencyCost(locale, presetCosts["30d"] || 0)}
loading={summaryLoading}
color="text-violet-400"
/>
<MetricCard
label={t("selectedWindow")}
value={currencyFormatter.format(summary.totalCost || 0)}
value={formatCurrencyCost(locale, summary.totalCost || 0)}
subValue={selectedRangeLabel}
color="text-amber-400"
/>
@@ -438,7 +461,7 @@ export default function CostOverviewTab() {
/>
<CompactMetric
label={t("avgCostPerRequest")}
value={currencyFormatter.format(avgCostPerRequest)}
value={formatCurrencyCost(locale, avgCostPerRequest)}
/>
</div>
</Card>
@@ -621,7 +644,7 @@ export default function CostOverviewTab() {
</div>
)}
{summary.totalCost <= 0 ? (
{summary.totalCost <= 0 && summary.totalRequests <= 0 ? (
<Card className="p-6">
<EmptyState
icon="payments"
@@ -631,14 +654,20 @@ export default function CostOverviewTab() {
</Card>
) : (
<>
<div className="grid grid-cols-1 xl:grid-cols-[1.4fr_1fr] gap-4">
<CostTrendCard
title={t("costTrend")}
rows={analytics?.dailyTrend || []}
locale={locale}
/>
<ProviderSpendCard title={t("providerShare")} rows={providersByCost} locale={locale} />
</div>
{hasCostData && (
<div className="grid grid-cols-1 xl:grid-cols-[1.4fr_1fr] gap-4">
<CostTrendCard
title={t("costTrend")}
rows={analytics?.dailyTrend || []}
locale={locale}
/>
<ProviderSpendCard
title={t("providerShare")}
rows={providersByCost}
locale={locale}
/>
</div>
)}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<TopListCard
@@ -649,6 +678,7 @@ export default function CostOverviewTab() {
secondaryLabel={t("tokens")}
rows={providersByCost}
locale={locale}
hasCostData={hasCostData}
/>
<TopListCard
title={t("topModels")}
@@ -658,6 +688,7 @@ export default function CostOverviewTab() {
secondaryLabel={t("tokens")}
rows={modelsByCost}
locale={locale}
hasCostData={hasCostData}
/>
</div>
@@ -1026,14 +1057,16 @@ function TopListCard({
secondaryKey,
secondaryLabel,
locale,
hasCostData,
}: {
title: string;
rows: Array<Record<string, string | number>>;
rows: Array<Record<string, any>>;
nameKey: string;
valueKey: string;
secondaryKey?: string;
secondaryLabel?: string;
locale: string;
hasCostData?: boolean;
}) {
const currencyFormatter = createCurrencyFormatter(locale);
@@ -1059,7 +1092,11 @@ function TopListCard({
</span>
) : null}
<span className="text-sm font-mono text-text-muted">
{currencyFormatter.format(Number(row[valueKey] || 0))}
{hasCostData || Number(row[valueKey] || 0) > 0 ? (
currencyFormatter.format(Number(row[valueKey] || 0))
) : (
<span className="text-xs italic opacity-70">Legacy / Free</span>
)}
</span>
</div>
</div>
@@ -1083,7 +1120,7 @@ function CostBreakdownTable({
locale,
}: {
title: string;
rows: Array<Record<string, string | number | null>>;
rows: Array<Record<string, any>>;
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":

View File

@@ -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<CatalogData | null>(null);
const [catalogError, setCatalogError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@@ -536,7 +538,7 @@ export default function ApiEndpointsTab() {
Example
</p>
<code className="text-[11px] font-mono text-text-main break-all">
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

View File

@@ -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 (
<div className="flex flex-col gap-8">

View File

@@ -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`
);
});
});

View File

@@ -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("");

View File

@@ -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<CacheConfig>({
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 (
<Card className="p-6">
<p className="text-sm text-text-muted">{t("loading")}</p>
</Card>
);
}
return (
<Card className="p-6">
<h3 className="text-lg font-semibold text-text-main flex items-center gap-2 mb-4">
<span className="material-symbols-outlined text-[20px]">cached</span>
{t("cacheSettings")}
</h3>
<div className="space-y-6">
{/* Semantic Cache */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-text-main">{t("semanticCache")}</h4>
<label className="flex items-center justify-between">
<span className="text-sm text-text-muted">{t("enabled")}</span>
<button
onClick={() =>
setConfig((c) => ({ ...c, semanticCacheEnabled: !c.semanticCacheEnabled }))
}
className={`relative w-10 h-5 rounded-full transition-colors ${
config.semanticCacheEnabled ? "bg-green-500" : "bg-border"
}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
config.semanticCacheEnabled ? "left-5" : "left-0.5"
}`}
/>
</button>
</label>
<label className="flex items-center justify-between">
<span className="text-sm text-text-muted">{t("maxEntries")}</span>
<input
type="number"
min={1}
max={1000}
value={config.semanticCacheMaxSize}
onChange={(e) =>
setConfig((c) => ({ ...c, semanticCacheMaxSize: parseInt(e.target.value) || 100 }))
}
className="w-24 px-2 py-1 text-sm rounded border border-border bg-surface text-text-main"
/>
</label>
<label className="flex items-center justify-between">
<span className="text-sm text-text-muted">{t("ttlMinutes")}</span>
<input
type="number"
min={1}
max={1440}
value={Math.round(config.semanticCacheTTL / 60000)}
onChange={(e) =>
setConfig((c) => ({
...c,
semanticCacheTTL: (parseInt(e.target.value) || 30) * 60000,
}))
}
className="w-24 px-2 py-1 text-sm rounded border border-border bg-surface text-text-main"
/>
</label>
</div>
{/* Prompt Cache */}
<div className="space-y-3 pt-4 border-t border-border/30">
<h4 className="text-sm font-medium text-text-main">{t("promptCache")}</h4>
<label className="flex items-center justify-between">
<span className="text-sm text-text-muted">{t("enabled")}</span>
<button
onClick={() =>
setConfig((c) => ({ ...c, promptCacheEnabled: !c.promptCacheEnabled }))
}
className={`relative w-10 h-5 rounded-full transition-colors ${
config.promptCacheEnabled ? "bg-green-500" : "bg-border"
}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
config.promptCacheEnabled ? "left-5" : "left-0.5"
}`}
/>
</button>
</label>
<label className="flex items-center justify-between">
<span className="text-sm text-text-muted">{t("strategy")}</span>
<select
value={config.promptCacheStrategy}
onChange={(e) =>
setConfig((c) => ({
...c,
promptCacheStrategy: e.target.value as CacheConfig["promptCacheStrategy"],
}))
}
className="px-2 py-1 text-sm rounded border border-border bg-surface text-text-main"
>
<option value="auto">Auto</option>
<option value="system-only">System Only</option>
<option value="manual">Manual</option>
</select>
</label>
<label className="flex items-center justify-between">
<span className="text-sm text-text-muted">{t("preserveClientCache")}</span>
<select
value={config.alwaysPreserveClientCache}
onChange={(e) =>
setConfig((c) => ({
...c,
alwaysPreserveClientCache: e.target
.value as CacheConfig["alwaysPreserveClientCache"],
}))
}
className="px-2 py-1 text-sm rounded border border-border bg-surface text-text-main"
>
<option value="auto">Auto</option>
<option value="always">Always</option>
<option value="never">Never</option>
</select>
</label>
</div>
{/* Save */}
<div className="pt-4 border-t border-border/30">
<Button onClick={handleSave} disabled={saving} size="sm">
{saving ? t("saving") : t("save")}
</Button>
</div>
</div>
</Card>
);
}

View File

@@ -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() {
</div>
</div>
</Card>
<RequestLimitsTab />
</div>
<ProxyConfigModal

View File

@@ -0,0 +1,167 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Card } from "@/shared/components";
import {
DEFAULT_REQUEST_BODY_LIMIT_MB,
MAX_REQUEST_BODY_LIMIT_MB,
MIN_REQUEST_BODY_LIMIT_MB,
} from "@/shared/constants/bodySize";
import { useTranslations } from "next-intl";
type Message = { type: "success" | "error"; text: string };
interface SettingsResponse {
maxBodySizeMb?: number;
[key: string]: unknown;
}
function normalizeInputValue(value: unknown): string {
return typeof value === "number" && Number.isFinite(value)
? String(value)
: String(DEFAULT_REQUEST_BODY_LIMIT_MB);
}
export default function RequestLimitsTab() {
const t = useTranslations("settings");
const [value, setValue] = useState(String(DEFAULT_REQUEST_BODY_LIMIT_MB));
const [savedValue, setSavedValue] = useState(String(DEFAULT_REQUEST_BODY_LIMIT_MB));
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<Message | null>(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<SettingsResponse>;
})
.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 (
<Card className="p-6 mt-4">
<div className="flex flex-col gap-3">
<div>
<p className="font-medium">{t("requestBodyLimitTitle")}</p>
<p className="text-sm text-text-muted mt-1">{t("requestBodyLimitDescription")}</p>
</div>
<div className="flex items-center gap-3">
<label htmlFor="request-body-limit-mb" className="sr-only">
{t("requestBodyLimitInputLabel")}
</label>
<input
id="request-body-limit-mb"
type="number"
min={MIN_REQUEST_BODY_LIMIT_MB}
max={MAX_REQUEST_BODY_LIMIT_MB}
step={1}
value={value}
onChange={(event) => {
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}
/>
<span className="text-xs text-text-muted">MB</span>
<Button
size="sm"
variant="primary"
disabled={loading || Boolean(validationError) || !dirty}
onClick={saveLimit}
>
{saving ? t("requestBodyLimitSaving") : t("requestBodyLimitSave")}
</Button>
{dirty && (
<span className="text-xs text-text-muted">
{t("requestBodyLimitCurrent", { value: savedValue })}
</span>
)}
</div>
{validationError && <p className="text-xs text-red-500">{validationError}</p>}
{message && (
<p
className={`text-xs ${
message.type === "success"
? "text-green-600 dark:text-green-400"
: "text-red-600 dark:text-red-400"
}`}
>
{message.text}
</p>
)}
</div>
</Card>
);
}

View File

@@ -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() {
</Link>
<VisionBridgeSettingsTab />
<SystemPromptTab />
<CacheSettingsTab />
<MemorySkillsTab />
<ModelsDevSyncTab />
</div>

110
src/app/api/assess/route.ts Normal file
View File

@@ -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<Array<{ providerId: string; modelId: string }>> {
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<Array<{ providerId: string; modelId: string }>> {
void providerId;
return getAllModels();
}

View File

@@ -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 });
}

View File

@@ -949,6 +949,11 @@ export async function GET(
});
};
if (provider === "reka") {
const localCatalog = buildLocalCatalogResponse();
if (localCatalog) return localCatalog;
}
if (
isOpenAICompatibleProvider(provider) ||
isLocalOpenAIStyleProvider(provider) ||

View File

@@ -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 });
}
}

View File

@@ -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);
}

View File

@@ -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 }
);
}
}

View File

@@ -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(),

View File

@@ -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({

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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<string, unknown>)) {
if (key.includes(lowerModel) || lowerModel.includes(key)) {
pricing = val;
break;
}
}
if (!pricing) {
const keys = Object.keys(providerPricing as Record<string, unknown>);
if (keys.length > 0) pricing = (providerPricing as Record<string, unknown>)[keys[0]];
}
}
return pricing as Record<string, unknown> | 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

View File

@@ -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) {

View File

@@ -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<string, unknown> =>
@@ -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(
{

View File

@@ -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<string, string> = {
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) =>
`<div class="mermaid-diagram-fallback my-6" data-mermaid="${encodeURIComponent(code.trim())}">${code.trim()}</div>`
);
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<Metadata> {
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 (
<div className="text-red-500 p-4 border border-red-200 bg-red-50 rounded-lg">
<h2 className="text-xl font-bold mb-2">Error Loading Documentation</h2>
<p>Failed to load {item.fileName}. Please try again later.</p>
<p className="text-sm mt-2 text-gray-600">Error: {loadError}</p>
</div>
);
}
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 (
<>
<DocsPageAnalytics slug={slug} title={pageTitle} section={sectionTitle} />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
/>
<div className="flex gap-8">
<div className="flex-1 min-w-0">
<nav className="mb-6" aria-label="Breadcrumb">
<ol className="flex items-center gap-2 text-sm text-text-muted">
<li>
<Link href="/docs" className="hover:text-text-main">
Docs
</Link>
</li>
<li className='before:content-["&gt;"] before:mx-2'>{sectionTitle}</li>
<li className='before:content-["&gt;"] before:mx-2'>{pageTitle}</li>
</ol>
</nav>
<div className="flex items-center gap-3 mb-6">
<h1 className="text-3xl font-bold text-text-main">{pageTitle}</h1>
{version && (
<span className="px-2 py-0.5 text-xs font-mono bg-primary/10 text-primary border border-primary/20 rounded">
v{version}
</span>
)}
</div>
{lastUpdated && (
<p className="text-xs text-text-muted mb-4">Last updated: {lastUpdated}</p>
)}
<DocsLazyWrapper>
<div className="prose-content" dangerouslySetInnerHTML={{ __html: htmlContent }} />
</DocsLazyWrapper>
{mermaidCharts.length > 0 && (
<DocsLazyWrapper>
<MermaidChartsClient charts={mermaidCharts} />
</DocsLazyWrapper>
)}
<DocCodeBlocks />
<FeedbackWidget slug={slug} />
<div className="flex items-center justify-between border-t border-border pt-6 mt-12">
{prevItem ? (
<Link
href={`/docs/${prev}`}
className="flex items-center gap-2 text-sm text-text-muted hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-sm">arrow_back</span>
{prevItem.item.title}
</Link>
) : (
<div />
)}
{nextItem ? (
<Link
href={`/docs/${next}`}
className="flex items-center gap-2 text-sm text-text-muted hover:text-primary transition-colors"
>
{nextItem.item.title}
<span className="material-symbols-outlined text-sm">arrow_forward</span>
</Link>
) : (
<div />
)}
</div>
</div>
{headings.length > 0 && (
<aside className="hidden xl:block w-56 shrink-0">
<div className="sticky top-8">
<h4 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">
On this page
</h4>
<nav className="space-y-1">
{headings.map((heading) => (
<a
key={heading.id}
href={`#${heading.id}`}
className={`block text-sm text-text-muted hover:text-primary transition-colors truncate
${heading.level === 3 ? "pl-3" : ""}
${heading.level === 4 ? "pl-6" : ""}`}
>
{heading.text}
</a>
))}
</nav>
</div>
</aside>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,20 @@
import { Metadata } from "next";
import { ApiExplorerClient } from "../components/ApiExplorerClient";
export const metadata: Metadata = {
title: "API Explorer — OmniRoute Docs",
description: "Interactive API explorer — try OmniRoute endpoints live with real-time responses",
};
export default function ApiExplorerPage() {
return (
<div>
<h1 className="text-3xl font-bold text-text-main mb-2">API Explorer</h1>
<p className="text-text-muted mb-8">
Try OmniRoute endpoints live. Select an endpoint, configure your request, and see the
response in real time.
</p>
<ApiExplorerClient />
</div>
);
}

View File

@@ -0,0 +1,298 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
interface ApiEndpoint {
method: string;
path: string;
description: string;
tag: string;
}
const API_ENDPOINTS: ApiEndpoint[] = [
{
method: "POST",
path: "/v1/chat/completions",
description: "OpenAI-compatible chat completions with streaming support",
tag: "Chat",
},
{
method: "POST",
path: "/v1/responses",
description: "OpenAI Responses API format",
tag: "Responses",
},
{
method: "GET",
path: "/v1/models",
description: "List available models across all providers",
tag: "Models",
},
{
method: "POST",
path: "/v1/embeddings",
description: "Generate text embeddings",
tag: "Embeddings",
},
{
method: "POST",
path: "/v1/images/generations",
description: "Generate images from text prompts",
tag: "Images",
},
{
method: "POST",
path: "/v1/audio/transcriptions",
description: "Transcribe audio files",
tag: "Audio",
},
{
method: "POST",
path: "/v1/audio/speech",
description: "Text-to-speech generation",
tag: "Audio",
},
{
method: "POST",
path: "/v1/moderations",
description: "Content moderation check",
tag: "Moderations",
},
{
method: "POST",
path: "/v1/rerank",
description: "Re-rank documents by relevance",
tag: "Rerank",
},
{
method: "POST",
path: "/v1/search",
description: "Web search across 5 providers",
tag: "Search",
},
{
method: "POST",
path: "/v1/videos/generations",
description: "Generate videos from prompts",
tag: "Video",
},
{
method: "POST",
path: "/v1/music/generations",
description: "Generate music from prompts",
tag: "Music",
},
];
const METHOD_COLORS: Record<string, string> = {
GET: "bg-emerald-500/10 text-emerald-600 border-emerald-500/20",
POST: "bg-blue-500/10 text-blue-600 border-blue-500/20",
PUT: "bg-amber-500/10 text-amber-600 border-amber-500/20",
DELETE: "bg-red-500/10 text-red-600 border-red-500/20",
PATCH: "bg-purple-500/10 text-purple-600 border-purple-500/20",
};
const EXAMPLE_BODIES: Record<string, string> = {
"/v1/chat/completions": JSON.stringify(
{ model: "openai/gpt-4o-mini", messages: [{ role: "user", content: "Hello!" }], stream: true },
null,
2
),
"/v1/models": "",
"/v1/embeddings": JSON.stringify(
{ model: "openai/text-embedding-3-small", input: "Hello world" },
null,
2
),
"/v1/images/generations": JSON.stringify(
{ model: "openai/dall-e-3", prompt: "A sunset over mountains", n: 1 },
null,
2
),
"/v1/responses": JSON.stringify(
{ model: "openai/gpt-4o-mini", input: "What is OmniRoute?" },
null,
2
),
};
export function ApiExplorerClient() {
const [selected, setSelected] = useState<ApiEndpoint | null>(null);
const [baseUrl, setBaseUrl] = useState("http://localhost:20128");
const [apiKey, setApiKey] = useState("");
const [requestBody, setRequestBody] = useState("");
const [response, setResponse] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [filterTag, setFilterTag] = useState<string | null>(null);
const allTags = [...new Set(API_ENDPOINTS.map((e) => e.tag))];
const filteredEndpoints = filterTag
? API_ENDPOINTS.filter((e) => e.tag === filterTag)
: API_ENDPOINTS;
const handleSelect = useCallback((endpoint: ApiEndpoint) => {
setSelected(endpoint);
setResponse(null);
const example = EXAMPLE_BODIES[endpoint.path] || "";
setRequestBody(example);
}, []);
const handleTryIt = async () => {
if (!selected) return;
setLoading(true);
setResponse(null);
try {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const opts: RequestInit = { method: selected.method, headers };
if (selected.method !== "GET" && requestBody.trim()) {
opts.body = requestBody;
}
const res = await fetch(`${baseUrl}${selected.path}`, opts);
const contentType = res.headers.get("content-type") || "";
if (contentType.includes("text/event-stream")) {
setResponse("SSE stream started — check the terminal/devtools for real-time output.");
} else {
const data = await res.json();
setResponse(JSON.stringify(data, null, 2));
}
} catch (err) {
setResponse(`Error: ${err instanceof Error ? err.message : "Request failed"}`);
} finally {
setLoading(false);
}
};
return (
<div className="flex flex-col lg:flex-row gap-6">
<div className="lg:w-72 shrink-0">
<div className="sticky top-4">
<div className="flex flex-wrap gap-1.5 mb-4">
<button
onClick={() => setFilterTag(null)}
className={`px-2.5 py-1 text-xs rounded-full border transition-colors
${!filterTag ? "bg-primary/10 text-primary border-primary/20" : "border-border text-text-muted hover:text-text-main"}`}
>
All
</button>
{allTags.map((tag) => (
<button
key={tag}
onClick={() => setFilterTag(filterTag === tag ? null : tag)}
className={`px-2.5 py-1 text-xs rounded-full border transition-colors
${filterTag === tag ? "bg-primary/10 text-primary border-primary/20" : "border-border text-text-muted hover:text-text-main"}`}
>
{tag}
</button>
))}
</div>
<div className="space-y-1 max-h-96 overflow-y-auto">
{filteredEndpoints.map((endpoint) => (
<button
key={`${endpoint.method}-${endpoint.path}`}
onClick={() => handleSelect(endpoint)}
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors
${
selected?.path === endpoint.path && selected?.method === endpoint.method
? "bg-primary/10 border border-primary/20"
: "hover:bg-bg-subtle border border-transparent"
}`}
>
<div className="flex items-center gap-2">
<span
className={`px-1.5 py-0.5 text-[10px] font-mono font-bold rounded border ${METHOD_COLORS[endpoint.method] || "border-border"}`}
>
{endpoint.method}
</span>
<span className="truncate text-text-muted font-mono text-xs">
{endpoint.path}
</span>
</div>
</button>
))}
</div>
</div>
</div>
<div className="flex-1 min-w-0">
{selected ? (
<div className="space-y-4">
<div className="flex items-center gap-3">
<span
className={`px-2 py-1 text-xs font-mono font-bold rounded border ${METHOD_COLORS[selected.method]}`}
>
{selected.method}
</span>
<span className="font-mono text-sm text-text-main">{selected.path}</span>
</div>
<p className="text-sm text-text-muted">{selected.description}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="text-xs text-text-muted block mb-1">Base URL</label>
<input
type="text"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
className="w-full px-3 py-2 text-sm bg-bg-subtle border border-border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label className="text-xs text-text-muted block mb-1">API Key</label>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
className="w-full px-3 py-2 text-sm bg-bg-subtle border border-border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
</div>
{selected.method !== "GET" && (
<div>
<label className="text-xs text-text-muted block mb-1">Request Body</label>
<textarea
value={requestBody}
onChange={(e) => setRequestBody(e.target.value)}
rows={8}
className="w-full px-3 py-2 text-sm font-mono bg-bg-subtle border border-border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
)}
<button
onClick={handleTryIt}
disabled={loading}
className="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{loading ? "Sending..." : "Send Request"}
</button>
{response !== null && (
<div>
<label className="text-xs text-text-muted block mb-1">Response</label>
<pre className="bg-bg-subtle p-4 rounded-lg overflow-x-auto text-xs font-mono text-text-main max-h-80">
{response}
</pre>
</div>
)}
</div>
) : (
<div className="text-center py-16 text-text-muted">
<span className="material-symbols-outlined text-4xl mb-2 block">api</span>
<p className="text-lg font-medium">Select an endpoint to explore</p>
<p className="text-sm mt-1">
Choose an API from the sidebar to see details and try it live
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import React, { useState, useRef } from "react";
export function CodeBlockCopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
} catch {
const textarea = document.createElement("textarea");
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
setCopied(true);
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
}
};
return (
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 bg-bg border border-border rounded-md
hover:bg-bg-subtle transition-colors opacity-0 group-hover:opacity-100"
aria-label={copied ? "Copied" : "Copy code"}
>
<span className="material-symbols-outlined text-sm text-text-muted">
{copied ? "check" : "content_copy"}
</span>
</button>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
export function DocCodeBlocks() {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = containerRef.current?.parentElement;
if (!container) return;
const pres = container.querySelectorAll("pre");
pres.forEach((pre) => {
if (pre.parentElement?.querySelector(".copy-btn")) return;
pre.parentElement?.classList.add("relative");
const btn = document.createElement("button");
btn.className = "copy-btn absolute top-2 right-2 p-1.5 bg-bg border border-border rounded-md hover:bg-bg-subtle transition-colors opacity-0 group-hover:opacity-100";
btn.setAttribute("aria-label", "Copy code");
btn.innerHTML = '<span class="material-symbols-outlined text-sm text-text-muted">content_copy</span>';
btn.addEventListener("click", async () => {
const code = pre.querySelector("code")?.textContent || pre.textContent || "";
try {
await navigator.clipboard.writeText(code);
} catch {
const textarea = document.createElement("textarea");
textarea.value = code;
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
btn.innerHTML = '<span class="material-symbols-outlined text-sm text-primary">check</span>';
btn.setAttribute("aria-label", "Copied");
setTimeout(() => {
btn.innerHTML = '<span class="material-symbols-outlined text-sm text-text-muted">content_copy</span>';
btn.setAttribute("aria-label", "Copy code");
}, 2000);
});
pre.parentElement?.appendChild(btn);
});
}, []);
return <div ref={containerRef} className="hidden" />;
}

View File

@@ -0,0 +1,200 @@
"use client";
import { useMemo, useState } from "react";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
type Locale = "en" | "pt-BR" | "es" | "fr" | "de" | "ja" | "zh-CN" | "ko" | "ru" | "ar";
const SECTION_LABELS: Record<Locale, Record<string, string>> = {
en: {
"Getting Started": "Getting Started",
Features: "Features",
"API & Protocols": "API & Protocols",
Deployment: "Deployment",
Operations: "Operations",
Development: "Development",
},
"pt-BR": {
"Getting Started": "Primeiros Passos",
Features: "Recursos",
"API & Protocols": "API & Protocolos",
Deployment: "Implantação",
Operations: "Operações",
Development: "Desenvolvimento",
},
es: {
"Getting Started": "Primeros Pasos",
Features: "Características",
"API & Protocols": "API y Protocolos",
Deployment: "Despliegue",
Operations: "Operaciones",
Development: "Desarrollo",
},
fr: {
"Getting Started": "Démarrage",
Features: "Fonctionnalités",
"API & Protocols": "API et Protocoles",
Deployment: "Déploiement",
Operations: "Opérations",
Development: "Développement",
},
de: {
"Getting Started": "Erste Schritte",
Features: "Funktionen",
"API & Protocols": "API & Protokolle",
Deployment: "Bereitstellung",
Operations: "Betrieb",
Development: "Entwicklung",
},
ja: {
"Getting Started": "はじめに",
Features: "機能",
"API & Protocols": "APIとプロトコル",
Deployment: "デプロイ",
Operations: "運用",
Development: "開発",
},
"zh-CN": {
"Getting Started": "快速开始",
Features: "功能",
"API & Protocols": "API与协议",
Deployment: "部署",
Operations: "运维",
Development: "开发",
},
ko: {
"Getting Started": "시작하기",
Features: "기능",
"API & Protocols": "API 및 프로토콜",
Deployment: "배포",
Operations: "운영",
Development: "개발",
},
ru: {
"Getting Started": "Начало работы",
Features: "Возможности",
"API & Protocols": "API и Протоколы",
Deployment: "Развертывание",
Operations: "Эксплуатация",
Development: "Разработка",
},
ar: {
"Getting Started": "البدء",
Features: "الميزات",
"API & Protocols": "API والبروتوكولات",
Deployment: "النشر",
Operations: "العمليات",
Development: "التطوير",
},
};
function detectLocale(): Locale {
if (typeof navigator === "undefined") return "en";
const lang = navigator.language;
if (lang.startsWith("pt-BR")) return "pt-BR";
if (lang.startsWith("es")) return "es";
if (lang.startsWith("fr")) return "fr";
if (lang.startsWith("de")) return "de";
if (lang.startsWith("ja")) return "ja";
if (lang.startsWith("zh")) return "zh-CN";
if (lang.startsWith("ko")) return "ko";
if (lang.startsWith("ru")) return "ru";
if (lang.startsWith("ar")) return "ar";
return "en";
}
export function useDocsLocale() {
const searchParams = useSearchParams();
return useMemo<Locale>(() => {
const langParam = searchParams.get("lang");
if (langParam && langParam in SECTION_LABELS) return langParam as Locale;
return detectLocale();
}, [searchParams]);
}
export function useLocalizedSectionTitle(englishTitle: string): string {
const locale = useDocsLocale();
return SECTION_LABELS[locale]?.[englishTitle] ?? englishTitle;
}
export function getAvailableLocales(): Locale[] {
return Object.keys(SECTION_LABELS) as Locale[];
}
export const LOCALE_NAMES: Record<Locale, string> = {
en: "English",
"pt-BR": "Português (Brasil)",
es: "Español",
fr: "Français",
de: "Deutsch",
ja: "日本語",
"zh-CN": "简体中文",
ko: "한국어",
ru: "Русский",
ar: "العربية",
};
const COMMON_LOCALES: Locale[] = ["en", "pt-BR", "es", "fr", "de", "ja", "zh-CN", "ko", "ru", "ar"];
export function DocsLocaleSwitcher() {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
const currentLocale = useMemo<Locale>(() => {
const langParam = searchParams.get("lang");
if (langParam && langParam in SECTION_LABELS) return langParam as Locale;
if (typeof navigator !== "undefined") {
const navLocale = detectLocale();
if (navLocale in SECTION_LABELS) return navLocale;
}
return "en";
}, [searchParams]);
const handleLocaleChange = (locale: Locale) => {
setIsOpen(false);
const params = new URLSearchParams(searchParams.toString());
if (locale === "en") {
params.delete("lang");
} else {
params.set("lang", locale);
}
const queryString = params.toString();
router.push(`${pathname}${queryString ? `?${queryString}` : ""}`);
};
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-1 px-2 py-1 text-xs border border-border rounded hover:border-primary/50 transition-colors text-text-muted hover:text-text-main"
aria-label="Change documentation language"
aria-expanded={isOpen}
aria-haspopup="listbox"
>
<span className="material-symbols-outlined text-sm">language</span>
{LOCALE_NAMES[currentLocale]}
</button>
{isOpen && (
<div
className="absolute right-0 top-full mt-1 bg-bg border border-border rounded-lg shadow-lg z-50 py-1 min-w-[160px]"
role="listbox"
>
{COMMON_LOCALES.map((locale) => (
<button
key={locale}
onClick={() => handleLocaleChange(locale)}
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-primary/5 transition-colors ${
locale === currentLocale ? "text-primary font-semibold" : "text-text-muted"
}`}
role="option"
aria-selected={locale === currentLocale}
>
{LOCALE_NAMES[locale]}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { Suspense } from "react";
export function DocsLazyWrapper({ children }: { children: React.ReactNode }) {
return <Suspense fallback={<DocsSkeleton />}>{children}</Suspense>;
}
function DocsSkeleton() {
return (
<div className="animate-pulse space-y-6" aria-label="Loading documentation" role="status">
<div className="h-8 bg-bg-subtle rounded w-3/4" />
<div className="h-4 bg-bg-subtle rounded w-1/4 mt-2" />
<div className="space-y-3 mt-6">
<div className="h-4 bg-bg-subtle rounded w-full" />
<div className="h-4 bg-bg-subtle rounded w-5/6" />
<div className="h-4 bg-bg-subtle rounded w-4/6" />
</div>
<div className="h-32 bg-bg-subtle rounded w-full mt-4" />
<div className="space-y-3 mt-4">
<div className="h-4 bg-bg-subtle rounded w-full" />
<div className="h-4 bg-bg-subtle rounded w-3/4" />
</div>
</div>
);
}

View File

@@ -0,0 +1,94 @@
"use client";
import { useEffect, useCallback, useRef } from "react";
interface PageAnalyticsProps {
slug: string;
title: string;
section: string;
}
const STORAGE_KEY = "omniroute_docs_analytics";
const MAX_EVENTS = 200;
interface AnalyticsEvent {
slug: string;
title: string;
section: string;
timestamp: number;
referrer: string;
}
function getEvents(): AnalyticsEvent[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
return JSON.parse(raw);
} catch {
return [];
}
}
function pruneEvents(events: AnalyticsEvent[]): AnalyticsEvent[] {
return events.slice(-MAX_EVENTS);
}
export function DocsPageAnalytics({ slug, title, section }: PageAnalyticsProps) {
const trackedRef = useRef(false);
const trackPageView = useCallback(() => {
if (trackedRef.current) return;
trackedRef.current = true;
try {
const events = getEvents();
events.push({
slug,
title,
section,
timestamp: Date.now(),
referrer: typeof document !== "undefined" ? document.referrer : "",
});
localStorage.setItem(STORAGE_KEY, JSON.stringify(pruneEvents(events)));
} catch {
// localStorage unavailable — silent fail
}
}, [slug, title, section]);
useEffect(() => {
trackPageView();
}, [trackPageView]);
return null; // invisible tracking component
}
export function getPopularPages(
limit = 5
): { slug: string; title: string; section: string; views: number }[] {
if (typeof window === "undefined") return [];
try {
const events = getEvents();
const counts = new Map<string, { title: string; section: string; views: number }>();
for (const event of events) {
const existing = counts.get(event.slug);
if (existing) {
existing.views++;
} else {
counts.set(event.slug, {
slug: event.slug,
title: event.title,
section: event.section,
views: 1,
});
}
}
return Array.from(counts.values())
.sort((a, b) => b.views - a.views)
.slice(0, limit);
} catch {
return [];
}
}

View File

@@ -0,0 +1,175 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link";
import Fuse from "fuse.js";
import { SEARCH_INDEX, SearchItem } from "../lib/searchIndex";
import { docsNavigation } from "../lib/docsNavigation";
const fuseTitles = new Fuse(SEARCH_INDEX, {
keys: [
{ name: "title", weight: 3 },
{ name: "slug", weight: 1 },
],
threshold: 0.3,
includeScore: true,
});
const fuseContent = new Fuse(SEARCH_INDEX, {
keys: [
{ name: "title", weight: 3 },
{ name: "content", weight: 2 },
{ name: "headings", weight: 2 },
],
threshold: 0.4,
includeScore: true,
});
export function DocsSearchClient({ onResultClick }: { onResultClick?: () => void }) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Fuse.FuseResult<SearchItem>[]>([]);
const [isOpen, setIsOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const handleSearch = useCallback((value: string) => {
setQuery(value);
if (value.trim().length === 0) {
setResults([]);
setIsOpen(false);
return;
}
const titleResults = fuseTitles.search(value.trim()).slice(0, 4);
const contentResults = fuseContent.search(value.trim()).slice(0, 4);
const seen = new Set<string>();
const merged: Fuse.FuseResult<SearchItem>[] = [];
for (const r of [...titleResults, ...contentResults]) {
if (!seen.has(r.item.slug)) {
seen.add(r.item.slug);
merged.push(r);
}
}
setResults(merged.slice(0, 8));
setIsOpen(true);
}, []);
const handleSelect = useCallback(() => {
setQuery("");
setResults([]);
setIsOpen(false);
onResultClick?.();
}, [onResultClick]);
const handleClear = useCallback(() => {
setQuery("");
setResults([]);
setIsOpen(false);
inputRef.current?.focus();
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
inputRef.current?.focus();
inputRef.current?.select();
}
if (e.key === "Escape") {
setIsOpen(false);
inputRef.current?.blur();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<div ref={containerRef} className="relative">
<div className="relative">
<span className="material-symbols-outlined absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted text-sm">
search
</span>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => handleSearch(e.target.value)}
onFocus={() => {
if (results.length > 0) setIsOpen(true);
}}
placeholder="Search docs..."
className="w-full pl-8 pr-8 py-2 text-sm bg-bg-subtle border border-border rounded-lg
focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary
placeholder:text-text-muted transition-colors"
aria-label="Search documentation"
/>
{query && (
<button
onClick={handleClear}
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-main transition-colors"
aria-label="Clear search"
>
<span className="material-symbols-outlined text-sm">close</span>
</button>
)}
{!query && (
<kbd className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-text-muted bg-bg border border-border rounded px-1.5 py-0.5 font-mono">
K
</kbd>
)}
</div>
{isOpen && results.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-bg border border-border rounded-lg shadow-lg z-50 overflow-hidden">
<div className="max-h-72 overflow-y-auto">
{results.map((result) => {
const item = result.item;
const section = docsNavigation.find((s) => s.items.some((i) => i.slug === item.slug));
return (
<Link
key={item.slug}
href={`/docs/${item.slug}`}
onClick={handleSelect}
className="flex items-center gap-3 px-3 py-2.5 hover:bg-bg-subtle transition-colors border-b border-border last:border-b-0"
>
<span className="material-symbols-outlined text-text-muted text-sm">article</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-text-main truncate">{item.title}</div>
{section && <div className="text-xs text-text-muted">{section.title}</div>}
{item.content && (
<div className="text-xs text-text-muted truncate mt-0.5">
{item.content.slice(0, 80)}...
</div>
)}
</div>
<span className="material-symbols-outlined text-text-muted text-xs">
arrow_forward
</span>
</Link>
);
})}
</div>
</div>
)}
{isOpen && query.trim().length > 0 && results.length === 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-bg border border-border rounded-lg shadow-lg z-50 p-4">
<p className="text-sm text-text-muted text-center">
No results found for &ldquo;{query}&rdquo;
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,144 @@
"use client";
import React, { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/shared/utils/cn";
import { docsNavigation } from "../lib/docsNavigation";
import { DocsSearchClient } from "./DocsSearchClient";
export function DocsSidebarClient({ mobileOnly = false }: { mobileOnly?: boolean }) {
const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
// Extract slug from pathname (e.g., /docs/setup-guide -> setup-guide)
const currentSlug = pathname.split("/").filter(Boolean).pop() || "";
const isActive = (slug: string) => currentSlug === slug;
if (mobileOnly) {
return (
<div className="lg:hidden">
<button
onClick={() => setIsOpen(!isOpen)}
className="p-2 bg-bg-subtle border border-border rounded-lg hover:bg-bg transition-colors"
aria-label="Toggle Sidebar"
>
<span className="material-symbols-outlined">menu</span>
</button>
{isOpen && (
<div className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm">
<div className="fixed left-0 top-0 h-full w-64 bg-bg border-r border-border overflow-y-auto z-50">
<MobileSidebarContent currentSlug={currentSlug} onClose={() => setIsOpen(false)} />
</div>
</div>
)}
</div>
);
}
return (
<nav
className="flex flex-col h-full w-64 bg-bg border-r border-border"
aria-label="Documentation pages"
>
<div className="p-4 border-b border-border">
<h2 className="font-bold text-text-primary mb-3">OmniRoute Docs</h2>
<DocsSearchClient />
</div>
<div
className="flex-1 overflow-y-auto p-4 space-y-6"
role="list"
aria-label="Documentation sections"
>
{docsNavigation.map((section, sectionIdx) => (
<div key={sectionIdx} className="space-y-2" role="listitem">
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{section.title}
</h3>
<ul className="space-y-1">
{section.items.map((item) => (
<li key={item.slug}>
<Link
href={`/docs/${item.slug}`}
className={cn(
"block rounded-md px-3 py-2 text-sm transition-colors",
"hover:bg-bg-subtle hover:text-text-main",
isActive(item.slug)
? "text-primary font-medium bg-primary/10"
: "text-text-main"
)}
aria-current={isActive(item.slug) ? "page" : undefined}
>
{item.title}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
</nav>
);
}
function MobileSidebarContent({
currentSlug,
onClose,
}: {
currentSlug: string;
onClose: () => void;
}) {
const isActive = (slug: string) => currentSlug === slug;
return (
<div className="flex flex-col h-full">
<div className="p-4 border-b border-border flex items-center justify-between">
<h2 className="font-bold text-text-primary">OmniRoute Docs</h2>
<button
onClick={onClose}
className="p-1 hover:bg-bg-subtle rounded transition-colors"
aria-label="Close Sidebar"
>
<span className="material-symbols-outlined">close</span>
</button>
</div>
<div className="px-4 pt-3 border-b border-border">
<DocsSearchClient onResultClick={onClose} />
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{docsNavigation.map((section, sectionIdx) => (
<div key={sectionIdx} className="space-y-2">
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{section.title}
</h3>
<ul className="space-y-1">
{section.items.map((item) => (
<li key={item.slug}>
<Link
href={`/docs/${item.slug}`}
onClick={onClose}
className={cn(
"block rounded-md px-3 py-2 text-sm transition-colors",
"hover:bg-bg-subtle hover:text-text-main",
isActive(item.slug)
? "text-primary font-medium bg-primary/10"
: "text-text-main"
)}
aria-current={isActive(item.slug) ? "page" : undefined}
>
{item.title}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import React, { useState } from "react";
export function FeedbackWidget({ slug }: { slug: string }) {
const [feedback, setFeedback] = useState<"yes" | "no" | null>(null);
const [submitted, setSubmitted] = useState(false);
const handleFeedback = (type: "yes" | "no") => {
setFeedback(type);
setSubmitted(true);
try {
const stored = JSON.parse(localStorage.getItem("docs-feedback") || "{}");
stored[slug] = type;
localStorage.setItem("docs-feedback", JSON.stringify(stored));
} catch {}
};
if (submitted) {
return (
<div className="mt-8 p-4 bg-bg-subtle border border-border rounded-lg text-center">
<span className="material-symbols-outlined text-primary text-2xl block mb-1">
check_circle
</span>
<p className="text-sm text-text-main">Thanks for your feedback!</p>
</div>
);
}
return (
<div className="mt-8 p-4 bg-bg-subtle border border-border rounded-lg">
<p className="text-sm text-text-main mb-3">Was this page helpful?</p>
<div className="flex gap-3">
<button
onClick={() => handleFeedback("yes")}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-border rounded-lg hover:border-primary hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-sm">thumb_up</span>
Yes
</button>
<button
onClick={() => handleFeedback("no")}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-border rounded-lg hover:border-red-400 hover:text-red-400 transition-colors"
>
<span className="material-symbols-outlined text-sm">thumb_down</span>
No
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
"use client";
import dynamic from "next/dynamic";
const MermaidDiagram = dynamic(() => import("./MermaidDiagram").then((mod) => mod.MermaidDiagram), {
ssr: false,
loading: () => <div className="my-6 h-32 bg-bg-subtle rounded-lg animate-pulse" />,
});
export function MermaidChartsClient({ charts }: { charts: string[] }) {
if (charts.length === 0) return null;
return (
<div className="mt-6 space-y-4">
{charts.map((chart, i) => (
<MermaidDiagram key={i} chart={chart} />
))}
</div>
);
}

View File

@@ -0,0 +1,59 @@
"use client";
import { useEffect, useRef, useId } from "react";
interface MermaidDiagramProps {
chart: string;
}
export function MermaidDiagram({ chart }: MermaidDiagramProps) {
const containerRef = useRef<HTMLDivElement>(null);
const uniqueId = useId().replace(/:/g, "_");
const renderedRef = useRef(false);
useEffect(() => {
if (renderedRef.current || !containerRef.current) return;
const renderDiagram = async () => {
try {
const mermaid = (await import("mermaid")).default;
mermaid.initialize({
startOnLoad: false,
theme: "default",
securityLevel: "loose",
fontFamily: "inherit",
});
const { svg } = await mermaid.render(`mermaid-${uniqueId}`, chart);
if (containerRef.current) {
containerRef.current.innerHTML = svg;
renderedRef.current = true;
}
} catch (error) {
console.error("Mermaid render error:", error);
if (containerRef.current) {
containerRef.current.innerHTML = `<pre class="text-red-500 text-sm p-2">${escapeHtml(chart)}</pre>`;
}
}
};
renderDiagram();
}, [chart, uniqueId]);
return (
<div
ref={containerRef}
className="mermaid-diagram my-6 flex justify-center overflow-x-auto rounded-lg border border-border bg-bg-subtle p-4"
role="img"
aria-label="Diagram"
/>
);
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}

View File

@@ -0,0 +1,150 @@
"use client";
import { useState, useEffect } from "react";
interface WhatsNewEntry {
version: string;
date: string;
title: string;
description: string;
slug?: string;
type: "feature" | "improvement" | "fix" | "breaking";
}
const WHATS_NEW_ENTRIES: WhatsNewEntry[] = [
{
version: "3.7",
date: "2025-06",
title: "Documentation Site Overhaul",
description:
"Full interactive docs site with search, API explorer, Mermaid diagrams, and accessibility compliance.",
slug: "/docs",
type: "feature",
},
{
version: "3.6",
date: "2025-05",
title: "MCP Server 37 Tools + A2A Protocol",
description:
"Expanded MCP to 37 tools with scoped auth. Added A2A v0.3 agent-to-agent protocol.",
slug: "mcp-server",
type: "feature",
},
{
version: "3.5",
date: "2025-04",
title: "RTK + Caveman Stacked Compression",
description:
"Stacked compression pipeline: RTK → Caveman. Save 78-95% eligible tokens on tool outputs.",
slug: "rtk-compression",
type: "feature",
},
{
version: "3.4",
date: "2025-03",
title: "1proxy Free Proxy Marketplace",
description:
"Built-in free proxy marketplace with quality scoring, auto-rotation, and circuit breaker.",
slug: "proxy-guide",
type: "feature",
},
{
version: "3.3",
date: "2025-02",
title: "DeepSeek V3.2 + GLM-5.1 Added",
description:
"New cheap providers: DeepSeek V3.2 at $0.27/M and GLM-5.1 at $0.5/1M with 128K output.",
slug: "free-tiers",
type: "feature",
},
{
version: "3.2",
date: "2025-01",
title: "Responses API Full Support",
description:
"Complete /v1/responses endpoint for Codex and OpenAI Responses API compatibility.",
slug: "api-reference",
type: "feature",
},
];
const TYPE_BADGES: Record<string, { label: string; color: string }> = {
feature: { label: "Feature", color: "bg-green-500/10 text-green-600 border-green-500/20" },
improvement: { label: "Improvement", color: "bg-blue-500/10 text-blue-600 border-blue-500/20" },
fix: { label: "Fix", color: "bg-yellow-500/10 text-yellow-600 border-yellow-500/20" },
breaking: { label: "Breaking", color: "bg-red-500/10 text-red-600 border-red-500/20" },
};
export function WhatsNewSection() {
const [expanded, setExpanded] = useState(false);
const displayEntries = expanded ? WHATS_NEW_ENTRIES : WHATS_NEW_ENTRIES.slice(0, 3);
return (
<section className="mt-8" aria-labelledby="whats-new-heading">
<h2
id="whats-new-heading"
className="text-xl font-bold text-text-main mb-4 flex items-center gap-2"
>
<span className="material-symbols-outlined text-primary">new_releases</span>
What&apos;s New
</h2>
<div className="space-y-3">
{displayEntries.map((entry) => (
<div
key={`${entry.version}-${entry.date}`}
className="flex items-start gap-3 p-4 border border-border rounded-lg hover:border-primary/50 transition-colors"
>
<div className="shrink-0 mt-0.5">
<span
className={`inline-block px-2 py-0.5 text-xs font-semibold border rounded ${TYPE_BADGES[entry.type]?.color ?? TYPE_BADGES.feature.color}`}
>
{TYPE_BADGES[entry.type]?.label ?? "Feature"}
</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-text-main">{entry.title}</span>
<span className="text-xs font-mono text-primary">v{entry.version}</span>
</div>
<p className="text-sm text-text-muted">{entry.description}</p>
</div>
<span className="shrink-0 text-xs text-text-muted">{entry.date}</span>
</div>
))}
</div>
{WHATS_NEW_ENTRIES.length > 3 && (
<button
onClick={() => setExpanded(!expanded)}
className="mt-3 text-sm text-primary hover:underline"
aria-expanded={expanded}
>
{expanded ? "Show less" : `Show ${WHATS_NEW_ENTRIES.length - 3} more updates`}
</button>
)}
</section>
);
}
export function MigrationGuideBanner({
fromVersion,
toVersion,
}: {
fromVersion: string;
toVersion: string;
}) {
return (
<div className="mb-6 border border-yellow-500/30 bg-yellow-500/5 rounded-lg p-4" role="alert">
<div className="flex items-center gap-2 mb-1">
<span className="material-symbols-outlined text-yellow-600">upgrade</span>
<span className="font-semibold text-yellow-600">
Migration Guide: v{fromVersion} v{toVersion}
</span>
</div>
<p className="text-sm text-text-muted">
This page describes features from v{toVersion}. If you&apos;re upgrading from v{fromVersion}
, check the breaking changes above.
</p>
</div>
);
}

68
src/app/docs/layout.tsx Normal file
View File

@@ -0,0 +1,68 @@
import Link from "next/link";
import { ReactNode, Suspense } from "react";
import { DocsSidebarClient } from "./components/DocsSidebarClient";
import { DocsLocaleSwitcher } from "./components/DocsI18n";
export const metadata = {
title: {
template: "%s — OmniRoute Docs",
default: "OmniRoute Documentation",
},
description:
"Comprehensive documentation for OmniRoute AI gateway — setup, API, compression, deployment, and more.",
robots: {
index: true,
follow: true,
},
};
export default function DocsLayout({ children }: { children: ReactNode }) {
return (
<div className="flex min-h-screen">
<a
href="#docs-main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-white focus:rounded"
>
Skip to main content
</a>
<aside
className="hidden lg:flex lg:w-64 lg:shrink-0 lg:flex-col lg:border-r lg:border-border"
role="navigation"
aria-label="Documentation sidebar"
>
<DocsSidebarClient />
</aside>
<div className="lg:hidden fixed top-4 left-4 z-50">
<DocsSidebarClient mobileOnly={true} />
</div>
<main id="docs-main-content" className="flex-1 overflow-y-auto bg-bg" tabIndex={-1}>
<div className="max-w-6xl mx-auto p-4 sm:px-6 lg:px-8 lg:py-8">
<nav
className="flex items-center justify-between gap-4 mb-6"
aria-label="Docs navigation"
>
<div className="flex items-center gap-4">
<Link href="/docs" className="text-text-muted hover:text-text-main transition-colors">
Back to Docs Overview
</Link>
<Link
href="/dashboard"
className="text-text-muted hover:text-text-main transition-colors"
>
Dashboard
</Link>
</div>
<Suspense fallback={<div className="w-24 h-8" />}>
<DocsLocaleSwitcher />
</Suspense>
</nav>
{children}
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,777 @@
// 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[] = [
{
"title": "Getting Started",
"items": [
{
"slug": "architecture",
"title": "OmniRoute Architecture",
"fileName": "ARCHITECTURE.md"
},
{
"slug": "cli-tools",
"title": "CLI Tools Setup Guide",
"fileName": "CLI-TOOLS.md"
},
{
"slug": "setup-guide",
"title": "Setup Guide",
"fileName": "SETUP_GUIDE.md"
},
{
"slug": "user-guide",
"title": "User Guide",
"fileName": "USER_GUIDE.md"
}
]
},
{
"title": "Features",
"items": [
{
"slug": "auto-combo",
"title": "OmniRoute Auto-Combo Engine",
"fileName": "AUTO-COMBO.md"
},
{
"slug": "compression-engines",
"title": "Compression Engines",
"fileName": "COMPRESSION_ENGINES.md"
},
{
"slug": "compression-guide",
"title": "🗜️ Prompt Compression Guide",
"fileName": "COMPRESSION_GUIDE.md"
},
{
"slug": "compression-language-packs",
"title": "Compression Language Packs",
"fileName": "COMPRESSION_LANGUAGE_PACKS.md"
},
{
"slug": "compression-rules-format",
"title": "Compression Rules Format",
"fileName": "COMPRESSION_RULES_FORMAT.md"
},
{
"slug": "features",
"title": "OmniRoute — Dashboard Features Gallery",
"fileName": "FEATURES.md"
},
{
"slug": "free-tiers",
"title": "🆓 Free LLM API Providers — Consolidated Directory",
"fileName": "FREE_TIERS.md"
},
{
"slug": "rtk-compression",
"title": "RTK Compression",
"fileName": "RTK_COMPRESSION.md"
}
]
},
{
"title": "API & Protocols",
"items": [
{
"slug": "a2a-server",
"title": "OmniRoute A2A Server Documentation",
"fileName": "A2A-SERVER.md"
},
{
"slug": "api-reference",
"title": "API Reference",
"fileName": "API_REFERENCE.md"
},
{
"slug": "mcp-server",
"title": "OmniRoute MCP Server Documentation",
"fileName": "MCP-SERVER.md"
}
]
},
{
"title": "Deployment",
"items": [
{
"slug": "docker-guide",
"title": "🐳 Docker Guide",
"fileName": "DOCKER_GUIDE.md"
},
{
"slug": "fly-io-deployment-guide",
"title": "OmniRoute Fly.io 部署指南",
"fileName": "FLY_IO_DEPLOYMENT_GUIDE.md"
},
{
"slug": "pwa-guide",
"title": "Progressive Web App (PWA) Guide",
"fileName": "PWA_GUIDE.md"
},
{
"slug": "termux-guide",
"title": "Termux Headless Setup",
"fileName": "TERMUX_GUIDE.md"
},
{
"slug": "vm-deployment-guide",
"title": "OmniRoute — Deployment Guide on VM with Cloudflare",
"fileName": "VM_DEPLOYMENT_GUIDE.md"
}
]
},
{
"title": "Operations",
"items": [
{
"slug": "environment",
"title": "Environment Variables Reference",
"fileName": "ENVIRONMENT.md"
},
{
"slug": "proxy-guide",
"title": "OmniRoute Proxy Guide",
"fileName": "PROXY_GUIDE.md"
},
{
"slug": "resilience-guide",
"title": "🛡️ Resilience Guide",
"fileName": "RESILIENCE_GUIDE.md"
},
{
"slug": "troubleshooting",
"title": "Troubleshooting",
"fileName": "TROUBLESHOOTING.md"
}
]
},
{
"title": "Development",
"items": [
{
"slug": "codebase-documentation",
"title": "omniroute — Codebase Documentation",
"fileName": "CODEBASE_DOCUMENTATION.md"
},
{
"slug": "coverage-plan",
"title": "Test Coverage Plan",
"fileName": "COVERAGE_PLAN.md"
},
{
"slug": "i18n",
"title": "i18n — Internationalization Guide",
"fileName": "I18N.md"
},
{
"slug": "release-checklist",
"title": "Release Checklist",
"fileName": "RELEASE_CHECKLIST.md"
},
{
"slug": "uninstall",
"title": "OmniRoute — Uninstall Guide",
"fileName": "UNINSTALL.md"
}
]
}
];
export const autoSearchIndex: AutoGenSearchItem[] = [
{
"slug": "architecture",
"title": "OmniRoute Architecture",
"fileName": "ARCHITECTURE.md",
"section": "Getting Started",
"content": "🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c",
"headings": [
"Executive Summary",
"Scope and Boundaries",
"In Scope",
"Out of Scope",
"Dashboard Surface (Current)",
"High-Level System Context",
"Core Runtime Components",
"1) API and Routing Layer (Next.js App Routes)",
"2) SSE + Translation Core",
"3) Persistence Layer"
]
},
{
"slug": "cli-tools",
"title": "CLI Tools Setup Guide",
"fileName": "CLI-TOOLS.md",
"section": "Getting Started",
"content": "This guide explains how to install and configure all supported AI coding CLI tools to use OmniRoute as the unified backend, giving you centralized key management, cost tracking, model switching, and request logging across every tool. The dashboard cards in /dashboard/cli-tools are generated from src",
"headings": [
"How It Works",
"Supported Tools (Dashboard Source of Truth)",
"CLI fingerprint sync (Agents + Settings)",
"Step 1 — Get an OmniRoute API Key",
"Step 2 — Install CLI Tools",
"Step 3 — Set Global Environment Variables",
"Step 4 — Configure Each Tool",
"Claude Code",
"OpenAI Codex",
"OpenCode"
]
},
{
"slug": "setup-guide",
"title": "Setup Guide",
"fileName": "SETUP_GUIDE.md",
"section": "Getting Started",
"content": "Complete setup reference for OmniRoute. For the quick version, see the Quick Start in README. - Install Methods - CLI Tool Configuration - Protocol Setup (MCP + A2A) - Timeout Configuration - Split-Port Mode - Void Linux (xbps-src) - Uninstalling -------------------- --------------------------------",
"headings": [
"Table of Contents",
"Install Methods",
"npm (recommended)",
"pnpm",
"Arch Linux (AUR)",
"From Source",
"Docker",
"CLI Options",
"CLI Tool Configuration",
"1) Connect Providers and Create API Key"
]
},
{
"slug": "user-guide",
"title": "User Guide",
"fileName": "USER_GUIDE.md",
"section": "Getting Started",
"content": "🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c",
"headings": [
"Table of Contents",
"💰 Pricing at a Glance",
"🎯 Use Cases",
"Case 1: \"I have Claude Pro subscription\"",
"Case 2: \"I want zero cost\"",
"Case 3: \"I need 24/7 coding, no interruptions\"",
"Case 4: \"I want FREE AI in OpenClaw\"",
"📖 Provider Setup",
"🔐 Subscription Providers",
"Claude Code (Pro/Max)"
]
},
{
"slug": "auto-combo",
"title": "OmniRoute Auto-Combo Engine",
"fileName": "AUTO-COMBO.md",
"section": "Features",
"content": "Self-managing model chains with adaptive scoring The Auto-Combo Engine dynamically selects the best provider/model for each request using a 6-factor scoring function: Factor Weight Description :--------- :----- :---------------------------------------------- Quota 0.20 Remaining capacity [0..1] Heal",
"headings": [
"How It Works",
"Mode Packs",
"Self-Healing",
"Bandit Exploration",
"API",
"Task Fitness",
"Files"
]
},
{
"slug": "compression-engines",
"title": "Compression Engines",
"fileName": "COMPRESSION_ENGINES.md",
"section": "Features",
"content": "OmniRoute compression is built around engine contracts. A mode can run one engine directly (caveman or rtk) or a deterministic stacked pipeline that executes multiple engines in order. Mode Engine path Intended input ------------ ---------------------------------- -----------------------------------",
"headings": [
"Modes",
"Engine Registry",
"Caveman",
"RTK",
"Stacked Pipelines",
"Compression Combos",
"API Surface",
"MCP Tools",
"Validation"
]
},
{
"slug": "compression-guide",
"title": "🗜️ Prompt Compression Guide",
"fileName": "COMPRESSION_GUIDE.md",
"section": "Features",
"content": "Save 15-95% on eligible context automatically. For a quick overview, see the README Compression section. OmniRoute implements a modular prompt compression pipeline that runs proactively before requests hit upstream providers. This means your token savings happen transparently — no changes needed to ",
"headings": [
"Overview",
"Compression Modes",
"Off",
"Lite Mode (~15% savings, <1ms latency)",
"Standard Mode (~30% savings)",
"Aggressive Mode (~50% savings)",
"Ultra Mode (~75% savings)",
"RTK Mode (60-90% upstream range)",
"Stacked Mode (78-95% eligible range)",
"Upstream Savings Math"
]
},
{
"slug": "compression-language-packs",
"title": "Compression Language Packs",
"fileName": "COMPRESSION_LANGUAGE_PACKS.md",
"section": "Features",
"content": "Caveman compression can load language-specific rule packs in addition to the built-in English rules. This keeps the core engine stable while allowing Portuguese, Spanish, German, French, Japanese, and future language packs to evolve independently. Language packs live under: Current shipped packs inc",
"headings": [
"Location",
"Language Detection",
"Config Shape",
"Adding a Language Pack",
"API",
"Operational Notes"
]
},
{
"slug": "compression-rules-format",
"title": "Compression Rules Format",
"fileName": "COMPRESSION_RULES_FORMAT.md",
"section": "Features",
"content": "Compression rules are JSON files loaded at runtime. They are intentionally data-only so new language packs and RTK command filters can be reviewed without changing engine code. Caveman rule packs live under: Each pack contains replacements that apply to normal prose after protected regions are isola",
"headings": [
"Caveman Rule Packs",
"Caveman Fields",
"RTK Filter Packs",
"RTK Fields",
"Safety Rules",
"Validation"
]
},
{
"slug": "features",
"title": "OmniRoute — Dashboard Features Gallery",
"fileName": "FEATURES.md",
"section": "Features",
"content": "🌐 Main README translations: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indones",
"headings": [
"🔌 Providers",
"🎨 Combos",
"📊 Analytics",
"🏥 System Health",
"🔧 Translator Playground",
"🎮 Model Playground _(v2.0.9+)_",
"🎨 Themes _(v2.0.5+)_",
"⚙️ Settings",
"🔧 CLI Tools",
"🤖 CLI Agents _(v2.0.11+)_"
]
},
{
"slug": "free-tiers",
"title": "🆓 Free LLM API Providers — Consolidated Directory",
"fileName": "FREE_TIERS.md",
"section": "Features",
"content": "The ultimate aggregated reference for all permanently free LLM API providers. Consolidated from 6 community repositories. Use with OmniRoute to route through 25+ free providers simultaneously. Last consolidated: May 2026 · Sources: awesome-free-llm-apis, awesome-free-llm-apis2, free-llm-api-resource",
"headings": [
"Table of Contents",
"Quick Comparison",
"Provider APIs (First-Party)",
"Google Gemini 🇺🇸",
"Mistral AI 🇫🇷",
"Cohere 🇨🇦",
"Z.AI (Zhipu AI) 🇨🇳",
"IBM watsonx 🇺🇸",
"Inference Providers (Third-Party)",
"Groq 🇺🇸"
]
},
{
"slug": "rtk-compression",
"title": "RTK Compression",
"fileName": "RTK_COMPRESSION.md",
"section": "Features",
"content": "RTK compression is OmniRoute's command-aware compression engine for terminal and tool output. It is designed for coding-agent sessions where most context growth comes from test logs, build output, package manager noise, shell transcripts, Docker output, git output, and stack traces. RTK can run dire",
"headings": [
"What It Compresses",
"Filter Resolution",
"Filter DSL",
"Configuration",
"API",
"Raw Output Recovery",
"Verify Gate",
"Extending RTK"
]
},
{
"slug": "a2a-server",
"title": "OmniRoute A2A Server Documentation",
"fileName": "A2A-SERVER.md",
"section": "API & Protocols",
"content": "Agent-to-Agent Protocol v0.3 — OmniRoute as an intelligent routing agent Returns the Agent Card describing OmniRoute's capabilities, skills, and authentication requirements. Sends a message to a skill and waits for the complete response. Response: Same as message/send but returns Server-Sent Events ",
"headings": [
"Agent Discovery",
"Authentication",
"Enablement",
"JSON-RPC 2.0 Methods",
"message/send — Synchronous Execution",
"message/stream — SSE Streaming",
"tasks/get — Query Task Status",
"tasks/cancel — Cancel a Task",
"Available Skills",
"Task Lifecycle"
]
},
{
"slug": "api-reference",
"title": "API Reference",
"fileName": "API_REFERENCE.md",
"section": "API & Protocols",
"content": "🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c",
"headings": [
"Table of Contents",
"Chat Completions",
"Custom Headers",
"Embeddings",
"Image Generation",
"List Models",
"Compatibility Endpoints",
"Dedicated Provider Routes",
"Semantic Cache",
"Dashboard & Management"
]
},
{
"slug": "mcp-server",
"title": "OmniRoute MCP Server Documentation",
"fileName": "MCP-SERVER.md",
"section": "API & Protocols",
"content": "Model Context Protocol server with 37 tools across routing, cache, compression, memory, skills, and proxy operations OmniRoute MCP is built-in. Start it with: Or via the open-sse transport: See MCP Client Configuration for Claude Desktop, Cursor, Cline, and compatible MCP client setup. -------------",
"headings": [
"Installation",
"IDE Configuration",
"Essential Tools (8)",
"Advanced Tools (8)",
"Cache Tools (2)",
"Compression Tools (5)",
"Other Tool Groups",
"Authentication",
"Audit Logging",
"Files"
]
},
{
"slug": "docker-guide",
"title": "🐳 Docker Guide",
"fileName": "DOCKER_GUIDE.md",
"section": "Deployment",
"content": "Complete Docker deployment reference. For a quick start, see the README Docker section. - Quick Run - With Environment File - Docker Compose - Docker Compose with Caddy (HTTPS) - Cloudflare Quick Tunnel - Image Tags - Important Notes --------------------- -------- ------ --------------------- diegos",
"headings": [
"Table of Contents",
"Quick Run",
"With Environment File",
"Docker Compose",
"Docker Compose with Caddy (HTTPS Auto-TLS)",
"Cloudflare Quick Tunnel",
"Tunnel Notes",
"Image Tags",
"Important Notes",
"See Also"
]
},
{
"slug": "fly-io-deployment-guide",
"title": "OmniRoute Fly.io 部署指南",
"fileName": "FLY_IO_DEPLOYMENT_GUIDE.md",
"section": "Deployment",
"content": "本文档记录 OmniRoute 在 Fly.io 上的实际部署方法,适用于两类场景: - 首次把当前项目部署到 Fly.io - 后续代码更新后继续发布 - 新项目参考同样流程部署 本文基于当前项目已经验证通过的配置整理,应用名为 omniroute。 当前仓库中的 fly.toml 已确认包含以下关键项: 说明: - app = 'omniroute' 决定实际部署到哪个 Fly 应用 - destination = '/data' 决定持久卷挂载目录 - 本项目必须让 DATADIR=/data否则数据库和密钥会写到容器临时目录 --- Windows PowerShell 如果安装脚",
"headings": [
"1. 部署目标",
"2. 当前项目关键配置",
"3. 必备工具",
"3.1 安装 Fly CLI",
"3.2 登录 Fly 账号",
"3.3 检查登录状态",
"4. 首次部署当前项目",
"4.1 获取代码并进入目录",
"4.2 确认应用名",
"4.3 创建应用"
]
},
{
"slug": "pwa-guide",
"title": "Progressive Web App (PWA) Guide",
"fileName": "PWA_GUIDE.md",
"section": "Deployment",
"content": "OmniRoute ships as a fully installable Progressive Web App. When you access the dashboard from any mobile browser — Android (Chrome) or iOS (Safari) — you can \"Add to Home Screen\" and get a native app-like experience with no app store required. A Progressive Web App turns the OmniRoute web dashboard",
"headings": [
"What Is a PWA?",
"Installation",
"Android (Chrome)",
"iOS (Safari)",
"Desktop (Chrome / Edge)",
"Features",
"Fullscreen Experience",
"Offline Support",
"Offline Page",
"App Icons"
]
},
{
"slug": "termux-guide",
"title": "Termux Headless Setup",
"fileName": "TERMUX_GUIDE.md",
"section": "Deployment",
"content": "OmniRoute can run as a headless server on Android through Termux. The Electron desktop app is not supported in Termux, but the web dashboard and OpenAI-compatible API work from the local browser or from other devices on the same network. Install Termux from F-Droid or GitHub releases, then update pa",
"headings": [
"Prerequisites",
"Install",
"Run",
"Background Execution",
"Access From Other Devices",
"Data Directory",
"Limitations",
"Troubleshooting",
"better-sqlite3 Build Errors",
"Port Already In Use"
]
},
{
"slug": "vm-deployment-guide",
"title": "OmniRoute — Deployment Guide on VM with Cloudflare",
"fileName": "VM_DEPLOYMENT_GUIDE.md",
"section": "Deployment",
"content": "🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c",
"headings": [
"Prerequisites",
"1. Configure the VM",
"1.1 Create the instance",
"1.2 Connect via SSH",
"1.3 Update the system",
"1.4 Install Docker",
"1.5 Install nginx",
"1.6 Configure Firewall (UFW)",
"2. Install OmniRoute",
"2.1 Create configuration directory"
]
},
{
"slug": "environment",
"title": "Environment Variables Reference",
"fileName": "ENVIRONMENT.md",
"section": "Operations",
"content": "Complete reference for every environment variable recognized by OmniRoute. For a quick-start template, see .env.example. These must be set before the first run. Without them, the application will either refuse to start or operate with insecure defaults. Variable Required Default Source File Descript",
"headings": [
"Table of Contents",
"1. Required Secrets",
"Generation Commands",
"2. Storage & Database",
"Scenarios",
"3. Network & Ports",
"Port Modes",
"4. Security & Authentication",
"Hardening Checklist",
"5. Input Sanitization & PII Protection"
]
},
{
"slug": "proxy-guide",
"title": "OmniRoute Proxy Guide",
"fileName": "PROXY_GUIDE.md",
"section": "Operations",
"content": "Bypass geographic blocks, protect your identity, and route AI traffic through any proxy — with zero configuration complexity. OmniRoute includes a full-featured proxy management system that lets you route upstream AI provider traffic through HTTP, HTTPS, or SOCKS5 proxies. Whether you're in a blocke",
"headings": [
"Table of Contents",
"Why Use Proxies?",
"Architecture Overview",
"Key Components",
"3-Level Proxy System",
"How Resolution Works",
"What Gets Proxied",
"Proxy Registry (CRUD)",
"Creating a Proxy",
"Updating a Proxy"
]
},
{
"slug": "resilience-guide",
"title": "🛡️ Resilience Guide",
"fileName": "RESILIENCE_GUIDE.md",
"section": "Operations",
"content": "How OmniRoute keeps your AI coding workflow running when providers fail. OmniRoute implements a multi-layered resilience system that ensures zero downtime: ------------ ------- ------------------------------------ Queue Size 10 Max queued requests per connection Pacing Interval 0ms Minimum gap betwe",
"headings": [
"Overview",
"Request Queue & Pacing",
"Connection Cooldown",
"Circuit Breaker",
"Wait For Cooldown",
"Anti-Thundering Herd",
"Combo Fallback Chains",
"13 Routing Strategies",
"TLS Fingerprint Spoofing",
"Health Dashboard"
]
},
{
"slug": "troubleshooting",
"title": "Troubleshooting",
"fileName": "TROUBLESHOOTING.md",
"section": "Operations",
"content": "🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c",
"headings": [
"Quick Fixes",
"Node.js Compatibility",
"Login page crashes or shows \"Module self-registration\" error",
"macOS: dlopen / \"slice is not valid mach-o file\"",
"Proxy Issues",
"Provider validation shows \"fetch failed\"",
"Token health check fails with \"fetch failed\"",
"SOCKS5 proxy returns \"invalid onRequestStart method\"",
"Provider Issues",
"\"Language model did not provide messages\""
]
},
{
"slug": "codebase-documentation",
"title": "omniroute — Codebase Documentation",
"fileName": "CODEBASE_DOCUMENTATION.md",
"section": "Development",
"content": "🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c",
"headings": [
"1. What Is omniroute?",
"2. Architecture Overview",
"Core Principle: Hub-and-Spoke Translation",
"3. Project Structure",
"4. Module-by-Module Breakdown",
"4.1 Config (open-sse/config/)",
"Credential Loading Flow",
"4.2 Executors (open-sse/executors/)",
"4.3 Handlers (open-sse/handlers/)",
"Request Lifecycle (chatCore.ts)"
]
},
{
"slug": "coverage-plan",
"title": "Test Coverage Plan",
"fileName": "COVERAGE_PLAN.md",
"section": "Development",
"content": "Last updated: 2026-03-28 There are multiple coverage numbers depending on how the report is computed. For planning, only one of them is useful. Metric Scope Statements / Lines Branches Functions Notes -------------------- ----------------------------------------------------- -----------------: -----",
"headings": [
"Baseline",
"Rules",
"Current command set",
"Milestones",
"Priority hotspots",
"Execution checklist",
"Phase 1: 56.95% -> 60%",
"Phase 2: 60% -> 65%",
"Phase 3: 65% -> 70%",
"Phase 4: 70% -> 75%"
]
},
{
"slug": "i18n",
"title": "i18n — Internationalization Guide",
"fileName": "I18N.md",
"section": "Development",
"content": "OmniRoute supports 30 languages with full dashboard UI translation, translated documentation, and RTL support for Arabic and Hebrew. 🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇩🇪 Deutsch 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇯🇵 日本語 🇰🇷 한국어 🇸🇦 العربية 🇮🇳 ",
"headings": [
"Quick Reference",
"Architecture",
"Source of Truth",
"Runtime Flow",
"Supported Locales",
"Adding a New Language",
"1. Register the Locale",
"2. Add to Generator",
"3. Generate Initial Translation",
"4. Review & Fix Auto-Translations"
]
},
{
"slug": "release-checklist",
"title": "Release Checklist",
"fileName": "RELEASE_CHECKLIST.md",
"section": "Development",
"content": "Use this checklist before tagging or publishing a new OmniRoute release. 1. Bump package.json version (x.y.z) in the release branch. 2. Move release notes from [Unreleased] in CHANGELOG.md to a dated section: - [x.y.z] — YYYY-MM-DD 3. Keep [Unreleased] as the first changelog section for upcoming wor",
"headings": [
"Version and Changelog",
"API Docs",
"Runtime Docs",
"Automated Check"
]
},
{
"slug": "uninstall",
"title": "OmniRoute — Uninstall Guide",
"fileName": "UNINSTALL.md",
"section": "Development",
"content": "🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c",
"headings": [
"Quick Uninstall (v3.6.2+)",
"Keep Your Data",
"Full Removal",
"Manual Uninstall",
"NPM Global Install",
"pnpm Global Install",
"Docker",
"Docker Compose",
"Electron Desktop App",
"Source Install (git clone)"
]
},
{
"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"
]
}
];
export const autoAllSlugs: string[] = [
"architecture",
"cli-tools",
"setup-guide",
"user-guide",
"auto-combo",
"compression-engines",
"compression-guide",
"compression-language-packs",
"compression-rules-format",
"features",
"free-tiers",
"rtk-compression",
"a2a-server",
"api-reference",
"mcp-server",
"docker-guide",
"fly-io-deployment-guide",
"pwa-guide",
"termux-guide",
"vm-deployment-guide",
"environment",
"proxy-guide",
"resilience-guide",
"troubleshooting",
"codebase-documentation",
"coverage-plan",
"i18n",
"release-checklist",
"uninstall"
];

View File

@@ -0,0 +1,36 @@
import { autoNavSections } from "./docs-auto-generated";
export interface DocNavItem {
slug: string;
title: string;
fileName: string;
}
export interface DocNavSection {
title: string;
items: DocNavItem[];
}
const MANUAL_OVERRIDES: Record<string, Partial<DocNavItem>> = {
"setup-guide": { title: "Setup Guide" },
"user-guide": { title: "User Guide" },
"cli-tools": { title: "CLI Tools" },
"compression-rules-format": { title: "Rules Format" },
"compression-language-packs": { title: "Language Packs" },
"vm-deployment-guide": { title: "VM Deployment" },
"fly-io-deployment-guide": { title: "Fly.io Deployment" },
"codebase-documentation": { title: "Codebase Docs" },
"release-checklist": { title: "Release Checklist" },
};
export const docsNavigation: DocNavSection[] = autoNavSections.map((section) => ({
title: section.title,
items: section.items.map((item) => {
const override = MANUAL_OVERRIDES[item.slug];
return {
slug: item.slug,
title: (override?.title ?? item.title) as string,
fileName: item.fileName,
};
}),
}));

View File

@@ -0,0 +1,12 @@
import { autoSearchIndex } from "./docs-auto-generated";
export interface SearchItem {
slug: string;
title: string;
fileName: string;
section: string;
content: string;
headings: string[];
}
export const SEARCH_INDEX: SearchItem[] = autoSearchIndex as SearchItem[];

View File

@@ -1,610 +1,107 @@
import Link from "next/link";
import { useTranslations } from "next-intl";
import { APP_CONFIG } from "@/shared/constants/config";
import { FREE_PROVIDERS, OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/providers";
import {
DOCS_DEPLOYMENT_GUIDES,
DOCS_ENDPOINT_ROWS,
DOCS_FEATURE_ITEMS,
DOCS_MANAGEMENT_ENDPOINT_ROWS,
DOCS_MCP_TOOL_GROUPS,
DOCS_TOC_ITEMS,
DOCS_TROUBLESHOOTING_KEYS,
DOCS_USE_CASE_ITEMS,
} from "./content";
import { Metadata } from "next";
import { docsNavigation } from "./lib/docsNavigation";
import { WhatsNewSection } from "./components/WhatsNewSection";
function ProviderTable({
title,
providers,
colorDot,
maxHeight,
}: {
title: string;
providers: Record<string, any>;
colorDot: string;
maxHeight?: string;
}) {
const t = useTranslations("docs");
const entries = Object.values(providers) as any[];
const isLargeList = entries.length > 20;
export const metadata: Metadata = {
title: "OmniRoute Documentation",
description:
"Everything you need to route, compress, and scale your AI — setup guides, API reference, compression, deployment, and more.",
openGraph: {
title: "OmniRoute Documentation",
description:
"Comprehensive docs for OmniRoute AI gateway — setup, API, compression, deployment, and more.",
type: "website",
url: "https://omniroute.online/docs",
},
twitter: {
card: "summary_large_image",
title: "OmniRoute Documentation",
description: "Comprehensive docs for OmniRoute AI gateway",
},
};
const featuredLinks = [
{
slug: "setup-guide",
title: "Setup Guide",
icon: "rocket_launch",
desc: "Get OmniRoute running in 3 minutes",
},
{
slug: "api-reference",
title: "API Reference",
icon: "code",
desc: "All endpoints with examples",
},
{
slug: "compression-guide",
title: "Compression Guide",
icon: "compress",
desc: "Save 15-95% eligible tokens automatically",
},
];
export default function DocsHomePage() {
return (
<div className="rounded-lg border border-border bg-bg p-4">
<div className="flex items-center gap-2 mb-3">
<span className={`size-2.5 rounded-full ${colorDot}`} />
<h3 className="font-semibold">{title}</h3>
<span className="text-xs text-text-muted ml-auto">
{t("providersCount", { count: entries.length })}
</span>
<div className="max-w-4xl mx-auto">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-text-main mb-4">OmniRoute Documentation</h1>
<p className="text-lg text-text-muted mb-6">
Everything you need to route, compress, and scale your AI
</p>
<p className="text-sm text-text-muted">
Press{" "}
<kbd className="px-1.5 py-0.5 bg-bg-subtle border border-border rounded font-mono text-xs">
K
</kbd>{" "}
to search the docs
</p>
</div>
<div
className={`${isLargeList ? "grid grid-cols-2 sm:grid-cols-3 gap-1.5" : "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1"} text-sm`}
style={maxHeight ? { maxHeight, overflowY: "auto" } : undefined}
>
{entries.map((p) => (
<div
key={p.id}
className={`flex items-center ${isLargeList ? "gap-1.5 py-1 px-2 rounded-md bg-bg-subtle border border-border/30" : "justify-between py-1.5 border-b border-border/40 last:border-0"}`}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-12">
{featuredLinks.map((link) => (
<Link
key={link.slug}
href={`/docs/${link.slug}`}
className="flex flex-col items-center text-center p-6 bg-bg-subtle border border-border rounded-xl
hover:border-primary hover:bg-primary/5 transition-all group"
>
<code className="text-xs text-text-muted shrink-0">{p.alias}/</code>
<span className={`${isLargeList ? "text-xs truncate" : "font-medium"}`}>{p.name}</span>
<span className="material-symbols-outlined text-3xl text-primary mb-3">
{link.icon}
</span>
<span className="font-semibold text-text-main group-hover:text-primary transition-colors">
{link.title}
</span>
<span className="text-sm text-text-muted mt-1">{link.desc}</span>
</Link>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{docsNavigation.map((section, sectionIdx) => (
<div
key={sectionIdx}
className="border border-border rounded-xl p-6 hover:border-primary/50 transition-colors"
>
<h2 className="text-lg font-bold text-text-main mb-4">{section.title}</h2>
<ul className="space-y-2">
{section.items.map((item) => (
<li key={item.slug}>
<Link
href={`/docs/${item.slug}`}
className="text-sm text-text-muted hover:text-primary hover:underline transition-colors"
>
{item.title}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
</div>
);
}
export default function DocsPage() {
const t = useTranslations("docs");
const totalProviders =
Object.keys(FREE_PROVIDERS).length +
Object.keys(OAUTH_PROVIDERS).length +
Object.keys(APIKEY_PROVIDERS).length;
const endpointRows = DOCS_ENDPOINT_ROWS.map((row) => ({
...row,
note: t(row.noteKey),
}));
const managementEndpointRows = DOCS_MANAGEMENT_ENDPOINT_ROWS.map((row) => ({
...row,
note: t(row.noteKey),
}));
const featureItems = DOCS_FEATURE_ITEMS.map((item) => ({
...item,
title: t(item.titleKey),
text: t(item.textKey),
}));
const useCases = DOCS_USE_CASE_ITEMS.map((item) => ({
...item,
title: t(item.titleKey),
text: t(item.textKey),
}));
const deploymentGuides = DOCS_DEPLOYMENT_GUIDES.map((item) => ({
...item,
title: t(item.titleKey),
text: t(item.textKey),
}));
const troubleshootingItems = DOCS_TROUBLESHOOTING_KEYS.map((key) => t(key));
const tocItems = DOCS_TOC_ITEMS.map((item) => ({ ...item, label: t(item.labelKey) }));
const mcpToolGroups = DOCS_MCP_TOOL_GROUPS.map((group) => ({
...group,
title: t(group.titleKey),
text: t(group.textKey),
}));
const totalMcpTools = DOCS_MCP_TOOL_GROUPS.reduce((sum, group) => sum + group.tools.length, 0);
const providerPrefixRows = [
...Object.values(FREE_PROVIDERS).map((p) => ({ ...p, type: "free" as const })),
...Object.values(OAUTH_PROVIDERS).map((p) => ({ ...p, type: "oauth" as const })),
...Object.values(APIKEY_PROVIDERS).map((p) => ({ ...p, type: "apiKey" as const })),
];
const getProviderTypeLabel = (type: "free" | "oauth" | "apiKey") => {
if (type === "free") return t("providerTypeFree");
if (type === "oauth") return t("providerTypeOAuth");
return t("providerTypeApiKey");
};
return (
<div className="min-h-screen bg-bg text-text-main">
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8 py-10 md:py-14 flex flex-col gap-8">
<header className="rounded-2xl border border-border bg-bg-subtle p-6 md:p-8">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs uppercase tracking-wider text-text-muted">
{t("documentationVersion", { version: APP_CONFIG.version })}
</p>
<h1 className="text-3xl md:text-4xl font-bold mt-1">
{APP_CONFIG.name} {t("docsLabel")}
</h1>
<p className="text-sm md:text-base text-text-muted mt-2 max-w-3xl">
{t("docsHeroDescription")}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Link
href="/dashboard"
className="px-3 py-2 rounded-lg border border-border text-sm hover:bg-bg transition-colors"
>
{t("openDashboard")}
</Link>
<Link
href="/dashboard/endpoint"
className="px-3 py-2 rounded-lg border border-border text-sm hover:bg-bg transition-colors"
>
{t("endpointPage")}
</Link>
<a
href="https://github.com/diegosouzapw/OmniRoute"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-2 rounded-lg border border-border text-sm hover:bg-bg transition-colors flex items-center gap-1"
>
{t("github")}{" "}
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
</a>
<a
href="https://github.com/diegosouzapw/OmniRoute/issues"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-2 rounded-lg border border-border text-sm hover:bg-bg transition-colors"
>
{t("reportIssue")}
</a>
</div>
</div>
</header>
<nav className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-sm font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("onThisPage")}
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm">
{tocItems.map((item) => (
<a
key={item.href}
href={item.href}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-border hover:bg-bg transition-colors"
>
<span className="material-symbols-outlined text-[14px] text-text-muted">tag</span>
{item.label}
</a>
))}
</div>
</nav>
<section id="quick-start" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">{t("quickStart")}</h2>
<ol className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<li className="rounded-lg border border-border p-3 bg-bg">
<span className="font-semibold">{t("quickStartStep1Title")}</span>
<p className="text-text-muted mt-1">
{t("quickStartStep1Prefix")}{" "}
<code className="px-1 rounded bg-bg-subtle">npx omniroute</code>{" "}
{t("quickStartStep1Middle")}{" "}
<code className="px-1 rounded bg-bg-subtle">npm start</code>.
</p>
</li>
<li className="rounded-lg border border-border p-3 bg-bg">
<span className="font-semibold">{t("quickStartStep2Title")}</span>
<p className="text-text-muted mt-1">{t("quickStartStep2Text")}</p>
</li>
<li className="rounded-lg border border-border p-3 bg-bg">
<span className="font-semibold">{t("quickStartStep3Title")}</span>
<p className="text-text-muted mt-1">{t("quickStartStep3Text")}</p>
</li>
<li className="rounded-lg border border-border p-3 bg-bg">
<span className="font-semibold">{t("quickStartStep4Title")}</span>
<p className="text-text-muted mt-1">
{t("quickStartStep4Prefix")}{" "}
<code className="px-1 rounded bg-bg-subtle">https://&lt;host&gt;/v1</code>.{" "}
{t("quickStartStep4Suffix")}{" "}
<code className="px-1 rounded bg-bg-subtle">gh/gpt-5.1-codex</code>.
</p>
</li>
</ol>
</section>
<section id="deployment" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">{t("deploymentGuides")}</h2>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
{deploymentGuides.map((item) => (
<a
key={item.titleKey}
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="rounded-lg border border-border p-4 bg-bg flex gap-3 transition-colors hover:border-primary/40 hover:bg-bg-subtle"
>
<span className="material-symbols-outlined text-[20px] text-primary shrink-0 mt-0.5">
{item.icon}
</span>
<div>
<h3 className="font-semibold text-sm">{item.title}</h3>
<p className="text-sm text-text-muted mt-1">{item.text}</p>
</div>
</a>
))}
</div>
</section>
<section id="features" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">{t("features")}</h2>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
{featureItems.map((item) => (
<article
key={item.titleKey}
className="rounded-lg border border-border p-4 bg-bg flex gap-3"
>
<span className="material-symbols-outlined text-[20px] text-primary shrink-0 mt-0.5">
{item.icon}
</span>
<div>
<h3 className="font-semibold text-sm">{item.title}</h3>
<p className="text-sm text-text-muted mt-1">{item.text}</p>
</div>
</article>
))}
</div>
</section>
<section
id="supported-providers"
className="rounded-2xl border border-border bg-bg-subtle p-6"
>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-xl font-semibold">{t("supportedProviders")}</h2>
<p className="text-sm text-text-muted mt-1">
{t("providersAcrossConnectionTypes", { count: totalProviders })}
</p>
</div>
<Link
href="/dashboard/providers"
className="px-3 py-2 rounded-lg border border-border text-sm hover:bg-bg transition-colors"
>
{t("manageProviders")}
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<ProviderTable
title={t("providerTypeFree")}
providers={FREE_PROVIDERS}
colorDot="bg-green-500"
/>
<ProviderTable
title={t("providerTypeOAuth")}
providers={OAUTH_PROVIDERS}
colorDot="bg-blue-500"
/>
</div>
<div className="mt-3">
<ProviderTable
title={t("providerTypeApiKey")}
providers={APIKEY_PROVIDERS}
colorDot="bg-amber-500"
maxHeight="400px"
/>
</div>
</section>
<section id="use-cases" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">{t("commonUseCases")}</h2>
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
{useCases.map((item) => (
<article key={item.titleKey} className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{item.title}</h3>
<p className="text-sm text-text-muted mt-2">{item.text}</p>
</article>
))}
</div>
</section>
<section
id="client-compatibility"
className="rounded-2xl border border-border bg-bg-subtle p-6"
>
<h2 className="text-xl font-semibold">{t("clientCompatibility")}</h2>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<article className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{t("clientCherryStudioTitle")}</h3>
<ul className="mt-2 text-text-muted space-y-1">
<li>
{t("baseUrlLabel")}:{" "}
<code className="px-1 rounded bg-bg-subtle">https://&lt;host&gt;/v1</code>
</li>
<li>
{t("chatEndpointLabel")}:{" "}
<code className="px-1 rounded bg-bg-subtle">/chat/completions</code>
</li>
<li>
{t("modelRecommendationLabel")} (
<code className="px-1 rounded bg-bg-subtle">gh/...</code>,{" "}
<code className="px-1 rounded bg-bg-subtle">cc/...</code>)
</li>
</ul>
</article>
<article className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{t("clientCodexTitle")}</h3>
<ul className="mt-2 text-text-muted space-y-1">
<li>
{t("clientCodexBullet1")} <code className="px-1 rounded bg-bg-subtle">gh/</code>.
</li>
<li>
{t("clientCodexBullet2")}{" "}
<code className="px-1 rounded bg-bg-subtle">/responses</code>.
</li>
<li>
{t("clientCodexBullet3")}{" "}
<code className="px-1 rounded bg-bg-subtle">/chat/completions</code>.
</li>
</ul>
</article>
<article className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{t("clientCursorTitle")}</h3>
<ul className="mt-2 text-text-muted space-y-1">
<li>
{t("clientCursorBullet1")} <code className="px-1 rounded bg-bg-subtle">cu/</code>{" "}
{t("clientCursorBullet1Suffix")}
</li>
<li>{t("clientCursorBullet2")}</li>
<li>{t("supportsChat")}</li>
</ul>
</article>
<article className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{t("clientClaudeTitle")}</h3>
<ul className="mt-2 text-text-muted space-y-1">
<li>
{t("clientClaudeBullet1Prefix")}{" "}
<code className="px-1 rounded bg-bg-subtle">cc/</code>{" "}
{t("clientClaudeBullet1Middle")}{" "}
<code className="px-1 rounded bg-bg-subtle">antigravity/</code>{" "}
{t("clientClaudeBullet1Suffix")}
</li>
<li>{t("oauthAutoRefresh")}</li>
<li>{t("fullStreaming")}</li>
</ul>
</article>
<article className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{t("clientWindsurfTitle")}</h3>
<ul className="mt-2 text-text-muted space-y-1">
<li>{t("clientWindsurfBullet1")}</li>
<li>{t("clientWindsurfBullet2")}</li>
<li>{t("clientWindsurfBullet3")}</li>
</ul>
</article>
<article className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{t("clientClineTitle")}</h3>
<ul className="mt-2 text-text-muted space-y-1">
<li>{t("clientClineBullet1")}</li>
<li>{t("clientClineBullet2")}</li>
<li>{t("clientClineBullet3")}</li>
</ul>
</article>
<article className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{t("clientKimiTitle")}</h3>
<ul className="mt-2 text-text-muted space-y-1">
<li>{t("clientKimiBullet1")}</li>
<li>{t("clientKimiBullet2")}</li>
<li>{t("clientKimiBullet3")}</li>
</ul>
</article>
</div>
</section>
<section id="protocols" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">{t("protocolsTitle")}</h2>
<p className="text-sm text-text-muted mt-2">{t("protocolsDescription")}</p>
<div className="mt-4 grid grid-cols-1 lg:grid-cols-3 gap-4 text-sm">
<article className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{t("protocolMcpTitle")}</h3>
<p className="text-text-muted mt-1">{t("protocolMcpDesc")}</p>
<ol className="mt-3 list-decimal list-inside space-y-1 text-text-muted">
<li>{t("protocolMcpStep1")}</li>
<li>{t("protocolMcpStep2")}</li>
<li>{t("protocolMcpStep3")}</li>
</ol>
<pre className="mt-3 p-3 rounded-lg border border-border bg-bg overflow-x-auto text-xs">
<code>{`omniroute --mcp`}</code>
</pre>
</article>
<article className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{t("protocolA2aTitle")}</h3>
<p className="text-text-muted mt-1">{t("protocolA2aDesc")}</p>
<ol className="mt-3 list-decimal list-inside space-y-1 text-text-muted">
<li>{t("protocolA2aStep1")}</li>
<li>{t("protocolA2aStep2")}</li>
<li>{t("protocolA2aStep3")}</li>
</ol>
<pre className="mt-3 p-3 rounded-lg border border-border bg-bg overflow-x-auto text-xs">
<code>{`GET /.well-known/agent.json
POST /a2a (JSON-RPC: message/send | message/stream)`}</code>
</pre>
</article>
<article className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{t("protocolAcpTitle")}</h3>
<p className="text-text-muted mt-1">{t("protocolAcpDesc")}</p>
<ol className="mt-3 list-decimal list-inside space-y-1 text-text-muted">
<li>{t("protocolAcpStep1")}</li>
<li>{t("protocolAcpStep2")}</li>
<li>{t("protocolAcpStep3")}</li>
</ol>
<pre className="mt-3 p-3 rounded-lg border border-border bg-bg overflow-x-auto text-xs">
<code>{`Dashboard -> Agents
Dashboard -> CLI Tools`}</code>
</pre>
</article>
</div>
<div className="mt-4 rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{t("protocolTroubleshootingTitle")}</h3>
<ul className="mt-2 list-disc list-inside text-sm text-text-muted space-y-1">
<li>{t("protocolTroubleshooting1")}</li>
<li>{t("protocolTroubleshooting2")}</li>
<li>{t("protocolTroubleshooting3")}</li>
</ul>
</div>
</section>
<section id="mcp-tools" className="rounded-2xl border border-border bg-bg-subtle p-6">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h2 className="text-xl font-semibold">{t("mcpToolsTitle")}</h2>
<p className="mt-2 text-sm text-text-muted">
{t("mcpToolsDescription", { count: totalMcpTools })}
</p>
</div>
<div className="rounded-full border border-border bg-bg px-3 py-1 text-xs text-text-muted">
{t("mcpToolsCount", { count: totalMcpTools })}
</div>
</div>
<div className="mt-4 grid grid-cols-1 lg:grid-cols-2 gap-3">
{mcpToolGroups.map((group) => (
<article key={group.titleKey} className="rounded-lg border border-border bg-bg p-4">
<h3 className="font-semibold">{group.title}</h3>
<p className="mt-1 text-sm text-text-muted">{group.text}</p>
<div className="mt-3 flex flex-wrap gap-2">
{group.tools.map((tool) => (
<code
key={tool}
className="rounded-md border border-border/70 bg-bg-subtle px-2 py-1 text-xs text-text-muted"
>
{tool}
</code>
))}
</div>
</article>
))}
</div>
</section>
<section id="api-reference" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">{t("apiReference")}</h2>
<div className="mt-4 overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 pr-4">{t("method")}</th>
<th className="text-left py-2 pr-4">{t("path")}</th>
<th className="text-left py-2">{t("notes")}</th>
</tr>
</thead>
<tbody>
{endpointRows.map((row) => (
<tr key={row.path} className="border-b border-border/60">
<td className="py-2 pr-4">
<code className="px-1.5 py-0.5 rounded bg-bg text-xs font-semibold">
{row.method}
</code>
</td>
<td className="py-2 pr-4 font-mono">{row.path}</td>
<td className="py-2 text-text-muted">{row.note}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section id="model-prefixes" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">{t("modelPrefixes")}</h2>
<p className="text-sm text-text-muted mt-2 mb-4">
{t("modelPrefixesDescriptionStart")}{" "}
<code className="px-1 rounded bg-bg">gh/gpt-5.1-codex</code>{" "}
{t("modelPrefixesDescriptionEnd")}
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 pr-4">{t("prefix")}</th>
<th className="text-left py-2 pr-4">{t("provider")}</th>
<th className="text-left py-2">{t("type")}</th>
</tr>
</thead>
<tbody>
{providerPrefixRows.map((p) => (
<tr key={p.id} className="border-b border-border/60">
<td className="py-2 pr-4 font-mono">
<code className="px-1.5 py-0.5 rounded bg-bg">{p.alias}/</code>
</td>
<td className="py-2 pr-4">{p.name}</td>
<td className="py-2">
<span
className={`inline-flex items-center gap-1 text-xs ${
p.type === "free"
? "text-green-500"
: p.type === "oauth"
? "text-blue-500"
: "text-amber-500"
}`}
>
<span
className={`size-1.5 rounded-full ${
p.type === "free"
? "bg-green-500"
: p.type === "oauth"
? "bg-blue-500"
: "bg-amber-500"
}`}
/>
{getProviderTypeLabel(p.type)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section id="management-api" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">{t("managementApiReference")}</h2>
<p className="text-sm text-text-muted mt-2">{t("managementApiDescription")}</p>
<div className="mt-4 overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 pr-4">{t("method")}</th>
<th className="text-left py-2 pr-4">{t("path")}</th>
<th className="text-left py-2">{t("notes")}</th>
</tr>
</thead>
<tbody>
{managementEndpointRows.map((row) => (
<tr key={`${row.method}:${row.path}`} className="border-b border-border/60">
<td className="py-2 pr-4">
<code className="px-1.5 py-0.5 rounded bg-bg text-xs font-semibold">
{row.method}
</code>
</td>
<td className="py-2 pr-4 font-mono">{row.path}</td>
<td className="py-2 text-text-muted">{row.note}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section id="troubleshooting" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">{t("troubleshooting")}</h2>
<ul className="mt-4 list-disc list-inside text-sm text-text-muted space-y-2">
{troubleshootingItems.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</section>
</div>
<WhatsNewSection />
</div>
);
}

View File

@@ -2,12 +2,13 @@
import { useState } from "react";
import { useTranslations } from "next-intl";
import { copyToClipboard } from "@/shared/utils/clipboard";
import { useDisplayBaseUrl } from "@/shared/hooks";
export default function GetStarted() {
const t = useTranslations("landing");
const [copied, setCopied] = useState(false);
const endpoint = "http://localhost:20128";
const endpoint = useDisplayBaseUrl();
const dashboardUrl = `${endpoint}/dashboard`;
const command = "npx omniroute";

View File

@@ -0,0 +1,206 @@
/**
* Assessor — Probes provider/model pairs to determine working status and performance.
*
* Sends lightweight chat completion requests through each provider connection
* and records success/failure, latency, and capability detection.
*
* @module domain/assessment/assessor
*/
import type {
ModelAssessment,
AssessmentStatus,
ProbeLevel,
AssessmentConfig,
AssessmentRun,
AssessmentScope,
AssessmentTrigger,
} from "./types";
import { DEFAULT_ASSESSMENT_CONFIG, PROBE_MESSAGES, PROBE_MAX_TOKENS } from "./types";
interface ProbeResult {
status: AssessmentStatus;
latencyMs: number;
error?: string;
supportsStreaming?: boolean;
content?: string;
}
export class Assessor {
private config: AssessmentConfig;
private assessments: Map<string, ModelAssessment> = new Map();
private apiKey: string;
private baseUrl: string;
constructor(
apiKey: string,
baseUrl: string = "http://localhost:20128/v1",
config: Partial<AssessmentConfig> = {}
) {
this.apiKey = apiKey;
this.baseUrl = baseUrl;
this.config = { ...DEFAULT_ASSESSMENT_CONFIG, ...config };
}
async probeModel(
providerId: string,
modelId: string,
level: ProbeLevel = "quick"
): Promise<ProbeResult> {
const messages = PROBE_MESSAGES[level];
const maxTokens = PROBE_MAX_TOKENS[level];
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.config.probeTimeoutMs);
try {
const start = Date.now();
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model: `${providerId}/${modelId}`,
messages,
max_tokens: maxTokens,
stream: false,
}),
signal: controller.signal,
});
const latencyMs = Date.now() - start;
if (response.status === 401 || response.status === 403) {
return { status: "auth_error", latencyMs, error: `Auth failed: ${response.status}` };
}
if (response.status === 429) {
return { status: "rate_limited", latencyMs, error: "Rate limited" };
}
if (response.status === 400) {
const body = await response.text();
return { status: "broken", latencyMs, error: `Bad request: ${body.slice(0, 200)}` };
}
if (!response.ok) {
return { status: "broken", latencyMs, error: `HTTP ${response.status}` };
}
const data = await response.json();
const content = data.choices?.[0]?.message?.content?.trim() ?? "";
const supportsStreaming = data.usage?.completion_tokens > 0;
return { status: "working", latencyMs, content, supportsStreaming };
} catch (err) {
if (err.name === "AbortError") {
return {
status: "timeout",
latencyMs: this.config.probeTimeoutMs,
error: "Probe timed out",
};
}
return { status: "broken", latencyMs: 0, error: err.message };
} finally {
clearTimeout(timeout);
}
}
async assessModel(providerId: string, modelId: string): Promise<ModelAssessment> {
const id = `${providerId}/${modelId}`;
const existing = this.assessments.get(id);
const now = new Date().toISOString();
const probeResults: ProbeResult[] = [];
for (const level of ["quick", "standard"] as ProbeLevel[]) {
const result = await this.probeModel(providerId, modelId, level);
probeResults.push(result);
if (result.status === "auth_error" || result.status === "broken") break;
}
const bestResult = probeResults.find((r) => r.status === "working") ?? probeResults[0];
const latencies = probeResults.filter((r) => r.status === "working").map((r) => r.latencyMs);
const assessment: ModelAssessment = {
id,
modelId,
providerId,
status: bestResult.status,
latencyP50: latencies.length > 0 ? percentile(latencies, 50) : null,
latencyP95: latencies.length > 0 ? percentile(latencies, 95) : null,
successRate: probeResults.filter((r) => r.status === "working").length / probeResults.length,
supportsVision: false,
supportsToolCall: false,
supportsStreaming: bestResult.supportsStreaming ?? false,
supportsStructuredOutput: false,
maxContextWindow: null,
maxOutputTokens: null,
categories: [],
fitnessScores: {} as Record<string, number>,
tier: "balanced",
lastTested: now,
lastError: bestResult.error ?? null,
consecutiveFails: bestResult.status === "working" ? 0 : (existing?.consecutiveFails ?? 0) + 1,
probeCount: (existing?.probeCount ?? 0) + probeResults.length,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
this.assessments.set(id, assessment);
return assessment;
}
async runAssessment(
models: Array<{ providerId: string; modelId: string }>,
trigger: AssessmentTrigger = "on_demand"
): Promise<AssessmentRun> {
const run: AssessmentRun = {
id: crypto.randomUUID(),
startedAt: new Date().toISOString(),
completedAt: null,
modelsTested: 0,
modelsPassed: 0,
modelsFailed: 0,
modelsRateLimited: 0,
durationMs: null,
trigger,
createdAt: new Date().toISOString(),
};
const start = Date.now();
for (const { providerId, modelId } of models) {
const assessment = await this.assessModel(providerId, modelId);
run.modelsTested++;
if (assessment.status === "working") run.modelsPassed++;
else if (assessment.status === "rate_limited") run.modelsRateLimited++;
else run.modelsFailed++;
}
run.completedAt = new Date().toISOString();
run.durationMs = Date.now() - start;
return run;
}
getAssessment(providerId: string, modelId: string): ModelAssessment | undefined {
return this.assessments.get(`${providerId}/${modelId}`);
}
getAllAssessments(): ModelAssessment[] {
return Array.from(this.assessments.values());
}
getWorkingModels(): ModelAssessment[] {
return this.getAllAssessments().filter((a) => a.status === "working");
}
}
function percentile(values: number[], p: number): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const idx = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.max(0, idx)];
}

View File

@@ -0,0 +1,118 @@
import type { ModelAssessment, ModelCategory, ModelTier } from "./types";
const TIER_SCORES: Record<ModelTier, number> = {
premium: 1.0,
balanced: 0.67,
fast: 0.5,
free: 0.0,
};
const CATEGORY_WEIGHTS: Record<
ModelCategory,
{ tier: number; speed: number; success: number; cost: number }
> = {
coding: { tier: 0.4, speed: 0.3, success: 0.2, cost: 0.1 },
reasoning: { tier: 0.5, success: 0.3, speed: 0.1, cost: 0.1 },
reasoning_deep: { tier: 0.7, success: 0.2, speed: 0.05, cost: 0.05 },
chat: { tier: 0.2, success: 0.4, speed: 0.3, cost: 0.1 },
fast: { tier: 0.1, success: 0.3, speed: 0.6, cost: 0 },
vision: { tier: 0.3, success: 0.5, speed: 0.1, cost: 0.1 },
tool_call: { tier: 0.3, success: 0.5, speed: 0.1, cost: 0.1 },
structured_output: { tier: 0.3, success: 0.5, speed: 0.1, cost: 0.1 },
};
const MODEL_PATTERNS: Array<{ pattern: RegExp; categories: ModelCategory[]; tier: ModelTier }> = [
{
pattern: /opus|o[1-4]/i,
categories: ["reasoning_deep", "coding", "reasoning"],
tier: "premium",
},
{ pattern: /sonnet/i, categories: ["coding", "reasoning", "chat"], tier: "premium" },
{ pattern: /haiku/i, categories: ["fast", "coding", "chat"], tier: "fast" },
{ pattern: /gpt-4|gpt-5/i, categories: ["coding", "reasoning", "chat"], tier: "premium" },
{ pattern: /gpt-3\.5|gpt-4o-mini/i, categories: ["fast", "chat"], tier: "fast" },
{
pattern: /deepseek.*pro|deepseek.*v[3-9]/i,
categories: ["coding", "reasoning"],
tier: "balanced",
},
{ pattern: /cogito/i, categories: ["reasoning_deep", "reasoning"], tier: "balanced" },
{ pattern: /devstral|codestral/i, categories: ["coding", "fast"], tier: "balanced" },
{ pattern: /gemma.*(?:31|71)/i, categories: ["coding", "reasoning"], tier: "balanced" },
{ pattern: /gemma.*(?:4|12)/i, categories: ["fast", "chat"], tier: "fast" },
{ pattern: /gemma.*3/i, categories: ["fast", "chat"], tier: "fast" },
{ pattern: /glm/i, categories: ["coding", "chat"], tier: "balanced" },
{ pattern: /qwen.*72|qwen.*plus/i, categories: ["reasoning", "coding"], tier: "balanced" },
{ pattern: /mini|flash|nano/i, categories: ["fast", "chat"], tier: "fast" },
];
export class Categorizer {
categorizeModel(assessment: ModelAssessment): ModelCategory[] {
const categories = new Set<ModelCategory>();
if (assessment.latencyP50 !== null && assessment.latencyP50 < 2000) {
categories.add("fast");
}
if (assessment.supportsVision) categories.add("vision");
if (assessment.supportsToolCall) categories.add("tool_call");
if (assessment.supportsStructuredOutput) categories.add("structured_output");
for (const { pattern, categories: patternCats } of MODEL_PATTERNS) {
if (pattern.test(assessment.modelId)) {
for (const cat of patternCats) categories.add(cat);
break;
}
}
if (categories.size === 0) categories.add("chat");
return Array.from(categories);
}
assignTier(assessment: ModelAssessment): ModelTier {
for (const { pattern, tier } of MODEL_PATTERNS) {
if (pattern.test(assessment.modelId)) return tier;
}
return "balanced";
}
calculateFitness(assessment: ModelAssessment, category: ModelCategory): number {
const weights = CATEGORY_WEIGHTS[category];
const tierScore = TIER_SCORES[assessment.tier];
const speedScore =
assessment.latencyP50 !== null ? Math.max(0, 1 - assessment.latencyP50 / 15000) : 0.5;
const successScore = assessment.successRate;
const costScore = 0.5;
return (
weights.tier * tierScore +
weights.speed * speedScore +
weights.success * successScore +
weights.cost * costScore
);
}
calculateAllFitness(assessment: ModelAssessment): Record<string, number> {
const scores: Record<string, number> = {};
for (const category of assessment.categories) {
scores[category] = Math.round(this.calculateFitness(assessment, category) * 100) / 100;
}
return scores;
}
assignCategoriesAndFitness(assessment: ModelAssessment): ModelAssessment {
const categories = this.categorizeModel(assessment);
const tier = this.assignTier(assessment);
const fitnessScores = categories.reduce(
(acc, cat) => ({ ...acc, [cat]: this.calculateFitness(assessment, cat) }),
{} as Record<string, number>
);
return {
...assessment,
categories,
tier,
fitnessScores,
};
}
}

View File

@@ -0,0 +1,24 @@
export { Assessor } from "./assessor";
export { Categorizer } from "./categorizer";
export { SelfHealer } from "./selfHealer";
export type {
AssessmentStatus,
ModelCategory,
ModelTier,
ProbeLevel,
AssessmentScope,
ModelAssessment,
AssessmentRun,
AssessmentTrigger,
ComboHealth,
HealActionType,
HealAction,
AssessmentConfig,
AutoComboTemplate,
} from "./types";
export {
DEFAULT_ASSESSMENT_CONFIG,
AUTO_COMBO_TEMPLATES,
PROBE_MESSAGES,
PROBE_MAX_TOKENS,
} from "./types";

View File

@@ -0,0 +1,109 @@
import Database from "better-sqlite3";
import { mkdirSync, existsSync } from "node:fs";
import { dirname } from "node:path";
const MIGRATION_SQL = `
-- Model assessments: probe results for each provider/model pair
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',
latency_p50 INTEGER,
latency_p95 INTEGER,
success_rate REAL DEFAULT 0,
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 '[]',
fitness_scores TEXT DEFAULT '{}',
tier TEXT DEFAULT 'balanced',
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)
);
-- Assessment run history
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 'on_demand',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Combo health tracking
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,
health_score REAL DEFAULT 0,
last_auto_fix TEXT,
auto_fix_count INTEGER DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (combo_id) REFERENCES combos(id)
);
-- Self-heal action log
CREATE TABLE IF NOT EXISTS heal_actions (
id TEXT PRIMARY KEY,
combo_id TEXT NOT NULL,
action_type TEXT NOT NULL,
model_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
reason TEXT NOT NULL,
previous_weight INTEGER,
new_weight INTEGER,
timestamp TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (combo_id) REFERENCES combos(id)
);
-- Indexes for common queries
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_model_assessments_last_tested ON model_assessments(last_tested);
CREATE INDEX IF NOT EXISTS idx_combo_health_health_score ON combo_health(health_score);
CREATE INDEX IF NOT EXISTS idx_heal_actions_combo_id ON heal_actions(combo_id);
CREATE INDEX IF NOT EXISTS idx_heal_actions_timestamp ON heal_actions(timestamp);
`;
export function runAssessmentMigration(dbPath: string): void {
const dir = dirname(dbPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const db = new Database(dbPath);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
db.exec(MIGRATION_SQL);
const versionRow = db
.prepare("SELECT COUNT(*) as count FROM _omniroute_migrations WHERE name = ?")
.get("assessment_engine") as { count: number };
if (versionRow.count === 0) {
db.prepare(
"INSERT INTO _omniroute_migrations (name, applied_at) VALUES (?, datetime('now'))"
).run("assessment_engine");
}
db.close();
}
export { MIGRATION_SQL };

View File

@@ -0,0 +1,261 @@
import type {
ModelAssessment,
ComboHealth,
HealAction,
HealActionType,
AssessmentConfig,
AutoComboTemplate,
} from "./types";
import { DEFAULT_ASSESSMENT_CONFIG, AUTO_COMBO_TEMPLATES } from "./types";
interface ComboModel {
id: string;
kind: string;
model: string;
providerId: string;
weight: number;
}
interface Combo {
id: string;
name: string;
data: {
name: string;
models: ComboModel[];
strategy?: string;
systemMessage?: string;
[key: string]: unknown;
};
[key: string]: unknown;
}
export class SelfHealer {
private config: AssessmentConfig;
private healLog: HealAction[] = [];
constructor(config: Partial<AssessmentConfig> = {}) {
this.config = { ...DEFAULT_ASSESSMENT_CONFIG, ...config };
}
healCombo(
combo: Combo,
assessments: Map<string, ModelAssessment>
): {
combo: Combo;
actions: HealAction[];
health: ComboHealth;
} {
const actions: HealAction[] = [];
const models = combo.data.models ?? [];
let healthyCount = 0;
let deadCount = 0;
const updatedModels: ComboModel[] = [];
for (const model of models) {
const assessmentKey = `${model.providerId}/${model.model}`;
const assessment = assessments.get(assessmentKey);
const status = assessment?.status ?? "unknown";
if (status === "broken") {
deadCount++;
if (this.config.selfHealEnabled) {
actions.push(
this.createAction(
combo.id,
"remove_model",
model.model,
model.providerId,
`Model broken: ${assessment?.lastError ?? "unknown error"}`
)
);
} else {
updatedModels.push(model);
}
continue;
}
if (status === "rate_limited") {
const newWeight = Math.max(
this.config.minimumWeight,
model.weight * (1 - this.config.maxWeightReduction)
);
updatedModels.push({ ...model, weight: newWeight });
healthyCount++;
actions.push(
this.createAction(
combo.id,
"reduce_weight",
model.model,
model.providerId,
`Rate limited: weight ${model.weight}${newWeight}`
)
);
continue;
}
if (status === "timeout") {
const newWeight = Math.max(this.config.minimumWeight, model.weight * 0.7);
updatedModels.push({ ...model, weight: newWeight });
healthyCount++;
actions.push(
this.createAction(
combo.id,
"reduce_weight",
model.model,
model.providerId,
`Timeout detected: weight ${model.weight}${newWeight}`
)
);
continue;
}
healthyCount++;
updatedModels.push(model);
}
if (updatedModels.length === 0 && models.length > 0 && this.config.selfHealEnabled) {
const bestAlternative = this.findBestWorkingModel(combo.name, assessments);
if (bestAlternative) {
updatedModels.push({
id: `emergency-${bestAlternative.providerId}-${bestAlternative.modelId}`,
kind: "model",
model: bestAlternative.modelId,
providerId: bestAlternative.providerId,
weight: 100,
});
actions.push(
this.createAction(
combo.id,
"emergency_replace",
bestAlternative.modelId,
bestAlternative.providerId,
"All models broken, added best available alternative"
)
);
healthyCount = 1;
deadCount = 0;
}
}
const totalModelCount = Math.max(models.length, 1);
const healthScore = healthyCount / totalModelCount;
const health: ComboHealth = {
comboId: combo.id,
healthyModelCount: healthyCount,
deadModelCount: deadCount,
totalModelCount,
healthScore,
lastAutoFix: actions.length > 0 ? new Date().toISOString() : null,
autoFixCount: actions.length,
updatedAt: new Date().toISOString(),
};
const healedCombo: Combo = {
...combo,
data: { ...combo.data, models: updatedModels },
};
this.healLog.push(...actions);
return { combo: healedCombo, actions, health };
}
findBestWorkingModel(
comboName: string,
assessments: Map<string, ModelAssessment>
): ModelAssessment | null {
const template = AUTO_COMBO_TEMPLATES.find((t) => t.name === comboName);
const workingModels = Array.from(assessments.values()).filter((a) => a.status === "working");
if (workingModels.length === 0) return null;
if (template) {
const matching = workingModels
.filter((a) => {
const hasCategory = template.categories.some((c) => a.categories.includes(c));
const hasTier = template.tiers.includes(a.tier);
return hasCategory && (template.tiers.length === 0 || hasTier);
})
.sort((a, b) => {
const aScore = Math.max(...Object.values(a.fitnessScores), 0);
const bScore = Math.max(...Object.values(b.fitnessScores), 0);
return bScore - aScore;
});
return (
matching[0] ?? workingModels.sort((a, b) => (b.successRate ?? 0) - (a.successRate ?? 0))[0]
);
}
return workingModels.sort((a, b) => (b.successRate ?? 0) - (a.successRate ?? 0))[0];
}
generateCombosFromAssessments(
assessments: Map<string, ModelAssessment>,
templates: AutoComboTemplate[] = AUTO_COMBO_TEMPLATES
): Array<{ template: AutoComboTemplate; models: ComboModel[] }> {
const results: Array<{ template: AutoComboTemplate; models: ComboModel[] }> = [];
const working = Array.from(assessments.values()).filter((a) => a.status === "working");
for (const template of templates) {
const candidates = working
.filter((a) => {
const hasCategory = template.categories.some((c) => a.categories.includes(c));
const hasTier = template.tiers.length === 0 || template.tiers.includes(a.tier);
return hasCategory && hasTier;
})
.sort((a, b) => {
const aBestFitness = Math.max(...template.categories.map((c) => a.fitnessScores[c] ?? 0));
const bBestFitness = Math.max(...template.categories.map((c) => b.fitnessScores[c] ?? 0));
return bBestFitness - aBestFitness;
});
if (candidates.length === 0) continue;
const totalWeight = 100;
const models: ComboModel[] = candidates.slice(0, 5).map((a, i) => {
const fitness = Math.max(...template.categories.map((c) => a.fitnessScores[c] ?? 0));
const weight =
i === 0
? Math.round(totalWeight * 0.35)
: Math.round((totalWeight * 0.65) / Math.min(candidates.length - 1, 4));
return {
id: `${template.name}-m${i + 1}-${a.providerId}-${a.modelId.replace(/[:/]/g, "-")}`,
kind: "model",
model: `${a.providerId}/${a.modelId}`,
providerId: a.providerId,
weight,
};
});
results.push({ template, models });
}
return results;
}
getHealLog(): HealAction[] {
return [...this.healLog];
}
private createAction(
comboId: string,
actionType: HealActionType,
modelId: string,
providerId: string,
reason: string
): HealAction {
return {
id: crypto.randomUUID(),
comboId,
actionType,
modelId,
providerId,
reason,
previousWeight: null,
newWeight: null,
timestamp: new Date().toISOString(),
};
}
}

View File

@@ -0,0 +1,352 @@
/**
* Assessment Engine Types
*
* Type definitions for the auto-assessment, categorization,
* and self-healing system.
*
* @module domain/assessment/types
*/
// ── Model Assessment ────────────────────────────────────────────────────────
/** Assessment status for a model/provider pair */
export type AssessmentStatus =
| "working" // Responds correctly to probes
| "broken" // Returns errors (4xx, 5xx) or invalid responses
| "rate_limited" // Temporarily rate-limited (retries may succeed later)
| "timeout" // Request exceeds probe timeout
| "auth_error" // Authentication failed (invalid key, expired token)
| "unknown"; // Not yet assessed
/** Capability categories for model classification */
export type ModelCategory =
| "coding" // Code generation, debugging, refactoring
| "reasoning" // Logical reasoning, math, analysis
| "reasoning_deep" // Extended thinking, complex multi-step reasoning
| "chat" // Conversational ability, general Q&A
| "fast" // Sub-2s response time for short prompts
| "vision" // Image input support
| "tool_call" // Function/tool calling support
| "structured_output"; // JSON mode / structured output support;
/** Provider account tier classification */
export type ModelTier = "premium" | "balanced" | "fast" | "free";
/** Probe intensity level */
export type ProbeLevel = "quick" | "standard" | "deep";
/** Scope for assessment runs */
export type AssessmentScope =
| { type: "all" }
| { type: "provider"; providerId: string }
| { type: "model"; modelId: string };
/** Complete assessment result for a model/provider pair */
export interface ModelAssessment {
/** Unique ID: `${providerId}/${modelId}` */
id: string;
/** Model identifier (e.g., "claude-sonnet-4.5") */
modelId: string;
/** Provider identifier (e.g., "kiro") */
providerId: string;
/** Current assessment status */
status: AssessmentStatus;
/** Median latency in milliseconds */
latencyP50: number | null;
/** P95 latency in milliseconds */
latencyP95: number | null;
/** Success rate over recent probes (0..1) */
successRate: number;
/** Whether model supports image inputs */
supportsVision: boolean;
/** Whether model supports function/tool calling */
supportsToolCall: boolean;
/** Whether model supports streaming */
supportsStreaming: boolean;
/** Whether model supports JSON/structured output */
supportsStructuredOutput: boolean;
/** Maximum context window in tokens */
maxContextWindow: number | null;
/** Maximum output tokens */
maxOutputTokens: number | null;
/** Capability categories */
categories: ModelCategory[];
/** Fitness score per category (0..1) */
fitnessScores: Record<ModelCategory, number>;
/** Overall tier classification */
tier: ModelTier;
/** ISO timestamp of last probe */
lastTested: string | null;
/** Last error message if any */
lastError: string | null;
/** Consecutive failed probes */
consecutiveFails: number;
/** Total probes executed */
probeCount: number;
/** ISO timestamp of creation */
createdAt: string;
/** ISO timestamp of last update */
updatedAt: string;
}
// ── Assessment Run ───────────────────────────────────────────────────────────
/** Trigger source for an assessment run */
export type AssessmentTrigger =
| "scheduled"
| "on_demand"
| "on_provider_change"
| "on_error"
| "startup";
/** Record of a single assessment run */
export interface AssessmentRun {
id: string;
startedAt: string;
completedAt: string | null;
modelsTested: number;
modelsPassed: number;
modelsFailed: number;
modelsRateLimited: number;
durationMs: number | null;
trigger: AssessmentTrigger;
createdAt: string;
}
// ── Combo Health ─────────────────────────────────────────────────────────────
/** Health status for a combo */
export interface ComboHealth {
comboId: string;
healthyModelCount: number;
deadModelCount: number;
totalModelCount: number;
healthScore: number; // 0..1, weighted by model health
lastAutoFix: string | null;
autoFixCount: number;
updatedAt: string;
}
// ── Self-Heal Actions ────────────────────────────────────────────────────────
/** Types of actions the self-healer can take */
export type HealActionType =
| "remove_model" // Remove broken model from combo
| "reduce_weight" // Reduce weight of rate-limited model
| "restore_weight" // Restore weight of recovered model
| "add_model" // Add working model to combo
| "emergency_replace"; // Replace all dead models in empty combo;
/** Record of a self-heal action */
export interface HealAction {
id: string;
comboId: string;
actionType: HealActionType;
modelId: string;
providerId: string;
reason: string;
previousWeight: number | null;
newWeight: number | null;
timestamp: string;
}
// ── Probe Configuration ──────────────────────────────────────────────────────
/** Configuration for assessment probes */
export interface AssessmentConfig {
/** Interval between quick probes (ms) */
quickProbeIntervalMs: number;
/** Interval between standard probes (ms) */
standardProbeIntervalMs: number;
/** Interval between deep probes (ms) */
deepProbeIntervalMs: number;
/** Maximum timeout for a single probe (ms) */
probeTimeoutMs: number;
/** Number of consecutive failures before marking as broken */
brokenThreshold: number;
/** Number of consecutive successes to restore a model */
restoreThreshold: number;
/** Maximum weight reduction factor (0..1, 0.5 = halve the weight) */
maxWeightReduction: number;
/** Minimum weight for rate-limited models */
minimumWeight: number;
/** Whether self-healing is enabled */
selfHealEnabled: boolean;
/** Whether auto-generation of combos is enabled */
autoGenerateEnabled: boolean;
/** Whether to skip broken models in combo resolver */
skipBrokenModels: boolean;
}
/** Default assessment configuration */
export const DEFAULT_ASSESSMENT_CONFIG: AssessmentConfig = {
quickProbeIntervalMs: 5 * 60 * 1000, // 5 minutes
standardProbeIntervalMs: 30 * 60 * 1000, // 30 minutes
deepProbeIntervalMs: 6 * 60 * 60 * 1000, // 6 hours
probeTimeoutMs: 30 * 1000, // 30 seconds
brokenThreshold: 3, // 3 consecutive failures
restoreThreshold: 2, // 2 consecutive successes
maxWeightReduction: 0.5, // Halve weight
minimumWeight: 5, // Minimum weight is 5%
selfHealEnabled: true,
autoGenerateEnabled: true,
skipBrokenModels: true,
};
// ── Probe Messages ───────────────────────────────────────────────────────────
/** Probe messages for different assessment levels */
export const PROBE_MESSAGES = {
quick: [{ role: "user" as const, content: "ok" }],
standard: [
{
role: "user" as const,
content: "Write a function that adds two numbers. Reply with just the function.",
},
],
deep: [{ role: "user" as const, content: "What is 17 * 23? Reply with just the number." }],
} as const;
/** Max tokens for each probe level */
export const PROBE_MAX_TOKENS = {
quick: 3,
standard: 100,
deep: 50,
} as const;
// ── Auto-Combo Templates ────────────────────────────────────────────────────
/** Template for auto-generating combos from assessment results */
export interface AutoComboTemplate {
name: string;
displayName: string;
categories: ModelCategory[];
tiers: ModelTier[];
strategy: "priority" | "weighted" | "round-robin" | "random" | "least-used";
/** System message for this combo type */
systemMessage?: string;
}
/** Default auto-combo templates */
export const AUTO_COMBO_TEMPLATES: AutoComboTemplate[] = [
{
name: "auto/best-coding",
displayName: "Best Coding",
categories: ["coding"],
tiers: ["premium", "balanced"],
strategy: "weighted",
systemMessage:
"You are an expert coding assistant. Write clean, efficient, well-documented code.",
},
{
name: "auto/best-reasoning",
displayName: "Best Reasoning",
categories: ["reasoning_deep", "reasoning"],
tiers: ["premium"],
strategy: "weighted",
systemMessage: "You are a deep reasoning assistant. Think carefully step by step.",
},
{
name: "auto/best-fast",
displayName: "Best Fast",
categories: ["fast"],
tiers: ["fast", "balanced"],
strategy: "weighted",
},
{
name: "auto/best-vision",
displayName: "Best Vision",
categories: ["vision"],
tiers: ["premium", "balanced"],
strategy: "weighted",
},
{
name: "auto/best-chat",
displayName: "Best Chat",
categories: ["chat"],
tiers: ["balanced", "premium"],
strategy: "weighted",
},
{
name: "auto/best-coding-fast",
displayName: "Best Coding Fast",
categories: ["coding", "fast"],
tiers: ["fast", "balanced"],
strategy: "weighted",
},
{
name: "auto/pro-coding",
displayName: "Pro Coding",
categories: ["coding"],
tiers: ["premium"],
strategy: "priority",
systemMessage:
"You are an expert coding assistant. Write clean, efficient, well-documented code.",
},
{
name: "auto/pro-reasoning",
displayName: "Pro Reasoning",
categories: ["reasoning_deep"],
tiers: ["premium"],
strategy: "priority",
systemMessage: "You are a deep reasoning assistant. Think carefully step by step.",
},
{
name: "auto/pro-vision",
displayName: "Pro Vision",
categories: ["vision"],
tiers: ["premium"],
strategy: "priority",
},
{
name: "auto/pro-chat",
displayName: "Pro Chat",
categories: ["chat"],
tiers: ["premium"],
strategy: "priority",
},
{
name: "auto/pro-fast",
displayName: "Pro Fast",
categories: ["fast"],
tiers: ["fast"],
strategy: "priority",
},
{
name: "auto/coding",
displayName: "Coding",
categories: ["coding"],
tiers: ["balanced", "fast", "premium"],
strategy: "weighted",
},
{
name: "auto/fast",
displayName: "Fast",
categories: ["fast"],
tiers: ["fast"],
strategy: "weighted",
},
{
name: "auto/chat",
displayName: "Chat",
categories: ["chat"],
tiers: ["balanced", "fast"],
strategy: "weighted",
},
{
name: "auto/claude-opus",
displayName: "Claude Opus",
categories: ["reasoning_deep", "coding", "reasoning"],
tiers: ["premium"],
strategy: "priority",
},
{
name: "auto/claude-sonnet",
displayName: "Claude Sonnet",
categories: ["coding", "reasoning", "chat"],
tiers: ["premium", "balanced"],
strategy: "priority",
},
];
export {};

View File

@@ -659,6 +659,7 @@
"analytics": "التحليلات",
"costs": "التكاليف",
"health": "الصحة",
"proxy": "بروكسي",
"limits": "الحدود والحصص",
"cliTools": "أدوات سطر الأوامر",
"media": "الوسائط",
@@ -3293,7 +3294,23 @@
"compressionModeRtk": "RTK",
"compressionModeRtkDesc": "Command-aware tool output filtering",
"compressionModeStacked": "Stacked",
"compressionModeStackedDesc": "RTK tool-output filtering followed by Caveman message compression"
"compressionModeStackedDesc": "RTK tool-output filtering followed by Caveman message compression",
"httpProxy": "HTTP Proxy",
"1proxy": "1proxy",
"proxySubTabsAria": "Proxy sections",
"requestBodyLimitTitle": "Request Body Limit",
"requestBodyLimitDescription": "Maximum API payload size accepted before a request body is parsed. Dedicated upload routes keep at least their built-in 100 MB limit.",
"requestBodyLimitInputLabel": "Request body limit in MB",
"requestBodyLimitEmptyError": "Enter a limit in MB",
"requestBodyLimitWholeNumberError": "Use a whole number",
"requestBodyLimitMinimumError": "Minimum is {min} MB",
"requestBodyLimitMaximumError": "Maximum is {max} MB",
"requestBodyLimitLoadFailed": "Failed to load request limit settings",
"requestBodyLimitSaveSuccess": "Request body limit saved",
"requestBodyLimitSaveFailed": "Failed to save request body limit",
"requestBodyLimitSaving": "Saving...",
"requestBodyLimitSave": "Save",
"requestBodyLimitCurrent": "Current: {value}"
},
"contextRtk": {
"title": "RTK Engine",

View File

@@ -659,6 +659,7 @@
"analytics": "Анализ",
"costs": "Разходи",
"health": "здраве",
"proxy": "Прокси",
"limits": "Ограничения и квоти",
"cliTools": "CLI инструменти",
"media": "Медия",
@@ -2719,6 +2720,22 @@
"systemPrompt": "Системен ред",
"thinkingBudget": "Мислен бюджет",
"proxy": "Прокси",
"httpProxy": "HTTP Proxy",
"1proxy": "1proxy",
"proxySubTabsAria": "Proxy sections",
"requestBodyLimitTitle": "Request Body Limit",
"requestBodyLimitDescription": "Maximum API payload size accepted before a request body is parsed. Dedicated upload routes keep at least their built-in 100 MB limit.",
"requestBodyLimitInputLabel": "Request body limit in MB",
"requestBodyLimitEmptyError": "Enter a limit in MB",
"requestBodyLimitWholeNumberError": "Use a whole number",
"requestBodyLimitMinimumError": "Minimum is {min} MB",
"requestBodyLimitMaximumError": "Maximum is {max} MB",
"requestBodyLimitLoadFailed": "Failed to load request limit settings",
"requestBodyLimitSaveSuccess": "Request body limit saved",
"requestBodyLimitSaveFailed": "Failed to save request body limit",
"requestBodyLimitSaving": "Saving...",
"requestBodyLimitSave": "Save",
"requestBodyLimitCurrent": "Current: {value}",
"pricing": "Ценообразуване",
"storage": "Съхранение",
"policies": "Политики",

View File

@@ -659,6 +659,7 @@
"analytics": "Analytics",
"costs": "Costs",
"health": "Health",
"proxy": "প্রক্সি",
"limits": "Limits & Quotas",
"cliTools": "CLI Tools",
"media": "Media",
@@ -3450,7 +3451,23 @@
"compressionModeRtk": "RTK",
"compressionModeRtkDesc": "Command-aware tool output filtering",
"compressionModeStacked": "Stacked",
"compressionModeStackedDesc": "RTK tool-output filtering followed by Caveman message compression"
"compressionModeStackedDesc": "RTK tool-output filtering followed by Caveman message compression",
"httpProxy": "HTTP Proxy",
"1proxy": "1proxy",
"proxySubTabsAria": "Proxy sections",
"requestBodyLimitTitle": "Request Body Limit",
"requestBodyLimitDescription": "Maximum API payload size accepted before a request body is parsed. Dedicated upload routes keep at least their built-in 100 MB limit.",
"requestBodyLimitInputLabel": "Request body limit in MB",
"requestBodyLimitEmptyError": "Enter a limit in MB",
"requestBodyLimitWholeNumberError": "Use a whole number",
"requestBodyLimitMinimumError": "Minimum is {min} MB",
"requestBodyLimitMaximumError": "Maximum is {max} MB",
"requestBodyLimitLoadFailed": "Failed to load request limit settings",
"requestBodyLimitSaveSuccess": "Request body limit saved",
"requestBodyLimitSaveFailed": "Failed to save request body limit",
"requestBodyLimitSaving": "Saving...",
"requestBodyLimitSave": "Save",
"requestBodyLimitCurrent": "Current: {value}"
},
"contextRtk": {
"title": "RTK Engine",

View File

@@ -659,6 +659,7 @@
"analytics": "Analytika",
"costs": "Náklady",
"health": "Zdraví",
"proxy": "Proxy",
"limits": "Limity a kvóty",
"cliTools": "CLI Nástroje",
"media": "Média",
@@ -2719,6 +2720,22 @@
"systemPrompt": "Systémový Prompt",
"thinkingBudget": "Promýšlení rozpočtu",
"proxy": "Proxy",
"httpProxy": "HTTP Proxy",
"1proxy": "1proxy",
"proxySubTabsAria": "Proxy sections",
"requestBodyLimitTitle": "Request Body Limit",
"requestBodyLimitDescription": "Maximum API payload size accepted before a request body is parsed. Dedicated upload routes keep at least their built-in 100 MB limit.",
"requestBodyLimitInputLabel": "Request body limit in MB",
"requestBodyLimitEmptyError": "Enter a limit in MB",
"requestBodyLimitWholeNumberError": "Use a whole number",
"requestBodyLimitMinimumError": "Minimum is {min} MB",
"requestBodyLimitMaximumError": "Maximum is {max} MB",
"requestBodyLimitLoadFailed": "Failed to load request limit settings",
"requestBodyLimitSaveSuccess": "Request body limit saved",
"requestBodyLimitSaveFailed": "Failed to save request body limit",
"requestBodyLimitSaving": "Saving...",
"requestBodyLimitSave": "Save",
"requestBodyLimitCurrent": "Current: {value}",
"pricing": "Ceny",
"storage": "Skladování",
"policies": "Zásady",

View File

@@ -659,6 +659,7 @@
"analytics": "Analytics",
"costs": "Omkostninger",
"health": "Sundhed",
"proxy": "Proxy",
"limits": "Grænser og kvoter",
"cliTools": "CLI værktøjer",
"media": "Medier",
@@ -3293,7 +3294,23 @@
"compressionModeRtk": "RTK",
"compressionModeRtkDesc": "Command-aware tool output filtering",
"compressionModeStacked": "Stacked",
"compressionModeStackedDesc": "RTK tool-output filtering followed by Caveman message compression"
"compressionModeStackedDesc": "RTK tool-output filtering followed by Caveman message compression",
"httpProxy": "HTTP Proxy",
"1proxy": "1proxy",
"proxySubTabsAria": "Proxy sections",
"requestBodyLimitTitle": "Request Body Limit",
"requestBodyLimitDescription": "Maximum API payload size accepted before a request body is parsed. Dedicated upload routes keep at least their built-in 100 MB limit.",
"requestBodyLimitInputLabel": "Request body limit in MB",
"requestBodyLimitEmptyError": "Enter a limit in MB",
"requestBodyLimitWholeNumberError": "Use a whole number",
"requestBodyLimitMinimumError": "Minimum is {min} MB",
"requestBodyLimitMaximumError": "Maximum is {max} MB",
"requestBodyLimitLoadFailed": "Failed to load request limit settings",
"requestBodyLimitSaveSuccess": "Request body limit saved",
"requestBodyLimitSaveFailed": "Failed to save request body limit",
"requestBodyLimitSaving": "Saving...",
"requestBodyLimitSave": "Save",
"requestBodyLimitCurrent": "Current: {value}"
},
"contextRtk": {
"title": "RTK Engine",

View File

@@ -659,6 +659,7 @@
"analytics": "Analytik",
"costs": "Kosten",
"health": "Gesundheit",
"proxy": "Proxy",
"limits": "Limits und Quoten",
"cliTools": "CLI-Tools",
"media": "Medien",
@@ -3293,7 +3294,23 @@
"compressionModeRtk": "RTK",
"compressionModeRtkDesc": "Command-aware tool output filtering",
"compressionModeStacked": "Stacked",
"compressionModeStackedDesc": "RTK tool-output filtering followed by Caveman message compression"
"compressionModeStackedDesc": "RTK tool-output filtering followed by Caveman message compression",
"httpProxy": "HTTP Proxy",
"1proxy": "1proxy",
"proxySubTabsAria": "Proxy sections",
"requestBodyLimitTitle": "Request Body Limit",
"requestBodyLimitDescription": "Maximum API payload size accepted before a request body is parsed. Dedicated upload routes keep at least their built-in 100 MB limit.",
"requestBodyLimitInputLabel": "Request body limit in MB",
"requestBodyLimitEmptyError": "Enter a limit in MB",
"requestBodyLimitWholeNumberError": "Use a whole number",
"requestBodyLimitMinimumError": "Minimum is {min} MB",
"requestBodyLimitMaximumError": "Maximum is {max} MB",
"requestBodyLimitLoadFailed": "Failed to load request limit settings",
"requestBodyLimitSaveSuccess": "Request body limit saved",
"requestBodyLimitSaveFailed": "Failed to save request body limit",
"requestBodyLimitSaving": "Saving...",
"requestBodyLimitSave": "Save",
"requestBodyLimitCurrent": "Current: {value}"
},
"contextRtk": {
"title": "RTK Engine",

View File

@@ -659,6 +659,7 @@
"analytics": "Analytics",
"costs": "Costs",
"health": "Health",
"proxy": "Proxy",
"limits": "Limits & Quotas",
"cliTools": "CLI Tools",
"media": "Media",
@@ -3101,6 +3102,22 @@
"systemPrompt": "System Prompt",
"thinkingBudget": "Thinking Budget",
"proxy": "Proxy",
"httpProxy": "HTTP Proxy",
"1proxy": "1proxy",
"proxySubTabsAria": "Proxy sections",
"requestBodyLimitTitle": "Request Body Limit",
"requestBodyLimitDescription": "Maximum API payload size accepted before a request body is parsed. Dedicated upload routes keep at least their built-in 100 MB limit.",
"requestBodyLimitInputLabel": "Request body limit in MB",
"requestBodyLimitEmptyError": "Enter a limit in MB",
"requestBodyLimitWholeNumberError": "Use a whole number",
"requestBodyLimitMinimumError": "Minimum is {min} MB",
"requestBodyLimitMaximumError": "Maximum is {max} MB",
"requestBodyLimitLoadFailed": "Failed to load request limit settings",
"requestBodyLimitSaveSuccess": "Request body limit saved",
"requestBodyLimitSaveFailed": "Failed to save request body limit",
"requestBodyLimitSaving": "Saving...",
"requestBodyLimitSave": "Save",
"requestBodyLimitCurrent": "Current: {value}",
"mitmProxy": "MITM Proxy",
"pricing": "Pricing",
"storage": "Storage",

View File

@@ -659,6 +659,7 @@
"analytics": "Analítica",
"costs": "Costos",
"health": "Salud",
"proxy": "Proxy",
"limits": "Límites y cuotas",
"cliTools": "Herramientas CLI",
"media": "Multimedia",
@@ -3293,7 +3294,23 @@
"compressionModeRtk": "RTK",
"compressionModeRtkDesc": "Command-aware tool output filtering",
"compressionModeStacked": "Stacked",
"compressionModeStackedDesc": "RTK tool-output filtering followed by Caveman message compression"
"compressionModeStackedDesc": "RTK tool-output filtering followed by Caveman message compression",
"httpProxy": "HTTP Proxy",
"1proxy": "1proxy",
"proxySubTabsAria": "Proxy sections",
"requestBodyLimitTitle": "Request Body Limit",
"requestBodyLimitDescription": "Maximum API payload size accepted before a request body is parsed. Dedicated upload routes keep at least their built-in 100 MB limit.",
"requestBodyLimitInputLabel": "Request body limit in MB",
"requestBodyLimitEmptyError": "Enter a limit in MB",
"requestBodyLimitWholeNumberError": "Use a whole number",
"requestBodyLimitMinimumError": "Minimum is {min} MB",
"requestBodyLimitMaximumError": "Maximum is {max} MB",
"requestBodyLimitLoadFailed": "Failed to load request limit settings",
"requestBodyLimitSaveSuccess": "Request body limit saved",
"requestBodyLimitSaveFailed": "Failed to save request body limit",
"requestBodyLimitSaving": "Saving...",
"requestBodyLimitSave": "Save",
"requestBodyLimitCurrent": "Current: {value}"
},
"contextRtk": {
"title": "RTK Engine",

View File

@@ -659,6 +659,7 @@
"analytics": "Analytics",
"costs": "Costs",
"health": "Health",
"proxy": "پروکسی",
"limits": "Limits & Quotas",
"cliTools": "CLI Tools",
"media": "Media",
@@ -3450,7 +3451,23 @@
"compressionModeRtk": "RTK",
"compressionModeRtkDesc": "Command-aware tool output filtering",
"compressionModeStacked": "Stacked",
"compressionModeStackedDesc": "RTK tool-output filtering followed by Caveman message compression"
"compressionModeStackedDesc": "RTK tool-output filtering followed by Caveman message compression",
"httpProxy": "HTTP Proxy",
"1proxy": "1proxy",
"proxySubTabsAria": "Proxy sections",
"requestBodyLimitTitle": "Request Body Limit",
"requestBodyLimitDescription": "Maximum API payload size accepted before a request body is parsed. Dedicated upload routes keep at least their built-in 100 MB limit.",
"requestBodyLimitInputLabel": "Request body limit in MB",
"requestBodyLimitEmptyError": "Enter a limit in MB",
"requestBodyLimitWholeNumberError": "Use a whole number",
"requestBodyLimitMinimumError": "Minimum is {min} MB",
"requestBodyLimitMaximumError": "Maximum is {max} MB",
"requestBodyLimitLoadFailed": "Failed to load request limit settings",
"requestBodyLimitSaveSuccess": "Request body limit saved",
"requestBodyLimitSaveFailed": "Failed to save request body limit",
"requestBodyLimitSaving": "Saving...",
"requestBodyLimitSave": "Save",
"requestBodyLimitCurrent": "Current: {value}"
},
"contextRtk": {
"title": "RTK Engine",

View File

@@ -659,6 +659,7 @@
"analytics": "Analytics",
"costs": "Kustannukset",
"health": "Terveys",
"proxy": "Välityspalvelin",
"limits": "Rajoitukset ja kiintiöt",
"cliTools": "CLI-työkalut",
"media": "Media",
@@ -2719,6 +2720,22 @@
"systemPrompt": "Järjestelmäkehote",
"thinkingBudget": "Miettivä budjetti",
"proxy": "Välityspalvelin",
"httpProxy": "HTTP Proxy",
"1proxy": "1proxy",
"proxySubTabsAria": "Proxy sections",
"requestBodyLimitTitle": "Request Body Limit",
"requestBodyLimitDescription": "Maximum API payload size accepted before a request body is parsed. Dedicated upload routes keep at least their built-in 100 MB limit.",
"requestBodyLimitInputLabel": "Request body limit in MB",
"requestBodyLimitEmptyError": "Enter a limit in MB",
"requestBodyLimitWholeNumberError": "Use a whole number",
"requestBodyLimitMinimumError": "Minimum is {min} MB",
"requestBodyLimitMaximumError": "Maximum is {max} MB",
"requestBodyLimitLoadFailed": "Failed to load request limit settings",
"requestBodyLimitSaveSuccess": "Request body limit saved",
"requestBodyLimitSaveFailed": "Failed to save request body limit",
"requestBodyLimitSaving": "Saving...",
"requestBodyLimitSave": "Save",
"requestBodyLimitCurrent": "Current: {value}",
"pricing": "Hinnoittelu",
"storage": "Varastointi",
"policies": "Käytännöt",

Some files were not shown because too many files have changed in this diff Show More