Compare commits

..

1 Commits

Author SHA1 Message Date
Anton
ed34d16dc9 feat(settings): surface Codex Fast Tier toggle in Settings > AI (#2440)
Integrated into release/v3.8.1
2026-05-20 16:35:25 -03:00
3449 changed files with 99984 additions and 573185 deletions

View File

@@ -0,0 +1,52 @@
---
name: capture-release-evidences-cx
description: Automatically run a browser-automation agent to visually validate all new UI features from the current release and capture evidence WebP recordings of the changes.
---
# Capture Release Evidences Workflow
Use this workflow to automatically drive a browser-automation agent to explore the newly deployed or locally running application and record evidence of the UI changes introduced in the latest release.
> **Tool mapping note (v3.8):** The `browser_subagent` tool referenced below is specific to an earlier agent runtime. In Claude Code, substitute with the available browser MCP tools (e.g. `mcp__claude-in-chrome__*`) for navigation/screenshots, plus the `Write` tool for saving artifacts. The high-level steps remain the same regardless of the browser-automation surface in use.
## Prerequisites
- OmniRoute must be actively running and accessible (e.g. locally at `http://localhost:20128` or on the Local VPS at `http://192.168.0.15:20128`).
- The user must provide the target URL to be tested, or default to `http://192.168.0.15:20128`.
## Workflow Steps
### 1. Identify Target Features
Review the `CHANGELOG.md` for the latest version to map out the new UI elements. For example:
- **CLI Tools Settings**
- **New Provider/Model Listings (e.g., Gemini 3.1, Qoder PAT)**
- **New Feature Modals**
### 2. Run the Browser Subagent
For each identified feature, invoke the `browser_subagent` using the `default_api:browser_subagent` tool.
**Important Task Guidelines for the Subagent:**
- `TaskName`: Give it a clear name like "Validate CLIProxyAPI Tool Tab".
- `TaskSummary`: "Navigate to the CLI Tools tab and verify the new Integration settings."
- `Task`: Provide unambiguous instructions for the subagent, such as: "Navigate to http://192.168.0.15:20128/dashboard. Click on the 'Settings' or 'CLI Tools' nav link. Scroll down to find the CLIProxyAPI integration card. Hover over it to trigger UI state. Verify the components render correctly and exit."
- `RecordingName`: Ensure it describes the feature (e.g. `v3_4_5_cli_proxy_api`). This is required and strictly automatically saved as a WebP artifacts video by the system.
_(Note: The `browser_subagent` automatically creates a WebP recording named by the `RecordingName` parameter. No additional tools for screenshots are needed.)_
### 3. Generate Report Artifact
After the `browser_subagent` finishes its sessions, generate a final Markdown artifact (using `Write` and `IsArtifact=true`) to present the recordings inline to the user using the `![caption](/absolute/path/to/media.webp)` syntax.
### Example Invocation
\```json
{
"TaskName": "Validating Qoder PAT Configuration UI",
"TaskSummary": "Validates the Qoder provider configuration modal",
"Task": "Go to http://192.168.0.15:20128/dashboard. Click on the 'Providers' tab. Find 'Qoder' in the list. Click 'Add Token' or 'Configure'. Type 'test_token' and submit. Return when done.",
"RecordingName": "qoder_pat_ui_validation"
}
\```

View File

@@ -0,0 +1,45 @@
---
name: deploy-vps-akamai-cx
description: Deploy the latest OmniRoute code to the Akamai VPS (69.164.221.35)
---
# Deploy to Akamai VPS Workflow
Deploy OmniRoute to the Akamai VPS using `npm pack + scp` + PM2.
## Codex Execution Notes
- Treat `// turbo` / `// turbo-all` as instructions to use `multi_tool_use.parallel` only for independent commands. Do not parallelize dependent build, copy, install, restart, and verification steps.
- Report each remote result explicitly before finishing.
**Akamai VPS:** `69.164.221.35`
**Process manager:** PM2 (`omniroute`)
**Port:** `20128`
## Steps
### 1. Build + pack locally
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts
```
### 2. Copy to Akamai VPS and install
// turbo-all
```bash
scp omniroute-*.tgz root@69.164.221.35:/tmp/
```
```bash
ssh root@69.164.221.35 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Akamai done'"
```
### 3. Verify the deployment
```bash
curl -s -o /dev/null -w 'AKAMAI HTTP %{http_code}\n' http://69.164.221.35:20128/
```

View File

@@ -0,0 +1,56 @@
---
name: deploy-vps-both-cx
description: Deploy the latest OmniRoute code to BOTH the Akamai VPS and the Local VPS
---
# Deploy to VPS (Both) Workflow
Deploy OmniRoute to the production VPSs using `npm pack + scp` + PM2.
**Akamai VPS:** `69.164.221.35`
**Local VPS:** `192.168.0.15`
**Process manager:** PM2 (`omniroute`)
**Port:** `20128`
**PM2 entry:** `/usr/lib/node_modules/omniroute/app/server.js`
> [!IMPORTANT]
> The npm registry rejects packages > 100MB, so deployment uses **npm pack + scp**.
## Codex Execution Notes
- Treat `// turbo` / `// turbo-all` as instructions to use `multi_tool_use.parallel` only for independent commands.
- Build/package once first. After the artifact exists, copy/install/verify on Akamai and Local may run in parallel if they do not depend on each other.
- Report each VPS result explicitly before finishing.
## Steps
### 1. Build + pack locally
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts
```
### 2. Copy to both VPS and install
// turbo-all
```bash
scp omniroute-*.tgz root@69.164.221.35:/tmp/ && scp omniroute-*.tgz root@192.168.0.15:/tmp/
```
```bash
ssh root@69.164.221.35 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Akamai done'"
```
```bash
ssh root@192.168.0.15 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Local done'"
```
### 3. Verify the deployment
```bash
curl -s -o /dev/null -w 'AKAMAI HTTP %{http_code}\n' http://69.164.221.35:20128/
curl -s -o /dev/null -w 'LOCAL HTTP %{http_code}\n' http://192.168.0.15:20128/
```

View File

@@ -0,0 +1,45 @@
---
name: deploy-vps-local-cx
description: Deploy the latest OmniRoute code to the Local VPS (192.168.0.15)
---
# Deploy to Local VPS Workflow
Deploy OmniRoute to the Local VPS using `npm pack + scp` + PM2.
## Codex Execution Notes
- Treat `// turbo` / `// turbo-all` as instructions to use `multi_tool_use.parallel` only for independent commands. Do not parallelize dependent build, copy, install, restart, and verification steps.
- Report each remote result explicitly before finishing.
**Local VPS:** `192.168.0.15`
**Process manager:** PM2 (`omniroute`)
**Port:** `20128`
## Steps
### 1. Build + pack locally
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts
```
### 2. Copy to Local VPS and install
// turbo-all
```bash
scp omniroute-*.tgz root@192.168.0.15:/tmp/
```
```bash
ssh root@192.168.0.15 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Local done'"
```
### 3. Verify the deployment
```bash
curl -s -o /dev/null -w 'LOCAL HTTP %{http_code}\n' http://192.168.0.15:20128/
```

View File

@@ -0,0 +1,368 @@
---
name: generate-release-cx
description: Create a new release, bump version up to the .10 patch threshold, update changelog, and manage Pull Requests
---
# Generate Release Workflow
Bump version, finalize CHANGELOG, commit, open a **PR to main** and wait for user confirmation before tagging, publishing, and deploying.
## Codex Execution Notes
- Treat `// turbo` / `// turbo-all` as instructions to use `multi_tool_use.parallel` for independent reads, checks, and GitHub calls.
- When the workflow says `notify_user` or `BlockedOnUser: true`, present the report/status in the final response and stop. Do not continue into the next phase until the user explicitly approves.
> **VERSION RULE: Always use PATCH bumps (3.x.y → 3.x.y+1)**
> NEVER use `npm version minor` or `npm version major`.
> Always use: `npm version patch --no-git-tag-version`
> The threshold rule: when `y` reaches 10, bump to `3.(x+1).0` — e.g. `3.8.10` → `3.9.0`.
> **🔴 SINGLE BRANCH RULE**: The `release/vX.Y.Z` branch is the **ONLY** development branch for the entire release cycle. ALL work — bug fixes, feature implementations, PR integrations, issue resolutions — MUST be committed directly on this branch. Never create separate `fix/`, `feat/`, or topic branches. When running `/resolve-issues`, `/implement-features`, or `/review-prs`, always work on the current release branch.
---
## ⚠️ Two-Phase Flow
```
Phase 1 (automated): bump → docs → i18n → commit → push → open PR
↕ 🛑 STOP: Notify user, wait for PR confirmation
Phase 2 (post-merge): tag → publish → GitHub release → Docker → deploy
```
**NEVER push directly to main or create tags before the user confirms the PR.**
---
## Phase 0: Security Verification (MANDATORY)
Before creating the release, you must ensure the codebase and supply chain are secure and free of known vulnerabilities.
1. **Run Local Dependencies Audit:**
```bash
npm audit
```
_Fix any `high` or `critical` vulnerabilities identified._
2. **Check GitHub CodeQL & Dependabot Alerts:**
Navigate to the repository's **Security** tab on GitHub, or use the project's `vulnerability-scanner` skill to analyze active alerts. Ensure all static analysis findings (e.g., prototype pollution, insecure randomness, ReDoS, shell injections) are addressed and logically committed on a target branch.
---
## Phase 1: Pre-Merge
### 1. Create release branch
```bash
git checkout -b release/v3.x.y
```
### 2. Determine and sync version
Check current version in `package.json`:
```bash
grep '"version"' package.json
```
> **🔴 BRANCH-VERSION PARITY RULE**: The logical version in `package.json` MUST exactly match the release branch name. For example, if you are on `release/v3.7.0`, the version in `package.json` MUST be `3.7.0`.
>
> - If this is the FIRST time generating a release for a new minor/major branch (e.g., bumping from 3.6.9 to 3.7.0), you MUST ensure the version is bumped to match the new branch logic.
> - If you are just bumping a patch on the current branch (e.g., 3.6.9 to 3.6.10), use:
> `npm version patch --no-git-tag-version`
> **⚠️ ATOMIC COMMIT RULE — Version bump MUST happen before committing feature files.**
>
> **CORRECT order:**
>
> 1. `npm version patch --no-git-tag-version` ← bump first
> 2. implement features / fix bugs
> 3. `git add -A && git commit -m "chore(release): v3.x.y — all changes in ONE commit"`
>
> **OR if features are already staged:**
>
> 1. implement features (do NOT commit yet)
> 2. `npm version patch --no-git-tag-version` ← bump before committing
> 3. `git add -A && git commit -m "chore(release): v3.x.y — all changes in ONE commit"`
>
> **NEVER do this (creates version mismatch in git history):**
>
> - ~~commit features → then bump version → commit package.json separately~~
>
> This ensures that `git show v3.x.y` always contains both code changes and the version bump together.
> The GitHub release tag will point to a commit that includes ALL changes for that version.
### 3. Regenerate lock file (REQUIRED after version bump)
**Mandatory** — skipping causes `@swc/helpers` lock mismatch and CI failures:
```bash
npm install
```
### 4. Finalize CHANGELOG.md
> **🔴 NO MIXUPS RULE**: Ensure you do NOT mix the backlog of the previous version with the new one. The new version section must ONLY contain the features and fixes for the current release.
Replace the `[Unreleased]` header with the new version and date.
Keep an empty `## [Unreleased]` section above it, separated by a horizontal rule (`---`).
```markdown
## [Unreleased]
---
## [3.7.0] — 2026-04-19
### ✨ New Features
- ...
### 🐛 Bug Fixes
- ...
---
## [3.6.9] — 2026-04-19
```
### 5. Update openapi.yaml version ⚠️ MANDATORY
> **CI will fail** if `docs/reference/openapi.yaml` version ≠ `package.json` version (`check:docs-sync` enforces this).
// turbo
```bash
VERSION=$(node -p "require('./package.json').version")
sed -i "s/ version: .*/ version: $VERSION/" docs/reference/openapi.yaml
echo "✓ openapi.yaml → $VERSION"
for dir in electron open-sse; do
if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then
(cd "$dir" && npm version "$VERSION" --no-git-tag-version --allow-same-version > /dev/null)
echo "✓ $dir/package.json → $VERSION"
fi
done
# Re-run install to assert the workspace lockfile is updated
npm install
```
### 6. Update README.md and i18n docs
Manually perform these documentation updates (there is no `/update-docs` workflow — it was deprecated in v3.8):
- Update feature table rows and "What's new in vX.Y.Z" section in `README.md`
- Sync feature changes to all 40 language `docs/i18n/*/README.md` files (use the same row edits across each translated README)
- Update the relevant `docs/<AREA>.md` if architecture or counts changed
- Re-run `npm run check:docs-sync` and `npm run check:docs-all` to catch drift
### 7. Run tests
// turbo
```bash
npm test
```
All tests must pass before creating the PR.
### 8. Stage, commit, and push
// turbo-all
```bash
git add -A
git commit -m "chore(release): v3.x.y — summary of changes"
git push origin release/v3.x.y
```
### 9. Open PR to main
### 9. Open PR to main
// turbo
```bash
VERSION=$(node -p "require('./package.json').version")
# Extract the exact changelog entry for this version from the root CHANGELOG.md
awk "/^## \\[$VERSION\\]/{flag=1; print; next} /^---/{if(flag) {flag=0; exit}} flag" CHANGELOG.md > /tmp/changelog_body.txt
# Append test status and next steps
echo "" >> /tmp/changelog_body.txt
echo "### Tests" >> /tmp/changelog_body.txt
echo "- All tests pass" >> /tmp/changelog_body.txt
echo "" >> /tmp/changelog_body.txt
echo "### ⚠️ After merging: run Phase 2 steps to tag, publish, and deploy." >> /tmp/changelog_body.txt
gh pr create \
--repo diegosouzapw/OmniRoute \
--base main \
--head release/v$VERSION \
--title "Release v$VERSION" \
--body-file /tmp/changelog_body.txt
```
### 10. 🛑 STOP — Notify User & Await PR Confirmation
**This is a mandatory stop point.** Present the report in the final response and stop. Do not continue to the next phase until the user explicitly approves.
Inform the user:
- PR URL
- Summary of changes
- Test results
- List of files changed
**DO NOT proceed to Phase 2 until the user confirms the PR looks good and merges it.**
---
## Phase 2: Post-Merge Validation (Local VPS)
> Run these steps only AFTER the user has merged the PR into `main` and all CI jobs have passed.
### 11. Deploy to Local VPS for Final Validation (MANDATORY)
Before cutting the official git tag and publishing to the world, deploy the `main` branch to the Local VPS for a final homologation test.
```bash
git checkout main
git pull origin main
# Build and pack locally
cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts
# Deploy to LOCAL VPS (192.168.0.15)
scp omniroute-*.tgz root@192.168.0.15:/tmp/
ssh root@192.168.0.15 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Local done'"
# Verify
curl -s -o /dev/null -w "LOCAL: HTTP %{http_code}\n" http://192.168.0.15:20128/
```
### 12. 🛑 STOP — Notify User & Await Final OK
**This is a mandatory stop point.**
Inform the user that the `main` branch is now running on the Local VPS.
Wait for the user to manually test and give the **OK**.
**DO NOT proceed to Phase 3 until the user confirms the local deploy is stable.**
---
## Phase 3: Official Launch
> Run these steps only AFTER the user gives the final OK from the Phase 2 local validation.
### 13. Create Git Tag and GitHub Release (MANDATORY)
// turbo
```bash
git checkout main
git pull origin main
VERSION=$(node -p "require('./package.json').version")
# Extracts the changelog section for this version
NOTES=$(awk "/^## \\[$VERSION\\]/{flag=1; next} /^---/{if(flag) {flag=0; exit}} flag" CHANGELOG.md | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
if [ -z "$NOTES" ]; then NOTES="OmniRoute v$VERSION Release"; fi
git tag -a "v$VERSION" -m "Release v$VERSION"
git push origin "v$VERSION"
gh release create "v$VERSION" --repo diegosouzapw/OmniRoute --title "v$VERSION" --notes "$NOTES" --target main || gh release edit "v$VERSION" --repo diegosouzapw/OmniRoute --title "v$VERSION" --notes "$NOTES"
```
### 14. 🐳 Trigger Docker Hub build (MANDATORY — keep npm and Docker in sync)
> **CRITICAL**: Docker Hub and npm MUST always publish the same version.
> The Docker image is built automatically via GitHub Actions when a new tag is pushed.
> After pushing the tag in step 13, **verify the workflow runs**:
```bash
# Verify the Docker workflow triggered
gh run list --repo diegosouzapw/OmniRoute --workflow docker-publish.yml --limit 3
# Wait for the Docker build to complete (usually 510 min)
gh run watch --repo diegosouzapw/OmniRoute
```
### 15. Publish to NPM (Optional/Automated)
Normally handled by CI, but if manual publish is required:
```bash
npm publish
```
### 16. Deploy to AKAMAI VPS (Production)
Now that the release is officially cut, deploy it to the Akamai VPS.
```bash
# Deploy to AKAMAI VPS (69.164.221.35)
scp omniroute-*.tgz root@69.164.221.35:/tmp/
ssh root@69.164.221.35 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Akamai done'"
# Verify
curl -s -o /dev/null -w "AKAMAI: HTTP %{http_code}\n" http://69.164.221.35:20128/
```
## Phase 4: Release Monitoring & Artifact Validation
> After triggering the official release, actively monitor the CI pipelines until all artifacts are successfully generated. If any pipeline fails, stop and apply the necessary corrections before continuing.
### 18. Monitor CI Pipelines
Wait for and verify the successful completion of the following automated jobs:
1. **Docker Hub Publish**
2. **Electron Build**
3. **NPM Registry Publish** (Check with `npm info omniroute version`)
```bash
# Monitor Docker Hub workflow
gh run list --repo diegosouzapw/OmniRoute --workflow docker-publish.yml --limit 1
gh run watch <RUN_ID>
# Monitor Electron build
gh run list --repo diegosouzapw/OmniRoute --workflow electron-release.yml --limit 1
gh run watch <RUN_ID>
# Verify NPM version
npm info omniroute version
```
### 19. Handle Failures (If Any)
If a workflow fails:
- Use `gh run view <RUN_ID> --log-failed` to identify the error.
- Apply the fix on the `main` branch.
- If necessary, re-trigger the workflow using `gh workflow run <workflow_name.yml> --repo diegosouzapw/OmniRoute --ref v3.x.y`
### 20. Preserve release branch
```bash
# Branch is kept for historical purposes. Do not delete.
```
---
## Notes
- Ensure CHANGELOG, README and `docs/*` are current BEFORE this workflow — run `npm run check:docs-all` and `/version-bump` first (there is no `/update-docs` workflow anymore)
- The `prepublishOnly` script runs `npm run build:cli` automatically during `npm publish`
- After npm publish, verify with `npm info omniroute version`
- Lock file sync errors are caused by skipping `npm install` after version bump
- Use `gh auth switch -u diegosouzapw` if git push fails with wrong account
## Known CI Pitfalls
| CI failure | Cause | Fix |
| ------------------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------- |
| `[docs-sync] FAIL - OpenAPI version differs from package.json` | Skipped step 5 — `docs/reference/openapi.yaml` version not updated | Run step 5 (`sed -i ...`) and commit |
| `[docs-sync] FAIL - CHANGELOG.md first section must be "## [Unreleased]"` | `## [Unreleased]` missing or not at top of CHANGELOG | Add `## [Unreleased]\n\n---\n` before the first versioned `## [x.y.z]` |
| Electron Linux `.deb` build fails (`FpmTarget` error) | `fpm` Ruby gem not installed on `ubuntu-latest` runner | Already fixed in `electron-release.yml` (`gem install fpm` step) |
| Docker Hub `502 error writing layer blob` | Transient Docker Hub network error during ARM64 push | Re-run the Docker publish workflow; no code change needed |

View File

@@ -0,0 +1,51 @@
---
name: issue-triage-cx
description: How to respond to GitHub issues with insufficient information
---
# Issue Triage Workflow
Respond to GitHub issues that need more information before they can be investigated.
## Steps
### 1. Identify issues needing triage
```bash
gh issue list --state open --limit 20
```
### 2. Evaluate each issue
Check if the issue has:
- Clear reproduction steps
- Environment details (OS, Node.js version, OmniRoute version)
- Error logs/screenshots
- Expected vs actual behavior
### 3. Respond with triage template
For issues missing information:
```markdown
Thank you for reporting this issue! To help us investigate, please provide:
1. **OmniRoute version**: (`omniroute --version`)
2. **Node.js version**: (`node --version`)
3. **Operating system**: (e.g., Ubuntu 24.04, macOS 15, Windows 11)
4. **Installation method**: (npm, Docker, source)
5. **Steps to reproduce**: (exact commands/actions that trigger the issue)
6. **Error logs**: (paste relevant logs from the console)
7. **Expected behavior**: (what should happen)
This will help us debug and resolve your issue faster. 🙏
```
### 4. Label the issue
Add appropriate labels: `needs-info`, `bug`, `enhancement`, `question`, etc.
```bash
gh issue edit <NUMBER> --add-label "needs-info"
```

View File

@@ -0,0 +1,173 @@
---
name: resolve-issues-cx
description: Fetch all open GitHub issues, analyze bugs, resolve what's possible, triage the rest, wait for user validation, then commit and release
---
# /resolve-issues — Automated Issue Resolution Workflow
## Overview
This workflow fetches all open issues from the project's GitHub repository, classifies them, analyzes bugs, proposes a resolution plan, waits for user validation, and ONLY THEN implements the fixes, commits, and closes the issues on the current release branch (`release/vX.Y.Z`). It does NOT merge or release automatically — the release branch is later merged via PR to main.
## Codex Execution Notes
- Treat `// turbo` / `// turbo-all` as instructions to use `multi_tool_use.parallel` for independent reads, checks, and GitHub calls.
- The initial report/plan is a hard stop. Do not edit code, close issues, or commit until the user explicitly approves the report.
- Keep classification and bug analysis bounded enough to produce the user-facing report before deep implementation work.
> **BRANCH RULE**: All work MUST happen on the current `release/vX.Y.Z` branch. Never create separate `fix/` branches. If no release branch exists yet, create one first using `/generate-release` Phase 1 steps 15.
> **⛔ PR PROHIBITION**: If a fix is associated with a contributor's PR, you MUST merge their PR — NEVER close it and re-implement the fix yourself. See `/review-prs` workflow for the full policy. The `gh pr close` command is FORBIDDEN unless the repository owner explicitly requests it.
## Steps
### 1. Identify the GitHub Repository
// turbo
- Run: `git -C <project_root> remote get-url origin` to extract the owner/repo
- Parse the owner and repo name from the URL
### 2. Ensure Release Branch Exists
// turbo
Before doing any work, ensure you are on the current release branch:
```bash
# Check current branch
git branch --show-current
# If on main, determine next version and create the release branch
VERSION=$(node -p "require('./package.json').version")
NEXT=$(node -p "const [a,b,c]=('$VERSION').split('.').map(Number); c>=9?a+'.'+(b+1)+'.0':a+'.'+b+'.'+(c+1)")
git checkout -b release/v$NEXT
npm version patch --no-git-tag-version
npm install
```
If already on a `release/vX.Y.Z` branch, continue working there.
### 3. Fetch All Open Issues
// turbo-all
**⚠️ CRITICAL**: The JSON output of `gh issue list` can be truncated by the tool, silently hiding issues. You MUST use the two-step approach below to guarantee **all** issues are fetched.
**Step 3a — Get Issue numbers only** (small output, never truncated):
- Run: `gh issue list --repo <owner>/<repo> --state open --limit 500 --json number --jq '.[].number'`
- This outputs one issue number per line. Count them and confirm total.
**Step 3b — Fetch full metadata for each Issue** (one call per issue):
- For each issue number from step 3a, run:
`gh issue view <NUMBER> --repo <owner>/<repo> --json number,title,labels,body,comments,createdAt,author`
- You may batch these into parallel calls (up to 4 at a time).
- Sort by oldest first (FIFO).
### 4. Classify Each Issue
For each issue, determine its type:
- **Bug** — Has `bug` label, or body contains error messages, stack traces, "doesn't work", "broken", "crash", "error"
- **Feature Request** — Has `enhancement`/`feature` label, or body describes new functionality
- **Question** — Has `question` label, or is asking "how to" something
- **Other** — Anything else
Focus ONLY on **Bugs** for resolution. Feature requests and questions should be skipped with a note in the final report.
### 5. Deep-Read Each Bug Issue (One-by-One Analysis)
**IMPORTANT**: Read each bug issue thoroughly, one at a time, before moving to the next. This is NOT a batch process — each issue needs focused attention.
#### 5a. Understand the Problem
For each bug issue, perform the full analysis:
1. **Read the entire body** — including Description, Steps to Reproduce, Expected/Actual Behavior, Error Logs, and Screenshots
2. **Read ALL comments** — including bot triage comments (Kilo, etc.) and owner/community responses. Pay attention to:
- Whether someone already responded with a fix
- Whether a community member confirmed the issue is resolved
- Whether the issue was marked as duplicate by a bot. **WARNING: DO NOT blindly trust bot duplicate labels (e.g., kilo-duplicate). Bots make mistakes. You MUST read the full conversation and do your own independent analysis to determine if it is truly a duplicate or a distinct bug.**
3. **Identify the claimed error** — extract the exact error message, status code, and provider/model involved
#### 5b. Check Information Sufficiency
Verify the issue contains enough to act on:
- [ ] Clear description of the problem
- [ ] Steps to reproduce OR error logs
- [ ] Provider/model/version information
- [ ] Expected vs actual behavior
#### 5c. Determine Issue Disposition
For each bug, classify into one of 5 actions:
| Disposition | When to Apply | Action |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| **✅ CLOSE — Already Fixed** | Owner responded with fix + no user follow-up, OR community confirmed fix | Close with comment citing which version fixed it |
| **✅ CLOSE — Duplicate** | You have independently verified the issue is a duplicate (do NOT rely solely on bot flags) + user provides no new info | Close referencing the original issue |
| **✅ CLOSE — Stale** | We requested logs/info > 7 days ago with no reply | Close thanking the user, invite to reopen if needed |
| **📝 RESPOND — Needs Info** | Issue is real but missing critical reproduction details | Comment asking for specifics per `/issue-triage` |
| **📝 RESPOND — User Config** | Error is caused by unsupported env (Node version, wrong model path, missing API enablement) | Comment explaining the user-side fix |
| **🔧 FIX — Code Change** | Root cause is confirmed in the codebase | Research, propose solution in report, wait for approval |
#### 5d. For "FIX — Code Change" Issues
Before coding, perform deep source analysis to formulate a plan:
1. **Search the codebase**`grep_search` for error strings, relevant function names, affected files
2. **Search the web** — for upstream API changes, SDK updates, or breaking changes that explain the bug
3. **Read the full source file** — don't rely on grep snippets; understand the surrounding logic
4. **Verify the root cause** — confirm the bug is reproducible based on the code, not just a user misconfiguration
5. **Formulate a proposed solution** — detail the exact files and lines you will change and how you will solve it.
6. **Create an Implementation Plan file** — write your proposed solution to `_tasks/features-vX.Y.Z/<ISSUE_NUMBER>-<short-description>.plan.md` (e.g. `_tasks/features-v3.7.6/1810-auto-restore-probe-failed-db.plan.md`) where `vX.Y.Z` is the current branch version. The plan should contain an Overview, Pre-Implementation Checklist, and detailed Implementation Steps (Files, Changes).
7. **DO NOT modify the codebase yet** — wait for user approval on your report and plan first.
#### 5e. For "RESPOND" Issues
Post a substantive comment that:
- Acknowledges the specific error they reported
- Explains the likely root cause
- Provides concrete steps to resolve (version upgrade, env var fix, model path correction)
- Asks for follow-up info if needed
**Do NOT post generic template responses.** Every comment should reference the user's specific error messages and environment.
### 6. Generate Report & Wait for Validation
Present a summary report to the user detailing your proposed actions. For any bugs that need fixing, explicitly explain your proposed solution (files to change and logic) and point out that it will be implemented on the release branch (`release/vX.Y.Z`) after approval.
| Issue | Title | Status | Proposed Action / Version |
| ----- | ----- | ------------- | ----------------------------------------- |
| #N | Title | ✅ Close | Already fixed / duplicate (explain why) |
| #N | Title | 🔧 Propose | Explanation of the code fix to be applied |
| #N | Title | 📝 Respond | Guidance comment to be posted |
| #N | Title | ❓ Needs Info | Triage comment to be posted |
| #N | Title | ⏭️ Skip | Feature request / not a bug |
> **⚠️ IMPORTANT**: Do NOT implement code changes, commit, push, or close issues at this step.
> Wait for the user to review the proposed fixes and respond with **OK** before proceeding.
- If the user says **OK** or approves → Proceed to step 7
- If the user requests changes → Adjust the proposed solution and present the report again
- If the user rejects → Revert any accidental changes and stop
### 7. Implement Fixes, Run Tests & Commit (only after user approval)
After the user validates and gives the OK:
1. **Implement the fixes** — modify the codebase according to the approved plan.
2. **Run tests**`npm run test:all` (or the specific test file) to ensure 100% pass.
3. **Update CHANGELOG.md** with all new bug fix entries.
4. **Commit** each fix individually on the release branch with message format: `fix: <description> (#<issue_number>)`.
5. **Push** the release branch: `git push origin release/vX.Y.Z`.
6. **Close resolved issues immediately**. For each issue that was marked as Fixed, run:
`gh issue close <NUMBER> --repo <owner>/<repo> --comment "Thank you for reporting! This issue has been fixed and will be included in the next release (vX.Y.Z)."`
7. Likewise, close `Duplicate` issues referencing the original, close `Needs Info` if stale, and post the required comments.
8. If the project runs automatic releases or needs a PR, proceed to run `/generate-release` workflow Phase 1 steps 710 (tests → commit → push → open PR to main → wait for user).
If NO fixes were committed, skip closing and source control steps and just conclude the workflow.

View File

@@ -0,0 +1,126 @@
---
name: review-discussions-cx
description: Read all open GitHub Discussions, summarize them, respond to pending ones, and create issues from actionable feature requests
---
# /review-discussions — GitHub Discussions Review & Response Workflow
## Overview
This workflow reads all open GitHub Discussions, generates a categorized summary, identifies which ones need a response, drafts and posts replies, and optionally creates issues from actionable feature requests. It follows the same flow used for Issues but adapted for the Discussions forum.
> **Tool mapping note (v3.8):** Where steps below say `browser_subagent`, modern runtimes should substitute with the `gh` CLI — `gh api graphql` for reading discussions and mutations for posting comments. `WebFetch` is acceptable for read-only HTML scraping when GraphQL is overkill, but prefer `gh` for any write actions.
## Codex Execution Notes
- Treat `// turbo` / `// turbo-all` as instructions to use `multi_tool_use.parallel` for independent reads and GitHub/browser fetches.
- The summary report is a hard stop. Do not post discussion replies or create issues until the user explicitly approves.
// turbo-all
## Steps
### 1. Identify the GitHub Repository
- Run: `git -C <project_root> remote get-url origin` to extract the owner/repo
- Parse the owner and repo name from the URL
### 2. Fetch All Open Discussions
- Use `WebFetch` to fetch `https://github.com/<owner>/<repo>/discussions`
- Parse the discussion list to get all discussion titles, IDs, authors, categories, and dates
- For each discussion, fetch the individual page to read the full content and all comments/replies
### 3. Summarize All Discussions
For each discussion, extract:
- **Title** and **#Number**
- **Author** (GitHub username)
- **Category** (Announcements, General, Ideas, Q&A, Show and tell)
- **Date** created
- **Summary** of the original post (1-2 sentences)
- **Comments count** and key participants
- **Your previous response** (if any)
- **Pending action** — whether a response or follow-up is needed
### 4. Present Summary Report to User
Present the full summary to the user organized by category, using a table:
| # | Category | Title | Author | Date | Status |
| --- | -------- | ----- | ------ | ------ | ----------------- |
| #N | Ideas | Title | @user | Mar 23 | ⚠️ Needs response |
| #N | Q&A | Title | @user | Mar 9 | ✅ Answered |
| #N | General | Title | @user | Mar 19 | ⚠️ Needs response |
Highlight:
- **⚠️ Needs response** — No reply from maintainer, or a follow-up comment was left unanswered
- **✅ Answered** — Maintainer already responded
- **🐛 Bug reported** — A bug was mentioned that needs tracking
- **💡 Actionable** — Contains a concrete feature request that could become an issue
### 5. Draft & Post Responses
For each discussion that needs a response, draft a reply following these guidelines:
#### Response Style
- **Friendly and professional** — Start with "Hey @username!"
- **Acknowledge the contribution** — Thank the user for their input
- **Be specific** — Reference existing features, settings, or dashboard pages if the feature already exists
- **Provide workarounds** — If the request isn't implemented yet, suggest current alternatives
- **Commit to action** — If the request is valid, state that you'll open an issue or add it to the roadmap
- **Keep it concise** — 3-5 paragraphs max
#### Posting via Browser
- Use `browser_subagent` to navigate to each discussion and post the comment
- **IMPORTANT**: When typing text in GitHub comment boxes via the browser, use only plain ASCII characters:
- Use regular hyphens `-` instead of em-dashes
- Use `->` instead of arrow symbols
- Do NOT use emoji Unicode characters (the browser keyboard may fail on them)
- Use `**bold**` and `\`code\`` markdown formatting
- Click the green "Comment" button (or "Reply" for threaded replies) after typing
- Verify the comment was posted by checking the page shows the new comment
### 6. Create Issues from Actionable Feature Requests
For discussions that contain concrete, actionable feature requests:
1. Ask the user which ones should become issues
2. For each approved request, create a GitHub issue via `browser_subagent`:
- Navigate to `https://github.com/<owner>/<repo>/issues/new`
- **Title**: `<Feature Name> - <Short description>`
- **Body** should include:
- `## Feature Request` header
- `**Source:** Discussion #N by @author`
- `## Problem` — What limitation the user hit
- `## Proposed Solution` — How it could work
- `### Implementation Ideas` — Technical approach
- `### Current Workarounds` — What users can do today
- `## Additional Context` — Links to related issues/discussions
- Add `enhancement` label
- Click "Submit new issue" / "Create"
3. After creation, go back to the original discussion and post a comment linking to the new issue:
- "I've opened Issue #N to track this feature request. Follow along there for updates!"
### 7. Final Report
Present a final summary to the user:
| Discussion | Action Taken |
| ---------- | ---------------------------------- |
| #N — Title | Responded with workarounds |
| #N — Title | Responded + created Issue #N |
| #N — Title | Already answered, no action needed |
| #N — Title | Responded to follow-up comment |
## Notes
- This workflow is **interactive** — always present the summary and wait for user approval before posting responses or creating issues
- If the user says "pode responder" (or similar approval), proceed with posting all drafted responses
- For discussions in non-English languages, respond in the same language as the original post
- Always reference specific dashboard paths, config options, or code files when explaining existing features
- When a discussion reveals a bug, note it separately from feature requests

View File

@@ -0,0 +1,268 @@
---
name: review-prs-cx
description: Analyze open Pull Requests from the project's GitHub repository, generate a critical report, and optionally implement approved changes
---
# /review-prs — PR Review & Analysis Workflow
## ⛔ ABSOLUTE PROHIBITION — Read Before Anything Else
> **NEVER close a contributor's PR if you intend to use ANY of their code, ideas, or fixes.**
>
> **NEVER manually integrate contributor code into a release branch and then close their PR.**
>
> These actions are **STRICTLY FORBIDDEN** under all circumstances:
>
> 1. ❌ Closing a PR and cherry-picking/copying its code into a release branch
> 2. ❌ Closing a PR "because of conflicts" and re-implementing the same fix yourself
> 3. ❌ Closing a PR and committing a "similar" solution inspired by it
> 4. ❌ Using `gh pr close` on any PR whose content was or will be used
>
> **Why**: Closing a PR after taking the contributor's work means they get ZERO credit on GitHub — no "Merged" badge, no contribution graph entry, no public record. This is effectively stealing their contribution. An audit found this happened to **37 PRs** in the past.
>
> **The ONLY acceptable flow**: Resolve conflicts IN the contributor's branch, push fixes TO their branch, then merge THEIR PR via `gh pr merge`. See Step 7 and Step 8 for the exact procedure.
>
> **When to close a PR**: ONLY when the user (repository owner) explicitly requests it, OR when the PR is clearly spam/malicious, OR when the author themselves asks to close it. In ALL other cases, leave it open.
## Overview
This workflow fetches all open PRs from the project's GitHub repository, performs a critical analysis of each one, generates a detailed report, and waits for user approval before proceeding with implementation. **All improvements are committed on the current release branch** (`release/vX.Y.Z`).
> **BRANCH RULE**: PRs are ALWAYS merged into the current `release/vX.Y.Z` branch, NEVER directly into `main`. The release branch acts as a staging area — only after all PRs are integrated and tests pass does the release branch get merged into `main` via the `/generate-release` workflow.
## Codex Execution Notes
The source Claude command uses `// turbo` and `// turbo-all` as execution hints. In Codex, treat them explicitly as follows:
- `// turbo`: batch independent local reads and small `gh`/`git` calls with `multi_tool_use.parallel`.
- `// turbo-all`: fan out independent per-PR/per-issue calls in practical batches, usually up to 4 GitHub calls at a time.
- Do not expand Step 4 into exhaustive CI-log debugging before Step 6. Fetch numbers, metadata, diffs/review comments, quick merge/conflict status, and only inspect extra logs when they directly affect the verdict.
- Step 6 is a hard stop. In Codex, present the report in the final response and wait for the user before Step 7/8.
- Do not checkout PR branches, edit files, post PR comments, close PRs, merge, cherry-pick, or run broad fix/test loops until the user explicitly approves the report.
- If `gh pr diff` is too large, record the limit and use `gh pr view --json files` plus `git fetch refs/pull/...` with `git diff --stat` / `git diff --name-status`; only read targeted hunks needed for confirmed findings.
## Steps
### 1. Identify the GitHub Repository
- Read `package.json` to get the repository URL, or use the git remote origin URL
// turbo
- Run: `git -C <project_root> remote get-url origin` to extract the owner/repo
### 2. Ensure Release Branch Exists
// turbo
Before doing any work, ensure you are on the current release branch:
```bash
# Check current branch
git branch --show-current
# If on main, determine next version and create the release branch
VERSION=$(node -p "require('./package.json').version")
# Bump patch: e.g. 3.3.11 → 3.3.12
NEXT=$(node -p "const [a,b,c]=('$VERSION').split('.').map(Number); c>=9?a+'.'+(b+1)+'.0':a+'.'+b+'.'+(c+1)")
git checkout -b release/v$NEXT
npm version patch --no-git-tag-version
npm install
```
If already on a `release/vX.Y.Z` branch, continue working there.
### 3. Fetch Open Pull Requests
// turbo-all
**⚠️ CRITICAL**: The JSON output of `gh pr list` can be truncated by the tool, silently hiding PRs. You MUST use the two-step approach below to guarantee **all** PRs are fetched.
**Step 3a — Get PR numbers only** (small output, never truncated):
- Run: `gh pr list --repo <owner>/<repo> --state open --limit 500 --json number --jq '.[].number'`
- This outputs one PR number per line. Count them and confirm total.
**Step 3b — Fetch full metadata for each PR** (one call per PR):
- For each PR number from step 3a, run:
`gh pr view <NUMBER> --repo <owner>/<repo> --json number,title,author,headRefName,baseRefName,body,createdAt,additions,deletions,files`
- You may batch these into parallel calls (up to 4 at a time).
**Step 3c — Fetch diffs for each PR** (one call per PR, saved to /tmp):
- For each PR number, run:
`gh pr diff <NUMBER> --repo <owner>/<repo> > /tmp/pr<NUMBER>.diff`
- Then read each diff file with the appropriate file-read tool (`Read` in Claude Code; equivalent in your agent runtime).
- For each open PR, collect:
- PR number, title, author, branch, number of commits, date
- PR description/body
- Files changed (diff)
- Existing review comments (from bots or humans)
**Verification**: Confirm the count of PRs analyzed matches the count from step 3a before proceeding.
### 3.5 Redirect PR Base Branches to Release Branch
// turbo-all
**⚠️ CRITICAL**: Contributors typically open PRs targeting `main`. Before analyzing or merging, redirect ALL open PRs to target the current release branch instead.
```bash
# Get the current release branch name
RELEASE_BRANCH=$(git branch --show-current) # e.g. release/v3.5.4
# For each open PR that targets main, change its base to the release branch
for PR_NUM in $(gh pr list --repo <owner>/<repo> --state open --json number,baseRefName --jq '.[] | select(.baseRefName == "main") | .number'); do
echo "Redirecting PR #$PR_NUM$RELEASE_BRANCH"
gh pr edit "$PR_NUM" --repo <owner>/<repo> --base "$RELEASE_BRANCH"
done
```
This ensures:
1. PRs merge into the release branch, not directly into `main`
2. Merge conflict detection is accurate against the release branch
3. The release branch accumulates all changes before the final merge to `main`
4. If the release branch doesn't exist on remote yet, push it first: `git push origin $RELEASE_BRANCH`
### 4. Analyze Each PR — For each open PR, perform the following analysis:
#### 4a. Feature Assessment
- **Does it make sense?** Evaluate if the feature fills a real gap or solves a valid problem
- **Alignment** — Check if it aligns with the project's architecture and roadmap
- **Complexity** — Assess if the scope is reasonable or if it should be split
#### 4b. Code Quality Review
- Check for code duplication
- Evaluate error handling patterns (consistent with existing codebase?)
- Check naming conventions and code style
- Verify TypeScript types (any `any` usage, missing types?)
#### 4c. Security Review
- Check for missing authentication/authorization on new endpoints
- Check for injection vulnerabilities (URL params, SQL, XSS)
- Verify input validation on all user-controlled data
- Check for hardcoded secrets or credentials
#### 4d. Architecture Review
- Does the change follow existing patterns?
- Are there any breaking changes to public APIs?
- Is the database schema affected? Migration needed?
- Impact on performance (N+1 queries, missing indexes?)
#### 4e. Test Coverage
- Does the PR include tests?
- Are edge cases covered?
- Would existing tests break?
#### 4f. Cross-Layer (Global) Analysis
Perform a **global impact assessment** to verify whether the PR changes are complete across all layers of the application:
- **Backend → Frontend check**: If the PR adds or modifies backend-only resources (new endpoints, services, data models), evaluate whether corresponding frontend changes are missing:
- Does a new endpoint require a new screen/page in the dashboard?
- Should there be a new action button, menu item, or navigation link?
- Are there new data fields that should be displayed or editable in the UI?
- Does a new feature need a toggle, configuration panel, or status indicator?
- **Frontend → Backend check**: If the PR adds frontend elements, verify the backend support exists:
- Are the required API endpoints implemented?
- Is the data model sufficient for the new UI components?
- **Cross-cutting concerns**: Check shared layers (types, DTOs, validation schemas, routes, middleware) for completeness
- **Document gaps** — If missing layers are detected, list them as **IMPORTANT** issues in the report with concrete suggestions for what should be added
### 5. Generate Report — Create a markdown report for each PR including:
- **PR Summary** — What it does, files affected, commit count
- **Improvements/Benefits** — Numbered list with impact level (HIGH/MEDIUM/LOW)
- **Risks & Issues** — Categorized as CRITICAL / IMPORTANT / MINOR
- **Scoring Table** — Rate across: Feature Relevance, Code Quality, Security, Robustness, Tests
- **Verdict** — Ready to merge? With mandatory vs optional fixes
- **Next Steps** — What will happen if approved
### 6. Present to User
- Show the report in the final response and stop. Mark this as a blocking checkpoint awaiting explicit user approval before continuing.
- Wait for user decision:
- **Approved** → Proceed to step 7
- **Approved with changes** → Implement the fixes and corrections before merging
- **Rejected** → Close the PR or leave a review comment
### 7. Pre-Merge Fixes & CI Green-Lighting (if approved)
> **⚠️ Fixes and Conflict Resolutions MUST be pushed back to the PR branch before merging.** We want the PR itself to be green and fully valid before it integrates.
- **Sync latest fixes & Resolve Conflicts:** Merge the current `release` branch into the PR branch. If there are merge conflicts, you MUST resolve them inside the author's PR branch. NEVER resolve conflicts by closing their PR and doing the work in a separate branch, as this steals credit from the original author.
- **Implement improvements:** Apply the required fixes identified in the analysis directly on the PR branch (e.g., adding missing API routes, fixing SSRF, applying comments from other agents).
- **Pushing changes to PR branches:**
```bash
# Checkout the PR locally
gh pr checkout <NUMBER>
# Apply fixes, commit your changes
git commit -m "chore: apply review suggestions and missing layers"
# Attempt to push directly to the PR branch
git push
```
- **Fallback (ONLY for external forks without maintainer edit access):**
Using `cherry-pick` instead of fixing the contributor's PR directly is a **LAST RESORT**. You MUST ALWAYS attempt to `git push` your fixes to their branch first.
**ONLY if `git push` explicitly fails with a permission/access error** (meaning the contributor unchecked "Allow edits from maintainers" or it's a locked fork), you may use `git cherry-pick` to bring their changes into the release branch and fix the issues locally.
Even then, 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**. Leave it open so the contributor retains credit. Under NO CIRCUMSTANCES should you use `gh pr close`.
- Run the project's test suite locally to verify nothing breaks:
// turbo
- Run: `npm test` or equivalent test command
### 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.
> You MUST ALWAYS resolve conflicts and apply fixes ON THE AUTHOR'S PR BRANCH (unless explicitly locked from edits), and then merge the PR using GitHub so the contributor gets the official "Merged" badge and proper credit on their profile. **Do not use cherry-pick just because it is "easier" than resolving conflicts on their branch.**
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) 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 or cherry-picked).
- Note it will be included in the upcoming release.
- Be friendly, professional, and encouraging.
> **⚠️ MANDATORY CHANGELOG CREDIT**: When cherry-picking is used (because the PR branch couldn't be pushed to or `gh pr merge` failed), the contributor does NOT get the automatic GitHub "Merged" badge. In this case, you MUST compensate by adding an explicit entry to `CHANGELOG.md` in the `[Unreleased]` section with `(#PR_NUMBER — thanks @username)` format. This ensures the contributor gets public credit in the release notes even if GitHub doesn't auto-detect the cherry-pick. This is NOT optional — skipping it effectively erases the contributor's work from the release record.
### 9. Sync Local Release Branch
After merging PRs, sync the local release branch to include the new changes:
```bash
git fetch origin
git pull origin release/vX.Y.Z
```
### 10. Continue or Finalize
After processing all approved PRs:
- If more PRs remain, go back to step 7
- When all PRs are processed, **update CHANGELOG.md** on the release branch with all new entries
- Run **test coverage** to verify the gate (≥75% statements/lines/functions, ≥70% branches — measured ~82%):
```bash
npm run test:coverage
```
- Fix any test regressions introduced by merged PRs
- Run `/generate-release` workflow Phase 1 steps 710 (tests → commit → push → open PR to main → wait for user)
- The `/generate-release` workflow handles the final merge from `release/vX.Y.Z` → `main`

View File

@@ -0,0 +1,347 @@
---
name: version-bump-cx
description: Bump version, auto-generate CHANGELOG from git commits, update all versioned files, and refresh root + docs/ documentation to reflect the current project state
---
# Version Bump Workflow
Automatically bump the project version, generate CHANGELOG entries from git history since the last tag, update every file that references the version, and refresh project documentation to reflect the current state.
## Codex Execution Notes
- Treat `// turbo` / `// turbo-all` as instructions to use `multi_tool_use.parallel` for independent reads, checks, and GitHub calls.
- Any user-approval phase is a hard stop: present the report/status in the final response and wait before committing, pushing, tagging, publishing, or deploying.
> **VERSION RULE: Always use PATCH bumps (3.x.y → 3.x.y+1)**
> NEVER use `npm version minor` or `npm version major`.
> Always use: `npm version patch --no-git-tag-version`
> The threshold rule: when `y` reaches 10, bump to `3.(x+1).0` — e.g. `3.4.10` → `3.5.0`.
---
## Phase 1: Determine Version
### 1. Read current version and last tag
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute
CURRENT_VERSION=$(node -p "require('./package.json').version")
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
CURRENT_BRANCH=$(git branch --show-current)
echo "Current version: $CURRENT_VERSION"
echo "Last tag: $LAST_TAG"
echo "Current branch: $CURRENT_BRANCH"
```
### 2. Calculate new version
Apply the patch bump rule:
- If the current patch number is `9`, the new version is `3.(minor+1).0`
- Otherwise, increment patch: `3.x.y``3.x.(y+1)`
If the version was ALREADY bumped (e.g. you are on a release branch and package.json already has the new version), **skip the npm version bump** and use the existing version.
### 3. Bump package.json (if needed)
// turbo
```bash
# Only if version hasn't been bumped yet
npm version patch --no-git-tag-version
```
Or for threshold (y=10):
```bash
# Manual threshold bump
VERSION="3.X.0" # compute manually
npm version "$VERSION" --no-git-tag-version
```
---
## Phase 2: Generate CHANGELOG from Git History
### 4. Collect commits since last tag
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null)
echo "=== Commits since $LAST_TAG ==="
git log "$LAST_TAG"..HEAD --pretty=format:"%h %s" --no-merges | head -100
echo ""
echo "=== Merge commits ==="
git log "$LAST_TAG"..HEAD --merges --pretty=format:"%h %s" | head -50
```
### 5. Classify commits and generate CHANGELOG section
Analyze each commit message and classify into categories based on the conventional-commit prefix and content:
| Category | Patterns |
| ------------------- | ------------------------------------------------ |
| ✨ New Features | `feat:`, `feat(*):` |
| 🐛 Bug Fixes | `fix:`, `fix(*):` |
| ⚠️ Breaking Changes | `BREAKING CHANGE`, `!:` suffix |
| 🛠️ Maintenance | `chore:`, `refactor:`, `perf:`, `build:` |
| 🧪 Tests | `test:`, `tests:` |
| 📝 Documentation | `docs:` |
| 🔒 Security | `security:`, CVE references, vulnerability fixes |
| 🌍 i18n | translation updates, locale changes |
For each category with entries, create a markdown section with descriptive bullet points. Use the commit messages but rewrite them to be human-readable and descriptive (not raw commit messages).
**If a commit references a PR number** (e.g. `#880`, `PR #885`), include it in the description.
### 6. Update CHANGELOG.md
Replace the `## [Unreleased]` section content with the generated entries, then add the new versioned section:
```markdown
## [Unreleased]
---
## [NEW_VERSION] — YYYY-MM-DD
### ✨ New Features
- **Feature name:** Description (#PR)
### 🐛 Bug Fixes
- **Fix name:** Description (#PR)
### 🛠️ Maintenance
- **Item:** Description
---
## [PREVIOUS_VERSION] — YYYY-MM-DD
...
```
The date must be today's date in `YYYY-MM-DD` format.
---
## Phase 3: Sync Version Across All Files
### 7. Update workspace package.json files and openapi.yaml
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute
VERSION=$(node -p "require('./package.json').version")
# Update docs/reference/openapi.yaml version
sed -i "s/ version: .*/ version: $VERSION/" docs/reference/openapi.yaml
echo "✓ docs/reference/openapi.yaml → $VERSION"
# Update workspace packages (open-sse, electron)
for dir in electron open-sse; do
if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then
(cd "$dir" && npm version "$VERSION" --no-git-tag-version --allow-same-version > /dev/null)
echo "$dir/package.json → $VERSION"
fi
done
echo "✓ All workspace packages synced to $VERSION"
```
### 8. Update llm.txt version references
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute
VERSION=$(node -p "require('./package.json').version")
OLD_VERSION_PATTERN='[0-9]\+\.[0-9]\+\.[0-9]\+'
# Update "Current version:" line
sed -i "s/\*\*Current version:\*\* $OLD_VERSION_PATTERN/**Current version:** $VERSION/" llm.txt
# Update "Key Features (vX.Y.Z)" header
sed -i "s/## Key Features (v$OLD_VERSION_PATTERN)/## Key Features (v$VERSION)/" llm.txt
echo "✓ llm.txt → $VERSION"
```
### 9. Regenerate lock file
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute
npm install
echo "✓ Lock file regenerated"
```
---
## Phase 4: Update Root Documentation
Based on the CHANGELOG entries generated in Phase 2, review and update these root-level files if relevant changes warrant updates:
### 10. Review and update root documentation files
For each file below, read the current content and determine if the CHANGELOG entries require any updates. Only modify files where substantive changes have occurred:
| File | When to update |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `README.md` | New providers, major features, stats changes (test count, provider count), badges, installation instructions, feature table |
| `AGENTS.md` | Architecture changes, new modules, new commands, new providers, new services/handlers/executors |
| `CONTRIBUTING.md` | Dev workflow changes, new tooling, test infrastructure changes |
| `SECURITY.md` | Security fixes, new auth mechanisms, vulnerability disclosures |
| `llm.txt` | Provider count changes, new features, architecture changes |
**Update rules:**
- **README.md**: Update provider count, test count, feature highlights table, badges if any numbers changed. If a new provider was added, add it to the provider table. If a major feature was added, add it to the features section.
- **AGENTS.md**: If new architecture components (handlers, executors, services, DB modules) were added, update the Architecture section. If new commands were added, update the Build/Test table.
- **SECURITY.md**: Add new vulnerability fixes or security improvements to the relevant section.
- **llm.txt**: Update provider count, feature list, version references.
### 11. Review and update docs/ files (excluding i18n/)
For each file in `docs/` (excluding `docs/i18n/`), review if CHANGELOG changes affect it:
| File | When to update |
| --------------------------------------------- | ------------------------------------------------------------------ |
| `docs/reference/API_REFERENCE.md` | New API endpoints, changed request/response formats |
| `docs/architecture/ARCHITECTURE.md` | New modules, new services, changed data flow |
| `docs/architecture/CODEBASE_DOCUMENTATION.md` | New files, architectural changes, module reorganization |
| `docs/architecture/REPOSITORY_MAP.md` | New folders / files / one-line descriptions |
| `docs/reference/CLI-TOOLS.md` | New CLI tool integrations, config format changes |
| `docs/guides/USER_GUIDE.md` | UX changes, new dashboard pages, settings changes |
| `docs/reference/PROVIDER_REFERENCE.md` | New providers (regenerate via `scripts/gen-provider-reference.ts`) |
| `docs/frameworks/MCP-SERVER.md` | New MCP tools, changed tool signatures, scope changes |
| `docs/frameworks/A2A-SERVER.md` | New A2A skills, protocol changes |
| `docs/frameworks/AGENT_PROTOCOLS_GUIDE.md` | New external agent protocols supported |
| `docs/frameworks/CLOUD_AGENT.md` | Cloud agent additions (codex-cloud, devin, jules) or API changes |
| `docs/architecture/AUTHZ_GUIDE.md` | New route classifications, policy changes |
| `docs/security/GUARDRAILS.md` | New guardrails registered, priority/order changes |
| `docs/security/COMPLIANCE.md` | Audit log / retention / no-log policy changes |
| `docs/frameworks/SKILLS.md` | Skill framework / registry / built-in skill changes |
| `docs/frameworks/MEMORY.md` | Memory pipeline / extraction / injection / Qdrant changes |
| `docs/frameworks/EVALS.md` | Evaluation framework changes, new evaluators |
| `docs/frameworks/WEBHOOKS.md` | New webhook events, payload schema changes |
| `docs/routing/REASONING_REPLAY.md` | Reasoning capture/replay pipeline changes |
| `docs/routing/AUTO-COMBO.md` | Routing changes, new strategies, scoring weight changes |
| `docs/architecture/RESILIENCE_GUIDE.md` | Circuit breaker / cooldown / lockout behavior changes |
| `docs/security/STEALTH_GUIDE.md` | TLS / CLI fingerprint changes |
| `docs/ops/TUNNELS_GUIDE.md` | Cloudflare tunnel feature changes |
| `docs/guides/ELECTRON_GUIDE.md` | Electron build / signing / packaging changes |
| `docs/guides/TROUBLESHOOTING.md` | New known issues, resolved problems |
| `docs/ops/RELEASE_CHECKLIST.md` | Process changes |
| `docs/ops/COVERAGE_PLAN.md` | Coverage gate adjustments, target metrics |
| `docs/reference/openapi.yaml` | Already updated in step 7 |
**Only update files where the CHANGELOG entries directly affect the documented content.** Do NOT update files just to bump a version number — only when the documented behavior, features, or architecture has actually changed.
---
## Phase 5: Verify
### 12. Run lint check
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute
npm run lint
```
### 13. Run tests
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute
npm test
```
### 14. Verify version sync across all files
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute
VERSION=$(node -p "require('./package.json').version")
echo "Expected version: $VERSION"
echo ""
echo "--- package.json ---"
grep '"version"' package.json | head -1
echo "--- open-sse/package.json ---"
grep '"version"' open-sse/package.json | head -1
echo "--- electron/package.json ---"
[ -f electron/package.json ] && grep '"version"' electron/package.json | head -1
echo "--- docs/reference/openapi.yaml ---"
grep " version:" docs/reference/openapi.yaml | head -1
echo "--- llm.txt ---"
grep "Current version:" llm.txt
echo "--- CHANGELOG.md (first versioned entry) ---"
grep "^## \[" CHANGELOG.md | head -2
```
### 15. 🛑 STOP — Present Summary to User
**STOP** and present a summary to the user including:
- Old version → New version
- CHANGELOG entries generated
- Files modified
- Test results
- Any documentation updates made
**Wait for the user to confirm before committing.**
---
## Phase 6: Commit (only after user approval)
### 16. Stage and commit
// turbo-all
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute
git add -A
VERSION=$(node -p "require('./package.json').version")
git commit -m "chore(release): bump to v$VERSION — changelog, docs, version sync"
```
---
## Notes
- This workflow does **NOT** create tags, releases, or deploy. Use `/generate-release` for the full release cycle after this.
- This workflow does **NOT** update `docs/i18n/` translations. Translation updates are handled manually or via release tooling — there is no `/update-i18n` workflow shipped in this repo.
- The CHANGELOG generation is based on git commits since the last tag. If there are no new commits, the workflow should inform the user and stop.
- Always verify the generated CHANGELOG entries make sense — raw commit messages may need rewriting for clarity.
- If the version was already bumped (e.g. you're on a `release/vX.Y.Z` branch), skip the `npm version` step and use the existing version.
## Version Touchpoints Checklist
| File | Field/Pattern |
| ----------------------------- | ----------------------------------------------------------- |
| `package.json` | `"version": "X.Y.Z"` |
| `open-sse/package.json` | `"version": "X.Y.Z"` |
| `electron/package.json` | `"version": "X.Y.Z"` |
| `docs/reference/openapi.yaml` | `version: X.Y.Z` |
| `llm.txt` | `**Current version:** X.Y.Z` and `## Key Features (vX.Y.Z)` |
| `CHANGELOG.md` | `## [X.Y.Z] — YYYY-MM-DD` |

View File

@@ -0,0 +1,51 @@
---
description: Automatically run a browser-automation agent to visually validate all new UI features from the current release and capture evidence WebP recordings of the changes.
---
# Capture Release Evidences Workflow
Use this workflow to automatically drive a browser-automation agent to explore the newly deployed or locally running application and record evidence of the UI changes introduced in the latest release.
> **Tool mapping note (v3.8):** The `browser_subagent` tool referenced below is specific to an earlier agent runtime. In Claude Code, substitute with the available browser MCP tools (e.g. `mcp__claude-in-chrome__*`) for navigation/screenshots, plus the `Write` tool for saving artifacts. The high-level steps remain the same regardless of the browser-automation surface in use.
## Prerequisites
- OmniRoute must be actively running and accessible (e.g. locally at `http://localhost:20128` or on the Local VPS at `http://192.168.0.15:20128`).
- The user must provide the target URL to be tested, or default to `http://192.168.0.15:20128`.
## Workflow Steps
### 1. Identify Target Features
Review the `CHANGELOG.md` for the latest version to map out the new UI elements. For example:
- **CLI Tools Settings**
- **New Provider/Model Listings (e.g., Gemini 3.1, Qoder PAT)**
- **New Feature Modals**
### 2. Run the Browser Subagent
For each identified feature, invoke the `browser_subagent` using the `default_api:browser_subagent` tool.
**Important Task Guidelines for the Subagent:**
- `TaskName`: Give it a clear name like "Validate CLIProxyAPI Tool Tab".
- `TaskSummary`: "Navigate to the CLI Tools tab and verify the new Integration settings."
- `Task`: Provide unambiguous instructions for the subagent, such as: "Navigate to http://192.168.0.15:20128/dashboard. Click on the 'Settings' or 'CLI Tools' nav link. Scroll down to find the CLIProxyAPI integration card. Hover over it to trigger UI state. Verify the components render correctly and exit."
- `RecordingName`: Ensure it describes the feature (e.g. `v3_4_5_cli_proxy_api`). This is required and strictly automatically saved as a WebP artifacts video by the system.
_(Note: The `browser_subagent` automatically creates a WebP recording named by the `RecordingName` parameter. No additional tools for screenshots are needed.)_
### 3. Generate Report Artifact
After the `browser_subagent` finishes its sessions, generate a final Markdown artifact (using `Write` and `IsArtifact=true`) to present the recordings inline to the user using the `![caption](/absolute/path/to/media.webp)` syntax.
### Example Invocation
\```json
{
"TaskName": "Validating Qoder PAT Configuration UI",
"TaskSummary": "Validates the Qoder provider configuration modal",
"Task": "Go to http://192.168.0.15:20128/dashboard. Click on the 'Providers' tab. Find 'Qoder' in the list. Click 'Add Token' or 'Configure'. Type 'test_token' and submit. Return when done.",
"RecordingName": "qoder_pat_ui_validation"
}
\```

View File

@@ -0,0 +1,39 @@
---
description: Deploy the latest OmniRoute code to the Akamai VPS (69.164.221.35)
---
# Deploy to Akamai VPS Workflow
Deploy OmniRoute to the Akamai VPS using `npm pack + scp` + PM2.
**Akamai VPS:** `69.164.221.35`
**Process manager:** PM2 (`omniroute`)
**Port:** `20128`
## Steps
### 1. Build + pack locally
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts
```
### 2. Copy to Akamai VPS and install
// turbo-all
```bash
scp omniroute-*.tgz root@69.164.221.35:/tmp/
```
```bash
ssh root@69.164.221.35 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Akamai done'"
```
### 3. Verify the deployment
```bash
curl -s -o /dev/null -w 'AKAMAI HTTP %{http_code}\n' http://69.164.221.35:20128/
```

View File

@@ -0,0 +1,49 @@
---
description: Deploy the latest OmniRoute code to BOTH the Akamai VPS and the Local VPS
---
# Deploy to VPS (Both) Workflow
Deploy OmniRoute to the production VPSs using `npm pack + scp` + PM2.
**Akamai VPS:** `69.164.221.35`
**Local VPS:** `192.168.0.15`
**Process manager:** PM2 (`omniroute`)
**Port:** `20128`
**PM2 entry:** `/usr/lib/node_modules/omniroute/app/server.js`
> [!IMPORTANT]
> The npm registry rejects packages > 100MB, so deployment uses **npm pack + scp**.
## Steps
### 1. Build + pack locally
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts
```
### 2. Copy to both VPS and install
// turbo-all
```bash
scp omniroute-*.tgz root@69.164.221.35:/tmp/ && scp omniroute-*.tgz root@192.168.0.15:/tmp/
```
```bash
ssh root@69.164.221.35 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Akamai done'"
```
```bash
ssh root@192.168.0.15 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Local done'"
```
### 3. Verify the deployment
```bash
curl -s -o /dev/null -w 'AKAMAI HTTP %{http_code}\n' http://69.164.221.35:20128/
curl -s -o /dev/null -w 'LOCAL HTTP %{http_code}\n' http://192.168.0.15:20128/
```

View File

@@ -0,0 +1,39 @@
---
description: Deploy the latest OmniRoute code to the Local VPS (192.168.0.15)
---
# Deploy to Local VPS Workflow
Deploy OmniRoute to the Local VPS using `npm pack + scp` + PM2.
**Local VPS:** `192.168.0.15`
**Process manager:** PM2 (`omniroute`)
**Port:** `20128`
## Steps
### 1. Build + pack locally
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts
```
### 2. Copy to Local VPS and install
// turbo-all
```bash
scp omniroute-*.tgz root@192.168.0.15:/tmp/
```
```bash
ssh root@192.168.0.15 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Local done'"
```
### 3. Verify the deployment
```bash
curl -s -o /dev/null -w 'LOCAL HTTP %{http_code}\n' http://192.168.0.15:20128/
```

View File

@@ -0,0 +1,362 @@
---
description: Create a new release, bump version up to the .10 patch threshold, update changelog, and manage Pull Requests
---
# Generate Release Workflow
Bump version, finalize CHANGELOG, commit, open a **PR to main** and wait for user confirmation before tagging, publishing, and deploying.
> **VERSION RULE: Always use PATCH bumps (3.x.y → 3.x.y+1)**
> NEVER use `npm version minor` or `npm version major`.
> Always use: `npm version patch --no-git-tag-version`
> The threshold rule: when `y` reaches 10, bump to `3.(x+1).0` — e.g. `3.8.10` → `3.9.0`.
> **🔴 SINGLE BRANCH RULE**: The `release/vX.Y.Z` branch is the **ONLY** development branch for the entire release cycle. ALL work — bug fixes, feature implementations, PR integrations, issue resolutions — MUST be committed directly on this branch. Never create separate `fix/`, `feat/`, or topic branches. When running `/resolve-issues`, `/implement-features`, or `/review-prs`, always work on the current release branch.
---
## ⚠️ Two-Phase Flow
```
Phase 1 (automated): bump → docs → i18n → commit → push → open PR
↕ 🛑 STOP: Notify user, wait for PR confirmation
Phase 2 (post-merge): tag → publish → GitHub release → Docker → deploy
```
**NEVER push directly to main or create tags before the user confirms the PR.**
---
## Phase 0: Security Verification (MANDATORY)
Before creating the release, you must ensure the codebase and supply chain are secure and free of known vulnerabilities.
1. **Run Local Dependencies Audit:**
```bash
npm audit
```
_Fix any `high` or `critical` vulnerabilities identified._
2. **Check GitHub CodeQL & Dependabot Alerts:**
Navigate to the repository's **Security** tab on GitHub, or use the project's `vulnerability-scanner` skill to analyze active alerts. Ensure all static analysis findings (e.g., prototype pollution, insecure randomness, ReDoS, shell injections) are addressed and logically committed on a target branch.
---
## Phase 1: Pre-Merge
### 1. Create release branch
```bash
git checkout -b release/v3.x.y
```
### 2. Determine and sync version
Check current version in `package.json`:
```bash
grep '"version"' package.json
```
> **🔴 BRANCH-VERSION PARITY RULE**: The logical version in `package.json` MUST exactly match the release branch name. For example, if you are on `release/v3.7.0`, the version in `package.json` MUST be `3.7.0`.
>
> - If this is the FIRST time generating a release for a new minor/major branch (e.g., bumping from 3.6.9 to 3.7.0), you MUST ensure the version is bumped to match the new branch logic.
> - If you are just bumping a patch on the current branch (e.g., 3.6.9 to 3.6.10), use:
> `npm version patch --no-git-tag-version`
> **⚠️ ATOMIC COMMIT RULE — Version bump MUST happen before committing feature files.**
>
> **CORRECT order:**
>
> 1. `npm version patch --no-git-tag-version` ← bump first
> 2. implement features / fix bugs
> 3. `git add -A && git commit -m "chore(release): v3.x.y — all changes in ONE commit"`
>
> **OR if features are already staged:**
>
> 1. implement features (do NOT commit yet)
> 2. `npm version patch --no-git-tag-version` ← bump before committing
> 3. `git add -A && git commit -m "chore(release): v3.x.y — all changes in ONE commit"`
>
> **NEVER do this (creates version mismatch in git history):**
>
> - ~~commit features → then bump version → commit package.json separately~~
>
> This ensures that `git show v3.x.y` always contains both code changes and the version bump together.
> The GitHub release tag will point to a commit that includes ALL changes for that version.
### 3. Regenerate lock file (REQUIRED after version bump)
**Mandatory** — skipping causes `@swc/helpers` lock mismatch and CI failures:
```bash
npm install
```
### 4. Finalize CHANGELOG.md
> **🔴 NO MIXUPS RULE**: Ensure you do NOT mix the backlog of the previous version with the new one. The new version section must ONLY contain the features and fixes for the current release.
Replace the `[Unreleased]` header with the new version and date.
Keep an empty `## [Unreleased]` section above it, separated by a horizontal rule (`---`).
```markdown
## [Unreleased]
---
## [3.7.0] — 2026-04-19
### ✨ New Features
- ...
### 🐛 Bug Fixes
- ...
---
## [3.6.9] — 2026-04-19
```
### 5. Update openapi.yaml version ⚠️ MANDATORY
> **CI will fail** if `docs/reference/openapi.yaml` version ≠ `package.json` version (`check:docs-sync` enforces this).
// turbo
```bash
VERSION=$(node -p "require('./package.json').version")
sed -i "s/ version: .*/ version: $VERSION/" docs/reference/openapi.yaml
echo "✓ openapi.yaml → $VERSION"
for dir in electron open-sse; do
if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then
(cd "$dir" && npm version "$VERSION" --no-git-tag-version --allow-same-version > /dev/null)
echo "✓ $dir/package.json → $VERSION"
fi
done
# Re-run install to assert the workspace lockfile is updated
npm install
```
### 6. Update README.md and i18n docs
Manually perform these documentation updates (there is no `/update-docs` workflow — it was deprecated in v3.8):
- Update feature table rows and "What's new in vX.Y.Z" section in `README.md`
- Sync feature changes to all 40 language `docs/i18n/*/README.md` files (use the same row edits across each translated README)
- Update the relevant `docs/<AREA>.md` if architecture or counts changed
- Re-run `npm run check:docs-sync` and `npm run check:docs-all` to catch drift
### 7. Run tests
// turbo
```bash
npm test
```
All tests must pass before creating the PR.
### 8. Stage, commit, and push
// turbo-all
```bash
git add -A
git commit -m "chore(release): v3.x.y — summary of changes"
git push origin release/v3.x.y
```
### 9. Open PR to main
### 9. Open PR to main
// turbo
```bash
VERSION=$(node -p "require('./package.json').version")
# Extract the exact changelog entry for this version from the root CHANGELOG.md
awk "/^## \\[$VERSION\\]/{flag=1; print; next} /^---/{if(flag) {flag=0; exit}} flag" CHANGELOG.md > /tmp/changelog_body.txt
# Append test status and next steps
echo "" >> /tmp/changelog_body.txt
echo "### Tests" >> /tmp/changelog_body.txt
echo "- All tests pass" >> /tmp/changelog_body.txt
echo "" >> /tmp/changelog_body.txt
echo "### ⚠️ After merging: run Phase 2 steps to tag, publish, and deploy." >> /tmp/changelog_body.txt
gh pr create \
--repo diegosouzapw/OmniRoute \
--base main \
--head release/v$VERSION \
--title "Release v$VERSION" \
--body-file /tmp/changelog_body.txt
```
### 10. 🛑 STOP — Notify User & Await PR Confirmation
**This is a mandatory stop point.** Present the report in the final response and stop. Do not continue to the next phase until the user explicitly approves.
Inform the user:
- PR URL
- Summary of changes
- Test results
- List of files changed
**DO NOT proceed to Phase 2 until the user confirms the PR looks good and merges it.**
---
## Phase 2: Post-Merge Validation (Local VPS)
> Run these steps only AFTER the user has merged the PR into `main` and all CI jobs have passed.
### 11. Deploy to Local VPS for Final Validation (MANDATORY)
Before cutting the official git tag and publishing to the world, deploy the `main` branch to the Local VPS for a final homologation test.
```bash
git checkout main
git pull origin main
# Build and pack locally
cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts
# Deploy to LOCAL VPS (192.168.0.15)
scp omniroute-*.tgz root@192.168.0.15:/tmp/
ssh root@192.168.0.15 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Local done'"
# Verify
curl -s -o /dev/null -w "LOCAL: HTTP %{http_code}\n" http://192.168.0.15:20128/
```
### 12. 🛑 STOP — Notify User & Await Final OK
**This is a mandatory stop point.**
Inform the user that the `main` branch is now running on the Local VPS.
Wait for the user to manually test and give the **OK**.
**DO NOT proceed to Phase 3 until the user confirms the local deploy is stable.**
---
## Phase 3: Official Launch
> Run these steps only AFTER the user gives the final OK from the Phase 2 local validation.
### 13. Create Git Tag and GitHub Release (MANDATORY)
// turbo
```bash
git checkout main
git pull origin main
VERSION=$(node -p "require('./package.json').version")
# Extracts the changelog section for this version
NOTES=$(awk "/^## \\[$VERSION\\]/{flag=1; next} /^---/{if(flag) {flag=0; exit}} flag" CHANGELOG.md | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
if [ -z "$NOTES" ]; then NOTES="OmniRoute v$VERSION Release"; fi
git tag -a "v$VERSION" -m "Release v$VERSION"
git push origin "v$VERSION"
gh release create "v$VERSION" --repo diegosouzapw/OmniRoute --title "v$VERSION" --notes "$NOTES" --target main || gh release edit "v$VERSION" --repo diegosouzapw/OmniRoute --title "v$VERSION" --notes "$NOTES"
```
### 14. 🐳 Trigger Docker Hub build (MANDATORY — keep npm and Docker in sync)
> **CRITICAL**: Docker Hub and npm MUST always publish the same version.
> The Docker image is built automatically via GitHub Actions when a new tag is pushed.
> After pushing the tag in step 13, **verify the workflow runs**:
```bash
# Verify the Docker workflow triggered
gh run list --repo diegosouzapw/OmniRoute --workflow docker-publish.yml --limit 3
# Wait for the Docker build to complete (usually 510 min)
gh run watch --repo diegosouzapw/OmniRoute
```
### 15. Publish to NPM (Optional/Automated)
Normally handled by CI, but if manual publish is required:
```bash
npm publish
```
### 16. Deploy to AKAMAI VPS (Production)
Now that the release is officially cut, deploy it to the Akamai VPS.
```bash
# Deploy to AKAMAI VPS (69.164.221.35)
scp omniroute-*.tgz root@69.164.221.35:/tmp/
ssh root@69.164.221.35 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Akamai done'"
# Verify
curl -s -o /dev/null -w "AKAMAI: HTTP %{http_code}\n" http://69.164.221.35:20128/
```
## Phase 4: Release Monitoring & Artifact Validation
> After triggering the official release, actively monitor the CI pipelines until all artifacts are successfully generated. If any pipeline fails, stop and apply the necessary corrections before continuing.
### 18. Monitor CI Pipelines
Wait for and verify the successful completion of the following automated jobs:
1. **Docker Hub Publish**
2. **Electron Build**
3. **NPM Registry Publish** (Check with `npm info omniroute version`)
```bash
# Monitor Docker Hub workflow
gh run list --repo diegosouzapw/OmniRoute --workflow docker-publish.yml --limit 1
gh run watch <RUN_ID>
# Monitor Electron build
gh run list --repo diegosouzapw/OmniRoute --workflow electron-release.yml --limit 1
gh run watch <RUN_ID>
# Verify NPM version
npm info omniroute version
```
### 19. Handle Failures (If Any)
If a workflow fails:
- Use `gh run view <RUN_ID> --log-failed` to identify the error.
- Apply the fix on the `main` branch.
- If necessary, re-trigger the workflow using `gh workflow run <workflow_name.yml> --repo diegosouzapw/OmniRoute --ref v3.x.y`
### 20. Preserve release branch
```bash
# Branch is kept for historical purposes. Do not delete.
```
---
## Notes
- Ensure CHANGELOG, README and `docs/*` are current BEFORE this workflow — run `npm run check:docs-all` and `/version-bump` first (there is no `/update-docs` workflow anymore)
- The `prepublishOnly` script runs `npm run build:cli` automatically during `npm publish`
- After npm publish, verify with `npm info omniroute version`
- Lock file sync errors are caused by skipping `npm install` after version bump
- Use `gh auth switch -u diegosouzapw` if git push fails with wrong account
## Known CI Pitfalls
| CI failure | Cause | Fix |
| ------------------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------- |
| `[docs-sync] FAIL - OpenAPI version differs from package.json` | Skipped step 5 — `docs/reference/openapi.yaml` version not updated | Run step 5 (`sed -i ...`) and commit |
| `[docs-sync] FAIL - CHANGELOG.md first section must be "## [Unreleased]"` | `## [Unreleased]` missing or not at top of CHANGELOG | Add `## [Unreleased]\n\n---\n` before the first versioned `## [x.y.z]` |
| Electron Linux `.deb` build fails (`FpmTarget` error) | `fpm` Ruby gem not installed on `ubuntu-latest` runner | Already fixed in `electron-release.yml` (`gem install fpm` step) |
| Docker Hub `502 error writing layer blob` | Transient Docker Hub network error during ARM64 push | Re-run the Docker publish workflow; no code change needed |

View File

@@ -0,0 +1,50 @@
---
description: How to respond to GitHub issues with insufficient information
---
# Issue Triage Workflow
Respond to GitHub issues that need more information before they can be investigated.
## Steps
### 1. Identify issues needing triage
```bash
gh issue list --state open --limit 20
```
### 2. Evaluate each issue
Check if the issue has:
- Clear reproduction steps
- Environment details (OS, Node.js version, OmniRoute version)
- Error logs/screenshots
- Expected vs actual behavior
### 3. Respond with triage template
For issues missing information:
```markdown
Thank you for reporting this issue! To help us investigate, please provide:
1. **OmniRoute version**: (`omniroute --version`)
2. **Node.js version**: (`node --version`)
3. **Operating system**: (e.g., Ubuntu 24.04, macOS 15, Windows 11)
4. **Installation method**: (npm, Docker, source)
5. **Steps to reproduce**: (exact commands/actions that trigger the issue)
6. **Error logs**: (paste relevant logs from the console)
7. **Expected behavior**: (what should happen)
This will help us debug and resolve your issue faster. 🙏
```
### 4. Label the issue
Add appropriate labels: `needs-info`, `bug`, `enhancement`, `question`, etc.
```bash
gh issue edit <NUMBER> --add-label "needs-info"
```

View File

@@ -0,0 +1,166 @@
---
description: Fetch all open GitHub issues, analyze bugs, resolve what's possible, triage the rest, wait for user validation, then commit and release
---
# /resolve-issues — Automated Issue Resolution Workflow
## Overview
This workflow fetches all open issues from the project's GitHub repository, classifies them, analyzes bugs, proposes a resolution plan, waits for user validation, and ONLY THEN implements the fixes, commits, and closes the issues on the current release branch (`release/vX.Y.Z`). It does NOT merge or release automatically — the release branch is later merged via PR to main.
> **BRANCH RULE**: All work MUST happen on the current `release/vX.Y.Z` branch. Never create separate `fix/` branches. If no release branch exists yet, create one first using `/generate-release` Phase 1 steps 15.
> **⛔ PR PROHIBITION**: If a fix is associated with a contributor's PR, you MUST merge their PR — NEVER close it and re-implement the fix yourself. See `/review-prs` workflow for the full policy. The `gh pr close` command is FORBIDDEN unless the repository owner explicitly requests it.
## Steps
### 1. Identify the GitHub Repository
// turbo
- Run: `git -C <project_root> remote get-url origin` to extract the owner/repo
- Parse the owner and repo name from the URL
### 2. Ensure Release Branch Exists
// turbo
Before doing any work, ensure you are on the current release branch:
```bash
# Check current branch
git branch --show-current
# If on main, determine next version and create the release branch
VERSION=$(node -p "require('./package.json').version")
NEXT=$(node -p "const [a,b,c]=('$VERSION').split('.').map(Number); c>=9?a+'.'+(b+1)+'.0':a+'.'+b+'.'+(c+1)")
git checkout -b release/v$NEXT
npm version patch --no-git-tag-version
npm install
```
If already on a `release/vX.Y.Z` branch, continue working there.
### 3. Fetch All Open Issues
// turbo-all
**⚠️ CRITICAL**: The JSON output of `gh issue list` can be truncated by the tool, silently hiding issues. You MUST use the two-step approach below to guarantee **all** issues are fetched.
**Step 3a — Get Issue numbers only** (small output, never truncated):
- Run: `gh issue list --repo <owner>/<repo> --state open --limit 500 --json number --jq '.[].number'`
- This outputs one issue number per line. Count them and confirm total.
**Step 3b — Fetch full metadata for each Issue** (one call per issue):
- For each issue number from step 3a, run:
`gh issue view <NUMBER> --repo <owner>/<repo> --json number,title,labels,body,comments,createdAt,author`
- You may batch these into parallel calls (up to 4 at a time).
- Sort by oldest first (FIFO).
### 4. Classify Each Issue
For each issue, determine its type:
- **Bug** — Has `bug` label, or body contains error messages, stack traces, "doesn't work", "broken", "crash", "error"
- **Feature Request** — Has `enhancement`/`feature` label, or body describes new functionality
- **Question** — Has `question` label, or is asking "how to" something
- **Other** — Anything else
Focus ONLY on **Bugs** for resolution. Feature requests and questions should be skipped with a note in the final report.
### 5. Deep-Read Each Bug Issue (One-by-One Analysis)
**IMPORTANT**: Read each bug issue thoroughly, one at a time, before moving to the next. This is NOT a batch process — each issue needs focused attention.
#### 5a. Understand the Problem
For each bug issue, perform the full analysis:
1. **Read the entire body** — including Description, Steps to Reproduce, Expected/Actual Behavior, Error Logs, and Screenshots
2. **Read ALL comments** — including bot triage comments (Kilo, etc.) and owner/community responses. Pay attention to:
- Whether someone already responded with a fix
- Whether a community member confirmed the issue is resolved
- Whether the issue was marked as duplicate by a bot. **WARNING: DO NOT blindly trust bot duplicate labels (e.g., kilo-duplicate). Bots make mistakes. You MUST read the full conversation and do your own independent analysis to determine if it is truly a duplicate or a distinct bug.**
3. **Identify the claimed error** — extract the exact error message, status code, and provider/model involved
#### 5b. Check Information Sufficiency
Verify the issue contains enough to act on:
- [ ] Clear description of the problem
- [ ] Steps to reproduce OR error logs
- [ ] Provider/model/version information
- [ ] Expected vs actual behavior
#### 5c. Determine Issue Disposition
For each bug, classify into one of 5 actions:
| Disposition | When to Apply | Action |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| **✅ CLOSE — Already Fixed** | Owner responded with fix + no user follow-up, OR community confirmed fix | Close with comment citing which version fixed it |
| **✅ CLOSE — Duplicate** | You have independently verified the issue is a duplicate (do NOT rely solely on bot flags) + user provides no new info | Close referencing the original issue |
| **✅ CLOSE — Stale** | We requested logs/info > 7 days ago with no reply | Close thanking the user, invite to reopen if needed |
| **📝 RESPOND — Needs Info** | Issue is real but missing critical reproduction details | Comment asking for specifics per `/issue-triage` |
| **📝 RESPOND — User Config** | Error is caused by unsupported env (Node version, wrong model path, missing API enablement) | Comment explaining the user-side fix |
| **🔧 FIX — Code Change** | Root cause is confirmed in the codebase | Research, propose solution in report, wait for approval |
#### 5d. For "FIX — Code Change" Issues
Before coding, perform deep source analysis to formulate a plan:
1. **Search the codebase**`grep_search` for error strings, relevant function names, affected files
2. **Search the web** — for upstream API changes, SDK updates, or breaking changes that explain the bug
3. **Read the full source file** — don't rely on grep snippets; understand the surrounding logic
4. **Verify the root cause** — confirm the bug is reproducible based on the code, not just a user misconfiguration
5. **Formulate a proposed solution** — detail the exact files and lines you will change and how you will solve it.
6. **Create an Implementation Plan file** — write your proposed solution to `_tasks/features-vX.Y.Z/<ISSUE_NUMBER>-<short-description>.plan.md` (e.g. `_tasks/features-v3.7.6/1810-auto-restore-probe-failed-db.plan.md`) where `vX.Y.Z` is the current branch version. The plan should contain an Overview, Pre-Implementation Checklist, and detailed Implementation Steps (Files, Changes).
7. **DO NOT modify the codebase yet** — wait for user approval on your report and plan first.
#### 5e. For "RESPOND" Issues
Post a substantive comment that:
- Acknowledges the specific error they reported
- Explains the likely root cause
- Provides concrete steps to resolve (version upgrade, env var fix, model path correction)
- Asks for follow-up info if needed
**Do NOT post generic template responses.** Every comment should reference the user's specific error messages and environment.
### 6. Generate Report & Wait for Validation
Present a summary report to the user detailing your proposed actions. For any bugs that need fixing, explicitly explain your proposed solution (files to change and logic) and point out that it will be implemented on the release branch (`release/vX.Y.Z`) after approval.
| Issue | Title | Status | Proposed Action / Version |
| ----- | ----- | ------------- | ----------------------------------------- |
| #N | Title | ✅ Close | Already fixed / duplicate (explain why) |
| #N | Title | 🔧 Propose | Explanation of the code fix to be applied |
| #N | Title | 📝 Respond | Guidance comment to be posted |
| #N | Title | ❓ Needs Info | Triage comment to be posted |
| #N | Title | ⏭️ Skip | Feature request / not a bug |
> **⚠️ IMPORTANT**: Do NOT implement code changes, commit, push, or close issues at this step.
> Wait for the user to review the proposed fixes and respond with **OK** before proceeding.
- If the user says **OK** or approves → Proceed to step 7
- If the user requests changes → Adjust the proposed solution and present the report again
- If the user rejects → Revert any accidental changes and stop
### 7. Implement Fixes, Run Tests & Commit (only after user approval)
After the user validates and gives the OK:
1. **Implement the fixes** — modify the codebase according to the approved plan.
2. **Run tests**`npm run test:all` (or the specific test file) to ensure 100% pass.
3. **Update CHANGELOG.md** with all new bug fix entries.
4. **Commit** each fix individually on the release branch with message format: `fix: <description> (#<issue_number>)`.
5. **Push** the release branch: `git push origin release/vX.Y.Z`.
6. **Close resolved issues immediately**. For each issue that was marked as Fixed, run:
`gh issue close <NUMBER> --repo <owner>/<repo> --comment "Thank you for reporting! This issue has been fixed and will be included in the next release (vX.Y.Z)."`
7. Likewise, close `Duplicate` issues referencing the original, close `Needs Info` if stale, and post the required comments.
8. If the project runs automatic releases or needs a PR, proceed to run `/generate-release` workflow Phase 1 steps 710 (tests → commit → push → open PR to main → wait for user).
If NO fixes were committed, skip closing and source control steps and just conclude the workflow.

View File

@@ -0,0 +1,120 @@
---
description: Read all open GitHub Discussions, summarize them, respond to pending ones, and create issues from actionable feature requests
---
# /review-discussions — GitHub Discussions Review & Response Workflow
## Overview
This workflow reads all open GitHub Discussions, generates a categorized summary, identifies which ones need a response, drafts and posts replies, and optionally creates issues from actionable feature requests. It follows the same flow used for Issues but adapted for the Discussions forum.
> **Tool mapping note (v3.8):** Where steps below say `browser_subagent`, in modern Claude Code substitute with the `gh` CLI via Bash — `gh api graphql` for reading discussions and mutations for posting comments. `WebFetch` is acceptable for read-only HTML scraping when GraphQL is overkill, but prefer `gh` for any write actions.
// turbo-all
## Steps
### 1. Identify the GitHub Repository
- Run: `git -C <project_root> remote get-url origin` to extract the owner/repo
- Parse the owner and repo name from the URL
### 2. Fetch All Open Discussions
- Use `WebFetch` to fetch `https://github.com/<owner>/<repo>/discussions`
- Parse the discussion list to get all discussion titles, IDs, authors, categories, and dates
- For each discussion, fetch the individual page to read the full content and all comments/replies
### 3. Summarize All Discussions
For each discussion, extract:
- **Title** and **#Number**
- **Author** (GitHub username)
- **Category** (Announcements, General, Ideas, Q&A, Show and tell)
- **Date** created
- **Summary** of the original post (1-2 sentences)
- **Comments count** and key participants
- **Your previous response** (if any)
- **Pending action** — whether a response or follow-up is needed
### 4. Present Summary Report to User
Present the full summary to the user organized by category, using a table:
| # | Category | Title | Author | Date | Status |
| --- | -------- | ----- | ------ | ------ | ----------------- |
| #N | Ideas | Title | @user | Mar 23 | ⚠️ Needs response |
| #N | Q&A | Title | @user | Mar 9 | ✅ Answered |
| #N | General | Title | @user | Mar 19 | ⚠️ Needs response |
Highlight:
- **⚠️ Needs response** — No reply from maintainer, or a follow-up comment was left unanswered
- **✅ Answered** — Maintainer already responded
- **🐛 Bug reported** — A bug was mentioned that needs tracking
- **💡 Actionable** — Contains a concrete feature request that could become an issue
### 5. Draft & Post Responses
For each discussion that needs a response, draft a reply following these guidelines:
#### Response Style
- **Friendly and professional** — Start with "Hey @username!"
- **Acknowledge the contribution** — Thank the user for their input
- **Be specific** — Reference existing features, settings, or dashboard pages if the feature already exists
- **Provide workarounds** — If the request isn't implemented yet, suggest current alternatives
- **Commit to action** — If the request is valid, state that you'll open an issue or add it to the roadmap
- **Keep it concise** — 3-5 paragraphs max
#### Posting via Browser
- Use `browser_subagent` to navigate to each discussion and post the comment
- **IMPORTANT**: When typing text in GitHub comment boxes via the browser, use only plain ASCII characters:
- Use regular hyphens `-` instead of em-dashes
- Use `->` instead of arrow symbols
- Do NOT use emoji Unicode characters (the browser keyboard may fail on them)
- Use `**bold**` and `\`code\`` markdown formatting
- Click the green "Comment" button (or "Reply" for threaded replies) after typing
- Verify the comment was posted by checking the page shows the new comment
### 6. Create Issues from Actionable Feature Requests
For discussions that contain concrete, actionable feature requests:
1. Ask the user which ones should become issues
2. For each approved request, create a GitHub issue via `browser_subagent`:
- Navigate to `https://github.com/<owner>/<repo>/issues/new`
- **Title**: `<Feature Name> - <Short description>`
- **Body** should include:
- `## Feature Request` header
- `**Source:** Discussion #N by @author`
- `## Problem` — What limitation the user hit
- `## Proposed Solution` — How it could work
- `### Implementation Ideas` — Technical approach
- `### Current Workarounds` — What users can do today
- `## Additional Context` — Links to related issues/discussions
- Add `enhancement` label
- Click "Submit new issue" / "Create"
3. After creation, go back to the original discussion and post a comment linking to the new issue:
- "I've opened Issue #N to track this feature request. Follow along there for updates!"
### 7. Final Report
Present a final summary to the user:
| Discussion | Action Taken |
| ---------- | ---------------------------------- |
| #N — Title | Responded with workarounds |
| #N — Title | Responded + created Issue #N |
| #N — Title | Already answered, no action needed |
| #N — Title | Responded to follow-up comment |
## Notes
- This workflow is **interactive** — always present the summary and wait for user approval before posting responses or creating issues
- If the user says "pode responder" (or similar approval), proceed with posting all drafted responses
- For discussions in non-English languages, respond in the same language as the original post
- Always reference specific dashboard paths, config options, or code files when explaining existing features
- When a discussion reveals a bug, note it separately from feature requests

View File

@@ -0,0 +1,256 @@
---
description: Analyze open Pull Requests from the project's GitHub repository, generate a critical report, and optionally implement approved changes
---
# /review-prs — PR Review & Analysis Workflow
## ⛔ ABSOLUTE PROHIBITION — Read Before Anything Else
> **NEVER close a contributor's PR if you intend to use ANY of their code, ideas, or fixes.**
>
> **NEVER manually integrate contributor code into a release branch and then close their PR.**
>
> These actions are **STRICTLY FORBIDDEN** under all circumstances:
>
> 1. ❌ Closing a PR and cherry-picking/copying its code into a release branch
> 2. ❌ Closing a PR "because of conflicts" and re-implementing the same fix yourself
> 3. ❌ Closing a PR and committing a "similar" solution inspired by it
> 4. ❌ Using `gh pr close` on any PR whose content was or will be used
>
> **Why**: Closing a PR after taking the contributor's work means they get ZERO credit on GitHub — no "Merged" badge, no contribution graph entry, no public record. This is effectively stealing their contribution. An audit found this happened to **37 PRs** in the past.
>
> **The ONLY acceptable flow**: Resolve conflicts IN the contributor's branch, push fixes TO their branch, then merge THEIR PR via `gh pr merge`. See Step 7 and Step 8 for the exact procedure.
>
> **When to close a PR**: ONLY when the user (repository owner) explicitly requests it, OR when the PR is clearly spam/malicious, OR when the author themselves asks to close it. In ALL other cases, leave it open.
## Overview
This workflow fetches all open PRs from the project's GitHub repository, performs a critical analysis of each one, generates a detailed report, and waits for user approval before proceeding with implementation. **All improvements are committed on the current release branch** (`release/vX.Y.Z`).
> **BRANCH RULE**: PRs are ALWAYS merged into the current `release/vX.Y.Z` branch, NEVER directly into `main`. The release branch acts as a staging area — only after all PRs are integrated and tests pass does the release branch get merged into `main` via the `/generate-release` workflow.
## Steps
### 1. Identify the GitHub Repository
- Read `package.json` to get the repository URL, or use the git remote origin URL
// turbo
- Run: `git -C <project_root> remote get-url origin` to extract the owner/repo
### 2. Ensure Release Branch Exists
// turbo
Before doing any work, ensure you are on the current release branch:
```bash
# Check current branch
git branch --show-current
# If on main, determine next version and create the release branch
VERSION=$(node -p "require('./package.json').version")
# Bump patch: e.g. 3.3.11 → 3.3.12
NEXT=$(node -p "const [a,b,c]=('$VERSION').split('.').map(Number); c>=9?a+'.'+(b+1)+'.0':a+'.'+b+'.'+(c+1)")
git checkout -b release/v$NEXT
npm version patch --no-git-tag-version
npm install
```
If already on a `release/vX.Y.Z` branch, continue working there.
### 3. Fetch Open Pull Requests
// turbo-all
**⚠️ CRITICAL**: The JSON output of `gh pr list` can be truncated by the tool, silently hiding PRs. You MUST use the two-step approach below to guarantee **all** PRs are fetched.
**Step 3a — Get PR numbers only** (small output, never truncated):
- Run: `gh pr list --repo <owner>/<repo> --state open --limit 500 --json number --jq '.[].number'`
- This outputs one PR number per line. Count them and confirm total.
**Step 3b — Fetch full metadata for each PR** (one call per PR):
- For each PR number from step 3a, run:
`gh pr view <NUMBER> --repo <owner>/<repo> --json number,title,author,headRefName,baseRefName,body,createdAt,additions,deletions,files`
- You may batch these into parallel calls (up to 4 at a time).
**Step 3c — Fetch diffs for each PR** (one call per PR, saved to /tmp):
- For each PR number, run:
`gh pr diff <NUMBER> --repo <owner>/<repo> > /tmp/pr<NUMBER>.diff`
- Then read each diff file with the appropriate file-read tool (`Read` in Claude Code; equivalent in your agent runtime).
- For each open PR, collect:
- PR number, title, author, branch, number of commits, date
- PR description/body
- Files changed (diff)
- Existing review comments (from bots or humans)
**Verification**: Confirm the count of PRs analyzed matches the count from step 3a before proceeding.
### 3.5 Redirect PR Base Branches to Release Branch
// turbo-all
**⚠️ CRITICAL**: Contributors typically open PRs targeting `main`. Before analyzing or merging, redirect ALL open PRs to target the current release branch instead.
```bash
# Get the current release branch name
RELEASE_BRANCH=$(git branch --show-current) # e.g. release/v3.5.4
# For each open PR that targets main, change its base to the release branch
for PR_NUM in $(gh pr list --repo <owner>/<repo> --state open --json number,baseRefName --jq '.[] | select(.baseRefName == "main") | .number'); do
echo "Redirecting PR #$PR_NUM$RELEASE_BRANCH"
gh pr edit "$PR_NUM" --repo <owner>/<repo> --base "$RELEASE_BRANCH"
done
```
This ensures:
1. PRs merge into the release branch, not directly into `main`
2. Merge conflict detection is accurate against the release branch
3. The release branch accumulates all changes before the final merge to `main`
4. If the release branch doesn't exist on remote yet, push it first: `git push origin $RELEASE_BRANCH`
### 4. Analyze Each PR — For each open PR, perform the following analysis:
#### 4a. Feature Assessment
- **Does it make sense?** Evaluate if the feature fills a real gap or solves a valid problem
- **Alignment** — Check if it aligns with the project's architecture and roadmap
- **Complexity** — Assess if the scope is reasonable or if it should be split
#### 4b. Code Quality Review
- Check for code duplication
- Evaluate error handling patterns (consistent with existing codebase?)
- Check naming conventions and code style
- Verify TypeScript types (any `any` usage, missing types?)
#### 4c. Security Review
- Check for missing authentication/authorization on new endpoints
- Check for injection vulnerabilities (URL params, SQL, XSS)
- Verify input validation on all user-controlled data
- Check for hardcoded secrets or credentials
#### 4d. Architecture Review
- Does the change follow existing patterns?
- Are there any breaking changes to public APIs?
- Is the database schema affected? Migration needed?
- Impact on performance (N+1 queries, missing indexes?)
#### 4e. Test Coverage
- Does the PR include tests?
- Are edge cases covered?
- Would existing tests break?
#### 4f. Cross-Layer (Global) Analysis
Perform a **global impact assessment** to verify whether the PR changes are complete across all layers of the application:
- **Backend → Frontend check**: If the PR adds or modifies backend-only resources (new endpoints, services, data models), evaluate whether corresponding frontend changes are missing:
- Does a new endpoint require a new screen/page in the dashboard?
- Should there be a new action button, menu item, or navigation link?
- Are there new data fields that should be displayed or editable in the UI?
- Does a new feature need a toggle, configuration panel, or status indicator?
- **Frontend → Backend check**: If the PR adds frontend elements, verify the backend support exists:
- Are the required API endpoints implemented?
- Is the data model sufficient for the new UI components?
- **Cross-cutting concerns**: Check shared layers (types, DTOs, validation schemas, routes, middleware) for completeness
- **Document gaps** — If missing layers are detected, list them as **IMPORTANT** issues in the report with concrete suggestions for what should be added
### 5. Generate Report — Create a markdown report for each PR including:
- **PR Summary** — What it does, files affected, commit count
- **Improvements/Benefits** — Numbered list with impact level (HIGH/MEDIUM/LOW)
- **Risks & Issues** — Categorized as CRITICAL / IMPORTANT / MINOR
- **Scoring Table** — Rate across: Feature Relevance, Code Quality, Security, Robustness, Tests
- **Verdict** — Ready to merge? With mandatory vs optional fixes
- **Next Steps** — What will happen if approved
### 6. Present to User
- Show the report in the final response and stop. Mark this as a blocking checkpoint awaiting explicit user approval.
- Wait for user decision:
- **Approved** → Proceed to step 7
- **Approved with changes** → Implement the fixes and corrections before merging
- **Rejected** → Close the PR or leave a review comment
### 7. Pre-Merge Fixes & CI Green-Lighting (if approved)
> **⚠️ Fixes and Conflict Resolutions MUST be pushed back to the PR branch before merging.** We want the PR itself to be green and fully valid before it integrates.
- **Sync latest fixes & Resolve Conflicts:** Merge the current `release` branch into the PR branch. If there are merge conflicts, you MUST resolve them inside the author's PR branch. NEVER resolve conflicts by closing their PR and doing the work in a separate branch, as this steals credit from the original author.
- **Implement improvements:** Apply the required fixes identified in the analysis directly on the PR branch (e.g., adding missing API routes, fixing SSRF, applying comments from other agents).
- **Pushing changes to PR branches:**
```bash
# Checkout the PR locally
gh pr checkout <NUMBER>
# Apply fixes, commit your changes
git commit -m "chore: apply review suggestions and missing layers"
# Attempt to push directly to the PR branch
git push
```
- **Fallback (ONLY for external forks without maintainer edit access):**
Using `cherry-pick` instead of fixing the contributor's PR directly is a **LAST RESORT**. You MUST ALWAYS attempt to `git push` your fixes to their branch first.
**ONLY if `git push` explicitly fails with a permission/access error** (meaning the contributor unchecked "Allow edits from maintainers" or it's a locked fork), you may use `git cherry-pick` to bring their changes into the release branch and fix the issues locally.
Even then, 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**. Leave it open so the contributor retains credit. Under NO CIRCUMSTANCES should you use `gh pr close`.
- Run the project's test suite locally to verify nothing breaks:
// turbo
- Run: `npm test` or equivalent test command
### 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.
> You MUST ALWAYS resolve conflicts and apply fixes ON THE AUTHOR'S PR BRANCH (unless explicitly locked from edits), and then merge the PR using GitHub so the contributor gets the official "Merged" badge and proper credit on their profile. **Do not use cherry-pick just because it is "easier" than resolving conflicts on their branch.**
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) 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 or cherry-picked).
- Note it will be included in the upcoming release.
- Be friendly, professional, and encouraging.
> **⚠️ MANDATORY CHANGELOG CREDIT**: When cherry-picking is used (because the PR branch couldn't be pushed to or `gh pr merge` failed), the contributor does NOT get the automatic GitHub "Merged" badge. In this case, you MUST compensate by adding an explicit entry to `CHANGELOG.md` in the `[Unreleased]` section with `(#PR_NUMBER — thanks @username)` format. This ensures the contributor gets public credit in the release notes even if GitHub doesn't auto-detect the cherry-pick. This is NOT optional — skipping it effectively erases the contributor's work from the release record.
### 9. Sync Local Release Branch
After merging PRs, sync the local release branch to include the new changes:
```bash
git fetch origin
git pull origin release/vX.Y.Z
```
### 10. Continue or Finalize
After processing all approved PRs:
- If more PRs remain, go back to step 7
- When all PRs are processed, **update CHANGELOG.md** on the release branch with all new entries
- Run **test coverage** to verify the gate (≥75% statements/lines/functions, ≥70% branches — measured ~82%):
```bash
npm run test:coverage
```
- Fix any test regressions introduced by merged PRs
- Run `/generate-release` workflow Phase 1 steps 710 (tests → commit → push → open PR to main → wait for user)
- The `/generate-release` workflow handles the final merge from `release/vX.Y.Z` → `main`

View File

@@ -0,0 +1,341 @@
---
description: Bump version, auto-generate CHANGELOG from git commits, update all versioned files, and refresh root + docs/ documentation to reflect the current project state
---
# Version Bump Workflow
Automatically bump the project version, generate CHANGELOG entries from git history since the last tag, update every file that references the version, and refresh project documentation to reflect the current state.
> **VERSION RULE: Always use PATCH bumps (3.x.y → 3.x.y+1)**
> NEVER use `npm version minor` or `npm version major`.
> Always use: `npm version patch --no-git-tag-version`
> The threshold rule: when `y` reaches 10, bump to `3.(x+1).0` — e.g. `3.4.10` → `3.5.0`.
---
## Phase 1: Determine Version
### 1. Read current version and last tag
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute
CURRENT_VERSION=$(node -p "require('./package.json').version")
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
CURRENT_BRANCH=$(git branch --show-current)
echo "Current version: $CURRENT_VERSION"
echo "Last tag: $LAST_TAG"
echo "Current branch: $CURRENT_BRANCH"
```
### 2. Calculate new version
Apply the patch bump rule:
- If the current patch number is `9`, the new version is `3.(minor+1).0`
- Otherwise, increment patch: `3.x.y``3.x.(y+1)`
If the version was ALREADY bumped (e.g. you are on a release branch and package.json already has the new version), **skip the npm version bump** and use the existing version.
### 3. Bump package.json (if needed)
// turbo
```bash
# Only if version hasn't been bumped yet
npm version patch --no-git-tag-version
```
Or for threshold (y=10):
```bash
# Manual threshold bump
VERSION="3.X.0" # compute manually
npm version "$VERSION" --no-git-tag-version
```
---
## Phase 2: Generate CHANGELOG from Git History
### 4. Collect commits since last tag
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null)
echo "=== Commits since $LAST_TAG ==="
git log "$LAST_TAG"..HEAD --pretty=format:"%h %s" --no-merges | head -100
echo ""
echo "=== Merge commits ==="
git log "$LAST_TAG"..HEAD --merges --pretty=format:"%h %s" | head -50
```
### 5. Classify commits and generate CHANGELOG section
Analyze each commit message and classify into categories based on the conventional-commit prefix and content:
| Category | Patterns |
| ------------------- | ------------------------------------------------ |
| ✨ New Features | `feat:`, `feat(*):` |
| 🐛 Bug Fixes | `fix:`, `fix(*):` |
| ⚠️ Breaking Changes | `BREAKING CHANGE`, `!:` suffix |
| 🛠️ Maintenance | `chore:`, `refactor:`, `perf:`, `build:` |
| 🧪 Tests | `test:`, `tests:` |
| 📝 Documentation | `docs:` |
| 🔒 Security | `security:`, CVE references, vulnerability fixes |
| 🌍 i18n | translation updates, locale changes |
For each category with entries, create a markdown section with descriptive bullet points. Use the commit messages but rewrite them to be human-readable and descriptive (not raw commit messages).
**If a commit references a PR number** (e.g. `#880`, `PR #885`), include it in the description.
### 6. Update CHANGELOG.md
Replace the `## [Unreleased]` section content with the generated entries, then add the new versioned section:
```markdown
## [Unreleased]
---
## [NEW_VERSION] — YYYY-MM-DD
### ✨ New Features
- **Feature name:** Description (#PR)
### 🐛 Bug Fixes
- **Fix name:** Description (#PR)
### 🛠️ Maintenance
- **Item:** Description
---
## [PREVIOUS_VERSION] — YYYY-MM-DD
...
```
The date must be today's date in `YYYY-MM-DD` format.
---
## Phase 3: Sync Version Across All Files
### 7. Update workspace package.json files and openapi.yaml
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute
VERSION=$(node -p "require('./package.json').version")
# Update docs/reference/openapi.yaml version
sed -i "s/ version: .*/ version: $VERSION/" docs/reference/openapi.yaml
echo "✓ docs/reference/openapi.yaml → $VERSION"
# Update workspace packages (open-sse, electron)
for dir in electron open-sse; do
if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then
(cd "$dir" && npm version "$VERSION" --no-git-tag-version --allow-same-version > /dev/null)
echo "$dir/package.json → $VERSION"
fi
done
echo "✓ All workspace packages synced to $VERSION"
```
### 8. Update llm.txt version references
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute
VERSION=$(node -p "require('./package.json').version")
OLD_VERSION_PATTERN='[0-9]\+\.[0-9]\+\.[0-9]\+'
# Update "Current version:" line
sed -i "s/\*\*Current version:\*\* $OLD_VERSION_PATTERN/**Current version:** $VERSION/" llm.txt
# Update "Key Features (vX.Y.Z)" header
sed -i "s/## Key Features (v$OLD_VERSION_PATTERN)/## Key Features (v$VERSION)/" llm.txt
echo "✓ llm.txt → $VERSION"
```
### 9. Regenerate lock file
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute
npm install
echo "✓ Lock file regenerated"
```
---
## Phase 4: Update Root Documentation
Based on the CHANGELOG entries generated in Phase 2, review and update these root-level files if relevant changes warrant updates:
### 10. Review and update root documentation files
For each file below, read the current content and determine if the CHANGELOG entries require any updates. Only modify files where substantive changes have occurred:
| File | When to update |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `README.md` | New providers, major features, stats changes (test count, provider count), badges, installation instructions, feature table |
| `AGENTS.md` | Architecture changes, new modules, new commands, new providers, new services/handlers/executors |
| `CONTRIBUTING.md` | Dev workflow changes, new tooling, test infrastructure changes |
| `SECURITY.md` | Security fixes, new auth mechanisms, vulnerability disclosures |
| `llm.txt` | Provider count changes, new features, architecture changes |
**Update rules:**
- **README.md**: Update provider count, test count, feature highlights table, badges if any numbers changed. If a new provider was added, add it to the provider table. If a major feature was added, add it to the features section.
- **AGENTS.md**: If new architecture components (handlers, executors, services, DB modules) were added, update the Architecture section. If new commands were added, update the Build/Test table.
- **SECURITY.md**: Add new vulnerability fixes or security improvements to the relevant section.
- **llm.txt**: Update provider count, feature list, version references.
### 11. Review and update docs/ files (excluding i18n/)
For each file in `docs/` (excluding `docs/i18n/`), review if CHANGELOG changes affect it:
| File | When to update |
| --------------------------------------------- | ------------------------------------------------------------------ |
| `docs/reference/API_REFERENCE.md` | New API endpoints, changed request/response formats |
| `docs/architecture/ARCHITECTURE.md` | New modules, new services, changed data flow |
| `docs/architecture/CODEBASE_DOCUMENTATION.md` | New files, architectural changes, module reorganization |
| `docs/architecture/REPOSITORY_MAP.md` | New folders / files / one-line descriptions |
| `docs/reference/CLI-TOOLS.md` | New CLI tool integrations, config format changes |
| `docs/guides/USER_GUIDE.md` | UX changes, new dashboard pages, settings changes |
| `docs/reference/PROVIDER_REFERENCE.md` | New providers (regenerate via `scripts/gen-provider-reference.ts`) |
| `docs/frameworks/MCP-SERVER.md` | New MCP tools, changed tool signatures, scope changes |
| `docs/frameworks/A2A-SERVER.md` | New A2A skills, protocol changes |
| `docs/frameworks/AGENT_PROTOCOLS_GUIDE.md` | New external agent protocols supported |
| `docs/frameworks/CLOUD_AGENT.md` | Cloud agent additions (codex-cloud, devin, jules) or API changes |
| `docs/architecture/AUTHZ_GUIDE.md` | New route classifications, policy changes |
| `docs/security/GUARDRAILS.md` | New guardrails registered, priority/order changes |
| `docs/security/COMPLIANCE.md` | Audit log / retention / no-log policy changes |
| `docs/frameworks/SKILLS.md` | Skill framework / registry / built-in skill changes |
| `docs/frameworks/MEMORY.md` | Memory pipeline / extraction / injection / Qdrant changes |
| `docs/frameworks/EVALS.md` | Evaluation framework changes, new evaluators |
| `docs/frameworks/WEBHOOKS.md` | New webhook events, payload schema changes |
| `docs/routing/REASONING_REPLAY.md` | Reasoning capture/replay pipeline changes |
| `docs/routing/AUTO-COMBO.md` | Routing changes, new strategies, scoring weight changes |
| `docs/architecture/RESILIENCE_GUIDE.md` | Circuit breaker / cooldown / lockout behavior changes |
| `docs/security/STEALTH_GUIDE.md` | TLS / CLI fingerprint changes |
| `docs/ops/TUNNELS_GUIDE.md` | Cloudflare tunnel feature changes |
| `docs/guides/ELECTRON_GUIDE.md` | Electron build / signing / packaging changes |
| `docs/guides/TROUBLESHOOTING.md` | New known issues, resolved problems |
| `docs/ops/RELEASE_CHECKLIST.md` | Process changes |
| `docs/ops/COVERAGE_PLAN.md` | Coverage gate adjustments, target metrics |
| `docs/reference/openapi.yaml` | Already updated in step 7 |
**Only update files where the CHANGELOG entries directly affect the documented content.** Do NOT update files just to bump a version number — only when the documented behavior, features, or architecture has actually changed.
---
## Phase 5: Verify
### 12. Run lint check
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute
npm run lint
```
### 13. Run tests
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute
npm test
```
### 14. Verify version sync across all files
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute
VERSION=$(node -p "require('./package.json').version")
echo "Expected version: $VERSION"
echo ""
echo "--- package.json ---"
grep '"version"' package.json | head -1
echo "--- open-sse/package.json ---"
grep '"version"' open-sse/package.json | head -1
echo "--- electron/package.json ---"
[ -f electron/package.json ] && grep '"version"' electron/package.json | head -1
echo "--- docs/reference/openapi.yaml ---"
grep " version:" docs/reference/openapi.yaml | head -1
echo "--- llm.txt ---"
grep "Current version:" llm.txt
echo "--- CHANGELOG.md (first versioned entry) ---"
grep "^## \[" CHANGELOG.md | head -2
```
### 15. 🛑 STOP — Present Summary to User
**STOP** and present a summary to the user including:
- Old version → New version
- CHANGELOG entries generated
- Files modified
- Test results
- Any documentation updates made
**Wait for the user to confirm before committing.**
---
## Phase 6: Commit (only after user approval)
### 16. Stage and commit
// turbo-all
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute
git add -A
VERSION=$(node -p "require('./package.json').version")
git commit -m "chore(release): bump to v$VERSION — changelog, docs, version sync"
```
---
## Notes
- This workflow does **NOT** create tags, releases, or deploy. Use `/generate-release` for the full release cycle after this.
- This workflow does **NOT** update `docs/i18n/` translations. Translation updates are handled manually or via release tooling — there is no `/update-i18n` workflow shipped in this repo.
- The CHANGELOG generation is based on git commits since the last tag. If there are no new commits, the workflow should inform the user and stop.
- Always verify the generated CHANGELOG entries make sense — raw commit messages may need rewriting for clarity.
- If the version was already bumped (e.g. you're on a `release/vX.Y.Z` branch), skip the `npm version` step and use the existing version.
## Version Touchpoints Checklist
| File | Field/Pattern |
| ----------------------------- | ----------------------------------------------------------- |
| `package.json` | `"version": "X.Y.Z"` |
| `open-sse/package.json` | `"version": "X.Y.Z"` |
| `electron/package.json` | `"version": "X.Y.Z"` |
| `docs/reference/openapi.yaml` | `version: X.Y.Z` |
| `llm.txt` | `**Current version:** X.Y.Z` and `## Key Features (vX.Y.Z)` |
| `CHANGELOG.md` | `## [X.Y.Z] — YYYY-MM-DD` |

View File

@@ -0,0 +1,51 @@
---
description: Automatically run a browser-automation agent to visually validate all new UI features from the current release and capture evidence WebP recordings of the changes.
---
# Capture Release Evidences Workflow
Use this workflow to automatically drive a browser-automation agent to explore the newly deployed or locally running application and record evidence of the UI changes introduced in the latest release.
> **Tool mapping note (v3.8):** This workflow references a `browser_subagent` tool that was specific to an earlier agent runtime. In Claude Code, substitute with the available browser MCP tools (e.g. `mcp__claude-in-chrome__*`) for navigation/screenshots, plus the `Write` tool for saving artifacts. The high-level steps below remain the same regardless of which browser-automation surface is used.
## Prerequisites
- OmniRoute must be actively running and accessible (e.g. locally at `http://localhost:20128` or on the Local VPS at `http://192.168.0.15:20128`).
- The user must provide the target URL to be tested, or default to `http://192.168.0.15:20128`.
## Workflow Steps
### 1. Identify Target Features
Review the `CHANGELOG.md` for the latest version to map out the new UI elements. For example:
- **CLI Tools Settings**
- **New Provider/Model Listings (e.g., Gemini 3.1, Qoder PAT)**
- **New Feature Modals**
### 2. Run the Browser Subagent
For each identified feature, invoke the `browser_subagent` using the `default_api:browser_subagent` tool.
**Important Task Guidelines for the Subagent:**
- `TaskName`: Give it a clear name like "Validate CLIProxyAPI Tool Tab".
- `TaskSummary`: "Navigate to the CLI Tools tab and verify the new Integration settings."
- `Task`: Provide unambiguous instructions for the subagent, such as: "Navigate to http://192.168.0.15:20128/dashboard. Click on the 'Settings' or 'CLI Tools' nav link. Scroll down to find the CLIProxyAPI integration card. Hover over it to trigger UI state. Verify the components render correctly and exit."
- `RecordingName`: Ensure it describes the feature (e.g. `v3_4_5_cli_proxy_api`). This is required and strictly automatically saved as a WebP artifacts video by the system.
_(Note: The `browser_subagent` automatically creates a WebP recording named by the `RecordingName` parameter. No additional tools for screenshots are needed.)_
### 3. Generate Report Artifact
After the `browser_subagent` finishes its sessions, generate a final Markdown artifact (using `Write` and `IsArtifact=true`) to present the recordings inline to the user using the `![caption](/absolute/path/to/media.webp)` syntax.
### Example Invocation
\```json
{
"TaskName": "Validating Qoder PAT Configuration UI",
"TaskSummary": "Validates the Qoder provider configuration modal",
"Task": "Go to http://192.168.0.15:20128/dashboard. Click on the 'Providers' tab. Find 'Qoder' in the list. Click 'Add Token' or 'Configure'. Type 'test_token' and submit. Return when done.",
"RecordingName": "qoder_pat_ui_validation"
}
\```

View File

@@ -0,0 +1,39 @@
---
description: Deploy the latest OmniRoute code to the Akamai VPS (69.164.221.35)
---
# Deploy to Akamai VPS Workflow
Deploy OmniRoute to the Akamai VPS using `npm pack + scp` + PM2.
**Akamai VPS:** `69.164.221.35`
**Process manager:** PM2 (`omniroute`)
**Port:** `20128`
## Steps
### 1. Build + pack locally
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts
```
### 2. Copy to Akamai VPS and install
// turbo-all
```bash
scp omniroute-*.tgz root@69.164.221.35:/tmp/
```
```bash
ssh root@69.164.221.35 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Akamai done'"
```
### 3. Verify the deployment
```bash
curl -s -o /dev/null -w 'AKAMAI HTTP %{http_code}\n' http://69.164.221.35:20128/
```

View File

@@ -0,0 +1,49 @@
---
description: Deploy the latest OmniRoute code to BOTH the Akamai VPS and the Local VPS
---
# Deploy to VPS (Both) Workflow
Deploy OmniRoute to the production VPSs using `npm pack + scp` + PM2.
**Akamai VPS:** `69.164.221.35`
**Local VPS:** `192.168.0.15`
**Process manager:** PM2 (`omniroute`)
**Port:** `20128`
**PM2 entry:** `/usr/lib/node_modules/omniroute/app/server.js`
> [!IMPORTANT]
> The npm registry rejects packages > 100MB, so deployment uses **npm pack + scp**.
## Steps
### 1. Build + pack locally
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts
```
### 2. Copy to both VPS and install
// turbo-all
```bash
scp omniroute-*.tgz root@69.164.221.35:/tmp/ && scp omniroute-*.tgz root@192.168.0.15:/tmp/
```
```bash
ssh root@69.164.221.35 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Akamai done'"
```
```bash
ssh root@192.168.0.15 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Local done'"
```
### 3. Verify the deployment
```bash
curl -s -o /dev/null -w 'AKAMAI HTTP %{http_code}\n' http://69.164.221.35:20128/
curl -s -o /dev/null -w 'LOCAL HTTP %{http_code}\n' http://192.168.0.15:20128/
```

View File

@@ -0,0 +1,39 @@
---
description: Deploy the latest OmniRoute code to the Local VPS (192.168.0.15)
---
# Deploy to Local VPS Workflow
Deploy OmniRoute to the Local VPS using `npm pack + scp` + PM2.
**Local VPS:** `192.168.0.15`
**Process manager:** PM2 (`omniroute`)
**Port:** `20128`
## Steps
### 1. Build + pack locally
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts
```
### 2. Copy to Local VPS and install
// turbo-all
```bash
scp omniroute-*.tgz root@192.168.0.15:/tmp/
```
```bash
ssh root@192.168.0.15 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Local done'"
```
### 3. Verify the deployment
```bash
curl -s -o /dev/null -w 'LOCAL HTTP %{http_code}\n' http://192.168.0.15:20128/
```

View File

@@ -0,0 +1,362 @@
---
description: Create a new release, bump version up to the .10 patch threshold, update changelog, and manage Pull Requests
---
# Generate Release Workflow
Bump version, finalize CHANGELOG, commit, open a **PR to main** and wait for user confirmation before tagging, publishing, and deploying.
> **VERSION RULE: Always use PATCH bumps (3.x.y → 3.x.y+1)**
> NEVER use `npm version minor` or `npm version major`.
> Always use: `npm version patch --no-git-tag-version`
> The threshold rule: when `y` reaches 10, bump to `3.(x+1).0` — e.g. `3.8.10` → `3.9.0`.
> **🔴 SINGLE BRANCH RULE**: The `release/vX.Y.Z` branch is the **ONLY** development branch for the entire release cycle. ALL work — bug fixes, feature implementations, PR integrations, issue resolutions — MUST be committed directly on this branch. Never create separate `fix/`, `feat/`, or topic branches. When running `/resolve-issues`, `/implement-features`, or `/review-prs`, always work on the current release branch.
---
## ⚠️ Two-Phase Flow
```
Phase 1 (automated): bump → docs → i18n → commit → push → open PR
↕ 🛑 STOP: Notify user, wait for PR confirmation
Phase 2 (post-merge): tag → publish → GitHub release → Docker → deploy
```
**NEVER push directly to main or create tags before the user confirms the PR.**
---
## Phase 0: Security Verification (MANDATORY)
Before creating the release, you must ensure the codebase and supply chain are secure and free of known vulnerabilities.
1. **Run Local Dependencies Audit:**
```bash
npm audit
```
_Fix any `high` or `critical` vulnerabilities identified._
2. **Check GitHub CodeQL & Dependabot Alerts:**
Navigate to the repository's **Security** tab on GitHub, or use the project's `vulnerability-scanner` skill to analyze active alerts. Ensure all static analysis findings (e.g., prototype pollution, insecure randomness, ReDoS, shell injections) are addressed and logically committed on a target branch.
---
## Phase 1: Pre-Merge
### 1. Create release branch
```bash
git checkout -b release/v3.x.y
```
### 2. Determine and sync version
Check current version in `package.json`:
```bash
grep '"version"' package.json
```
> **🔴 BRANCH-VERSION PARITY RULE**: The logical version in `package.json` MUST exactly match the release branch name. For example, if you are on `release/v3.7.0`, the version in `package.json` MUST be `3.7.0`.
>
> - If this is the FIRST time generating a release for a new minor/major branch (e.g., bumping from 3.6.9 to 3.7.0), you MUST ensure the version is bumped to match the new branch logic.
> - If you are just bumping a patch on the current branch (e.g., 3.6.9 to 3.6.10), use:
> `npm version patch --no-git-tag-version`
> **⚠️ ATOMIC COMMIT RULE — Version bump MUST happen before committing feature files.**
>
> **CORRECT order:**
>
> 1. `npm version patch --no-git-tag-version` ← bump first
> 2. implement features / fix bugs
> 3. `git add -A && git commit -m "chore(release): v3.x.y — all changes in ONE commit"`
>
> **OR if features are already staged:**
>
> 1. implement features (do NOT commit yet)
> 2. `npm version patch --no-git-tag-version` ← bump before committing
> 3. `git add -A && git commit -m "chore(release): v3.x.y — all changes in ONE commit"`
>
> **NEVER do this (creates version mismatch in git history):**
>
> - ~~commit features → then bump version → commit package.json separately~~
>
> This ensures that `git show v3.x.y` always contains both code changes and the version bump together.
> The GitHub release tag will point to a commit that includes ALL changes for that version.
### 3. Regenerate lock file (REQUIRED after version bump)
**Mandatory** — skipping causes `@swc/helpers` lock mismatch and CI failures:
```bash
npm install
```
### 4. Finalize CHANGELOG.md
> **🔴 NO MIXUPS RULE**: Ensure you do NOT mix the backlog of the previous version with the new one. The new version section must ONLY contain the features and fixes for the current release.
Replace the `[Unreleased]` header with the new version and date.
Keep an empty `## [Unreleased]` section above it, separated by a horizontal rule (`---`).
```markdown
## [Unreleased]
---
## [3.7.0] — 2026-04-19
### ✨ New Features
- ...
### 🐛 Bug Fixes
- ...
---
## [3.6.9] — 2026-04-19
```
### 5. Update openapi.yaml version ⚠️ MANDATORY
> **CI will fail** if `docs/reference/openapi.yaml` version ≠ `package.json` version (`check:docs-sync` enforces this).
// turbo
```bash
VERSION=$(node -p "require('./package.json').version")
sed -i "s/ version: .*/ version: $VERSION/" docs/reference/openapi.yaml
echo "✓ openapi.yaml → $VERSION"
for dir in electron open-sse; do
if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then
(cd "$dir" && npm version "$VERSION" --no-git-tag-version --allow-same-version > /dev/null)
echo "✓ $dir/package.json → $VERSION"
fi
done
# Re-run install to assert the workspace lockfile is updated
npm install
```
### 6. Update README.md and i18n docs
Manually perform these documentation updates (there is no `/update-docs` slash command — it was deprecated in v3.8):
- Update feature table rows and "What's new in vX.Y.Z" section in `README.md`
- Sync feature changes to all 40 language `docs/i18n/*/README.md` files (use the same row edits across each translated README)
- Update the relevant `docs/<AREA>.md` if architecture or counts changed (e.g. `docs/frameworks/MCP-SERVER.md` when MCP tools change)
- Re-run `npm run check:docs-sync` and `npm run check:docs-all` to catch drift
### 7. Run tests
// turbo
```bash
npm test
```
All tests must pass before creating the PR.
### 8. Stage, commit, and push
// turbo-all
```bash
git add -A
git commit -m "chore(release): v3.x.y — summary of changes"
git push origin release/v3.x.y
```
### 9. Open PR to main
### 9. Open PR to main
// turbo
```bash
VERSION=$(node -p "require('./package.json').version")
# Extract the exact changelog entry for this version from the root CHANGELOG.md
awk "/^## \\[$VERSION\\]/{flag=1; print; next} /^---/{if(flag) {flag=0; exit}} flag" CHANGELOG.md > /tmp/changelog_body.txt
# Append test status and next steps
echo "" >> /tmp/changelog_body.txt
echo "### Tests" >> /tmp/changelog_body.txt
echo "- All tests pass" >> /tmp/changelog_body.txt
echo "" >> /tmp/changelog_body.txt
echo "### ⚠️ After merging: run Phase 2 steps to tag, publish, and deploy." >> /tmp/changelog_body.txt
gh pr create \
--repo diegosouzapw/OmniRoute \
--base main \
--head release/v$VERSION \
--title "Release v$VERSION" \
--body-file /tmp/changelog_body.txt
```
### 10. 🛑 STOP — Notify User & Await PR Confirmation
**This is a mandatory stop point.** Present the report in the final response and stop. Do not continue to the next phase until the user explicitly approves.
Inform the user:
- PR URL
- Summary of changes
- Test results
- List of files changed
**DO NOT proceed to Phase 2 until the user confirms the PR looks good and merges it.**
---
## Phase 2: Post-Merge Validation (Local VPS)
> Run these steps only AFTER the user has merged the PR into `main` and all CI jobs have passed.
### 11. Deploy to Local VPS for Final Validation (MANDATORY)
Before cutting the official git tag and publishing to the world, deploy the `main` branch to the Local VPS for a final homologation test.
```bash
git checkout main
git pull origin main
# Build and pack locally
cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts
# Deploy to LOCAL VPS (192.168.0.15)
scp omniroute-*.tgz root@192.168.0.15:/tmp/
ssh root@192.168.0.15 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Local done'"
# Verify
curl -s -o /dev/null -w "LOCAL: HTTP %{http_code}\n" http://192.168.0.15:20128/
```
### 12. 🛑 STOP — Notify User & Await Final OK
**This is a mandatory stop point.**
Inform the user that the `main` branch is now running on the Local VPS.
Wait for the user to manually test and give the **OK**.
**DO NOT proceed to Phase 3 until the user confirms the local deploy is stable.**
---
## Phase 3: Official Launch
> Run these steps only AFTER the user gives the final OK from the Phase 2 local validation.
### 13. Create Git Tag and GitHub Release (MANDATORY)
// turbo
```bash
git checkout main
git pull origin main
VERSION=$(node -p "require('./package.json').version")
# Extracts the changelog section for this version
NOTES=$(awk "/^## \\[$VERSION\\]/{flag=1; next} /^---/{if(flag) {flag=0; exit}} flag" CHANGELOG.md | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
if [ -z "$NOTES" ]; then NOTES="OmniRoute v$VERSION Release"; fi
git tag -a "v$VERSION" -m "Release v$VERSION"
git push origin "v$VERSION"
gh release create "v$VERSION" --repo diegosouzapw/OmniRoute --title "v$VERSION" --notes "$NOTES" --target main || gh release edit "v$VERSION" --repo diegosouzapw/OmniRoute --title "v$VERSION" --notes "$NOTES"
```
### 14. 🐳 Trigger Docker Hub build (MANDATORY — keep npm and Docker in sync)
> **CRITICAL**: Docker Hub and npm MUST always publish the same version.
> The Docker image is built automatically via GitHub Actions when a new tag is pushed.
> After pushing the tag in step 13, **verify the workflow runs**:
```bash
# Verify the Docker workflow triggered
gh run list --repo diegosouzapw/OmniRoute --workflow docker-publish.yml --limit 3
# Wait for the Docker build to complete (usually 510 min)
gh run watch --repo diegosouzapw/OmniRoute
```
### 15. Publish to NPM (Optional/Automated)
Normally handled by CI, but if manual publish is required:
```bash
npm publish
```
### 16. Deploy to AKAMAI VPS (Production)
Now that the release is officially cut, deploy it to the Akamai VPS.
```bash
# Deploy to AKAMAI VPS (69.164.221.35)
scp omniroute-*.tgz root@69.164.221.35:/tmp/
ssh root@69.164.221.35 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Akamai done'"
# Verify
curl -s -o /dev/null -w "AKAMAI: HTTP %{http_code}\n" http://69.164.221.35:20128/
```
## Phase 4: Release Monitoring & Artifact Validation
> After triggering the official release, actively monitor the CI pipelines until all artifacts are successfully generated. If any pipeline fails, stop and apply the necessary corrections before continuing.
### 18. Monitor CI Pipelines
Wait for and verify the successful completion of the following automated jobs:
1. **Docker Hub Publish**
2. **Electron Build**
3. **NPM Registry Publish** (Check with `npm info omniroute version`)
```bash
# Monitor Docker Hub workflow
gh run list --repo diegosouzapw/OmniRoute --workflow docker-publish.yml --limit 1
gh run watch <RUN_ID>
# Monitor Electron build
gh run list --repo diegosouzapw/OmniRoute --workflow electron-release.yml --limit 1
gh run watch <RUN_ID>
# Verify NPM version
npm info omniroute version
```
### 19. Handle Failures (If Any)
If a workflow fails:
- Use `gh run view <RUN_ID> --log-failed` to identify the error.
- Apply the fix on the `main` branch.
- If necessary, re-trigger the workflow using `gh workflow run <workflow_name.yml> --repo diegosouzapw/OmniRoute --ref v3.x.y`
### 20. Preserve release branch
```bash
# Branch is kept for historical purposes. Do not delete.
```
---
## Notes
- Ensure CHANGELOG, README and `docs/*` are current BEFORE this workflow — run `npm run check:docs-all` and `/version-bump` first (there is no `/update-docs` slash command anymore)
- The `prepublishOnly` script runs `npm run build:cli` automatically during `npm publish`
- After npm publish, verify with `npm info omniroute version`
- Lock file sync errors are caused by skipping `npm install` after version bump
- Use `gh auth switch -u diegosouzapw` if git push fails with wrong account
## Known CI Pitfalls
| CI failure | Cause | Fix |
| ------------------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------- |
| `[docs-sync] FAIL - OpenAPI version differs from package.json` | Skipped step 5 — `docs/reference/openapi.yaml` version not updated | Run step 5 (`sed -i ...`) and commit |
| `[docs-sync] FAIL - CHANGELOG.md first section must be "## [Unreleased]"` | `## [Unreleased]` missing or not at top of CHANGELOG | Add `## [Unreleased]\n\n---\n` before the first versioned `## [x.y.z]` |
| Electron Linux `.deb` build fails (`FpmTarget` error) | `fpm` Ruby gem not installed on `ubuntu-latest` runner | Already fixed in `electron-release.yml` (`gem install fpm` step) |
| Docker Hub `502 error writing layer blob` | Transient Docker Hub network error during ARM64 push | Re-run the Docker publish workflow; no code change needed |

View File

@@ -0,0 +1,50 @@
---
description: How to respond to GitHub issues with insufficient information
---
# Issue Triage Workflow
Respond to GitHub issues that need more information before they can be investigated.
## Steps
### 1. Identify issues needing triage
```bash
gh issue list --state open --limit 20
```
### 2. Evaluate each issue
Check if the issue has:
- Clear reproduction steps
- Environment details (OS, Node.js version, OmniRoute version)
- Error logs/screenshots
- Expected vs actual behavior
### 3. Respond with triage template
For issues missing information:
```markdown
Thank you for reporting this issue! To help us investigate, please provide:
1. **OmniRoute version**: (`omniroute --version`)
2. **Node.js version**: (`node --version`)
3. **Operating system**: (e.g., Ubuntu 24.04, macOS 15, Windows 11)
4. **Installation method**: (npm, Docker, source)
5. **Steps to reproduce**: (exact commands/actions that trigger the issue)
6. **Error logs**: (paste relevant logs from the console)
7. **Expected behavior**: (what should happen)
This will help us debug and resolve your issue faster. 🙏
```
### 4. Label the issue
Add appropriate labels: `needs-info`, `bug`, `enhancement`, `question`, etc.
```bash
gh issue edit <NUMBER> --add-label "needs-info"
```

View File

@@ -0,0 +1,166 @@
---
description: Fetch all open GitHub issues, analyze bugs, resolve what's possible, triage the rest, wait for user validation, then commit and release
---
# /resolve-issues — Automated Issue Resolution Workflow
## Overview
This workflow fetches all open issues from the project's GitHub repository, classifies them, analyzes bugs, proposes a resolution plan, waits for user validation, and ONLY THEN implements the fixes, commits, and closes the issues on the current release branch (`release/vX.Y.Z`). It does NOT merge or release automatically — the release branch is later merged via PR to main.
> **BRANCH RULE**: All work MUST happen on the current `release/vX.Y.Z` branch. Never create separate `fix/` branches. If no release branch exists yet, create one first using `/generate-release` Phase 1 steps 15.
> **⛔ PR PROHIBITION**: If a fix is associated with a contributor's PR, you MUST merge their PR — NEVER close it and re-implement the fix yourself. See `/review-prs` workflow for the full policy. The `gh pr close` command is FORBIDDEN unless the repository owner explicitly requests it.
## Steps
### 1. Identify the GitHub Repository
// turbo
- Run: `git -C <project_root> remote get-url origin` to extract the owner/repo
- Parse the owner and repo name from the URL
### 2. Ensure Release Branch Exists
// turbo
Before doing any work, ensure you are on the current release branch:
```bash
# Check current branch
git branch --show-current
# If on main, determine next version and create the release branch
VERSION=$(node -p "require('./package.json').version")
NEXT=$(node -p "const [a,b,c]=('$VERSION').split('.').map(Number); c>=9?a+'.'+(b+1)+'.0':a+'.'+b+'.'+(c+1)")
git checkout -b release/v$NEXT
npm version patch --no-git-tag-version
npm install
```
If already on a `release/vX.Y.Z` branch, continue working there.
### 3. Fetch All Open Issues
// turbo-all
**⚠️ CRITICAL**: The JSON output of `gh issue list` can be truncated by the tool, silently hiding issues. You MUST use the two-step approach below to guarantee **all** issues are fetched.
**Step 3a — Get Issue numbers only** (small output, never truncated):
- Run: `gh issue list --repo <owner>/<repo> --state open --limit 500 --json number --jq '.[].number'`
- This outputs one issue number per line. Count them and confirm total.
**Step 3b — Fetch full metadata for each Issue** (one call per issue):
- For each issue number from step 3a, run:
`gh issue view <NUMBER> --repo <owner>/<repo> --json number,title,labels,body,comments,createdAt,author`
- You may batch these into parallel calls (up to 4 at a time).
- Sort by oldest first (FIFO).
### 4. Classify Each Issue
For each issue, determine its type:
- **Bug** — Has `bug` label, or body contains error messages, stack traces, "doesn't work", "broken", "crash", "error"
- **Feature Request** — Has `enhancement`/`feature` label, or body describes new functionality
- **Question** — Has `question` label, or is asking "how to" something
- **Other** — Anything else
Focus ONLY on **Bugs** for resolution. Feature requests and questions should be skipped with a note in the final report.
### 5. Deep-Read Each Bug Issue (One-by-One Analysis)
**IMPORTANT**: Read each bug issue thoroughly, one at a time, before moving to the next. This is NOT a batch process — each issue needs focused attention.
#### 5a. Understand the Problem
For each bug issue, perform the full analysis:
1. **Read the entire body** — including Description, Steps to Reproduce, Expected/Actual Behavior, Error Logs, and Screenshots
2. **Read ALL comments** — including bot triage comments (Kilo, etc.) and owner/community responses. Pay attention to:
- Whether someone already responded with a fix
- Whether a community member confirmed the issue is resolved
- Whether the issue was marked as duplicate by a bot. **WARNING: DO NOT blindly trust bot duplicate labels (e.g., kilo-duplicate). Bots make mistakes. You MUST read the full conversation and do your own independent analysis to determine if it is truly a duplicate or a distinct bug.**
3. **Identify the claimed error** — extract the exact error message, status code, and provider/model involved
#### 5b. Check Information Sufficiency
Verify the issue contains enough to act on:
- [ ] Clear description of the problem
- [ ] Steps to reproduce OR error logs
- [ ] Provider/model/version information
- [ ] Expected vs actual behavior
#### 5c. Determine Issue Disposition
For each bug, classify into one of 5 actions:
| Disposition | When to Apply | Action |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| **✅ CLOSE — Already Fixed** | Owner responded with fix + no user follow-up, OR community confirmed fix | Close with comment citing which version fixed it |
| **✅ CLOSE — Duplicate** | You have independently verified the issue is a duplicate (do NOT rely solely on bot flags) + user provides no new info | Close referencing the original issue |
| **✅ CLOSE — Stale** | We requested logs/info > 7 days ago with no reply | Close thanking the user, invite to reopen if needed |
| **📝 RESPOND — Needs Info** | Issue is real but missing critical reproduction details | Comment asking for specifics per `/issue-triage` |
| **📝 RESPOND — User Config** | Error is caused by unsupported env (Node version, wrong model path, missing API enablement) | Comment explaining the user-side fix |
| **🔧 FIX — Code Change** | Root cause is confirmed in the codebase | Research, propose solution in report, wait for approval |
#### 5d. For "FIX — Code Change" Issues
Before coding, perform deep source analysis to formulate a plan:
1. **Search the codebase**`grep_search` for error strings, relevant function names, affected files
2. **Search the web** — for upstream API changes, SDK updates, or breaking changes that explain the bug
3. **Read the full source file** — don't rely on grep snippets; understand the surrounding logic
4. **Verify the root cause** — confirm the bug is reproducible based on the code, not just a user misconfiguration
5. **Formulate a proposed solution** — detail the exact files and lines you will change and how you will solve it.
6. **Create an Implementation Plan file** — write your proposed solution to `_tasks/features-vX.Y.Z/<ISSUE_NUMBER>-<short-description>.plan.md` (e.g. `_tasks/features-v3.7.6/1810-auto-restore-probe-failed-db.plan.md`) where `vX.Y.Z` is the current branch version. The plan should contain an Overview, Pre-Implementation Checklist, and detailed Implementation Steps (Files, Changes).
7. **DO NOT modify the codebase yet** — wait for user approval on your report and plan first.
#### 5e. For "RESPOND" Issues
Post a substantive comment that:
- Acknowledges the specific error they reported
- Explains the likely root cause
- Provides concrete steps to resolve (version upgrade, env var fix, model path correction)
- Asks for follow-up info if needed
**Do NOT post generic template responses.** Every comment should reference the user's specific error messages and environment.
### 6. Generate Report & Wait for Validation
Present a summary report to the user detailing your proposed actions. For any bugs that need fixing, explicitly explain your proposed solution (files to change and logic) and point out that it will be implemented on the release branch (`release/vX.Y.Z`) after approval.
| Issue | Title | Status | Proposed Action / Version |
| ----- | ----- | ------------- | ----------------------------------------- |
| #N | Title | ✅ Close | Already fixed / duplicate (explain why) |
| #N | Title | 🔧 Propose | Explanation of the code fix to be applied |
| #N | Title | 📝 Respond | Guidance comment to be posted |
| #N | Title | ❓ Needs Info | Triage comment to be posted |
| #N | Title | ⏭️ Skip | Feature request / not a bug |
> **⚠️ IMPORTANT**: Do NOT implement code changes, commit, push, or close issues at this step.
> Wait for the user to review the proposed fixes and respond with **OK** before proceeding.
- If the user says **OK** or approves → Proceed to step 7
- If the user requests changes → Adjust the proposed solution and present the report again
- If the user rejects → Revert any accidental changes and stop
### 7. Implement Fixes, Run Tests & Commit (only after user approval)
After the user validates and gives the OK:
1. **Implement the fixes** — modify the codebase according to the approved plan.
2. **Run tests**`npm run test:all` (or the specific test file) to ensure 100% pass.
3. **Update CHANGELOG.md** with all new bug fix entries.
4. **Commit** each fix individually on the release branch with message format: `fix: <description> (#<issue_number>)`.
5. **Push** the release branch: `git push origin release/vX.Y.Z`.
6. **Close resolved issues immediately**. For each issue that was marked as Fixed, run:
`gh issue close <NUMBER> --repo <owner>/<repo> --comment "Thank you for reporting! This issue has been fixed and will be included in the next release (vX.Y.Z)."`
7. Likewise, close `Duplicate` issues referencing the original, close `Needs Info` if stale, and post the required comments.
8. If the project runs automatic releases or needs a PR, proceed to run `/generate-release` workflow Phase 1 steps 710 (tests → commit → push → open PR to main → wait for user).
If NO fixes were committed, skip closing and source control steps and just conclude the workflow.

View File

@@ -0,0 +1,120 @@
---
description: Read all open GitHub Discussions, summarize them, respond to pending ones, and create issues from actionable feature requests
---
# /review-discussions — GitHub Discussions Review & Response Workflow
## Overview
This workflow reads all open GitHub Discussions, generates a categorized summary, identifies which ones need a response, drafts and posts replies, and optionally creates issues from actionable feature requests. It follows the same flow used for Issues but adapted for the Discussions forum.
> **Tool mapping note (v3.8):** Where steps below say `browser_subagent` (an earlier-runtime tool), in Claude Code use the `gh` CLI via the `Bash` tool — `gh api graphql` for reading discussions and `gh api graphql -F query=...` mutations for posting comments. `WebFetch` is acceptable for read-only HTML scraping when GraphQL is overkill, but prefer `gh` for any write actions.
// turbo-all
## Steps
### 1. Identify the GitHub Repository
- Run: `git -C <project_root> remote get-url origin` to extract the owner/repo
- Parse the owner and repo name from the URL
### 2. Fetch All Open Discussions
- Use `WebFetch` to fetch `https://github.com/<owner>/<repo>/discussions`
- Parse the discussion list to get all discussion titles, IDs, authors, categories, and dates
- For each discussion, fetch the individual page to read the full content and all comments/replies
### 3. Summarize All Discussions
For each discussion, extract:
- **Title** and **#Number**
- **Author** (GitHub username)
- **Category** (Announcements, General, Ideas, Q&A, Show and tell)
- **Date** created
- **Summary** of the original post (1-2 sentences)
- **Comments count** and key participants
- **Your previous response** (if any)
- **Pending action** — whether a response or follow-up is needed
### 4. Present Summary Report to User
Present the full summary to the user organized by category, using a table:
| # | Category | Title | Author | Date | Status |
| --- | -------- | ----- | ------ | ------ | ----------------- |
| #N | Ideas | Title | @user | Mar 23 | ⚠️ Needs response |
| #N | Q&A | Title | @user | Mar 9 | ✅ Answered |
| #N | General | Title | @user | Mar 19 | ⚠️ Needs response |
Highlight:
- **⚠️ Needs response** — No reply from maintainer, or a follow-up comment was left unanswered
- **✅ Answered** — Maintainer already responded
- **🐛 Bug reported** — A bug was mentioned that needs tracking
- **💡 Actionable** — Contains a concrete feature request that could become an issue
### 5. Draft & Post Responses
For each discussion that needs a response, draft a reply following these guidelines:
#### Response Style
- **Friendly and professional** — Start with "Hey @username!"
- **Acknowledge the contribution** — Thank the user for their input
- **Be specific** — Reference existing features, settings, or dashboard pages if the feature already exists
- **Provide workarounds** — If the request isn't implemented yet, suggest current alternatives
- **Commit to action** — If the request is valid, state that you'll open an issue or add it to the roadmap
- **Keep it concise** — 3-5 paragraphs max
#### Posting via Browser
- Use `browser_subagent` to navigate to each discussion and post the comment
- **IMPORTANT**: When typing text in GitHub comment boxes via the browser, use only plain ASCII characters:
- Use regular hyphens `-` instead of em-dashes
- Use `->` instead of arrow symbols
- Do NOT use emoji Unicode characters (the browser keyboard may fail on them)
- Use `**bold**` and `\`code\`` markdown formatting
- Click the green "Comment" button (or "Reply" for threaded replies) after typing
- Verify the comment was posted by checking the page shows the new comment
### 6. Create Issues from Actionable Feature Requests
For discussions that contain concrete, actionable feature requests:
1. Ask the user which ones should become issues
2. For each approved request, create a GitHub issue via `browser_subagent`:
- Navigate to `https://github.com/<owner>/<repo>/issues/new`
- **Title**: `<Feature Name> - <Short description>`
- **Body** should include:
- `## Feature Request` header
- `**Source:** Discussion #N by @author`
- `## Problem` — What limitation the user hit
- `## Proposed Solution` — How it could work
- `### Implementation Ideas` — Technical approach
- `### Current Workarounds` — What users can do today
- `## Additional Context` — Links to related issues/discussions
- Add `enhancement` label
- Click "Submit new issue" / "Create"
3. After creation, go back to the original discussion and post a comment linking to the new issue:
- "I've opened Issue #N to track this feature request. Follow along there for updates!"
### 7. Final Report
Present a final summary to the user:
| Discussion | Action Taken |
| ---------- | ---------------------------------- |
| #N — Title | Responded with workarounds |
| #N — Title | Responded + created Issue #N |
| #N — Title | Already answered, no action needed |
| #N — Title | Responded to follow-up comment |
## Notes
- This workflow is **interactive** — always present the summary and wait for user approval before posting responses or creating issues
- If the user says "pode responder" (or similar approval), proceed with posting all drafted responses
- For discussions in non-English languages, respond in the same language as the original post
- Always reference specific dashboard paths, config options, or code files when explaining existing features
- When a discussion reveals a bug, note it separately from feature requests

View File

@@ -0,0 +1,256 @@
---
description: Analyze open Pull Requests from the project's GitHub repository, generate a critical report, and optionally implement approved changes
---
# /review-prs — PR Review & Analysis Workflow
## ⛔ ABSOLUTE PROHIBITION — Read Before Anything Else
> **NEVER close a contributor's PR if you intend to use ANY of their code, ideas, or fixes.**
>
> **NEVER manually integrate contributor code into a release branch and then close their PR.**
>
> These actions are **STRICTLY FORBIDDEN** under all circumstances:
>
> 1. ❌ Closing a PR and cherry-picking/copying its code into a release branch
> 2. ❌ Closing a PR "because of conflicts" and re-implementing the same fix yourself
> 3. ❌ Closing a PR and committing a "similar" solution inspired by it
> 4. ❌ Using `gh pr close` on any PR whose content was or will be used
>
> **Why**: Closing a PR after taking the contributor's work means they get ZERO credit on GitHub — no "Merged" badge, no contribution graph entry, no public record. This is effectively stealing their contribution. An audit found this happened to **37 PRs** in the past.
>
> **The ONLY acceptable flow**: Resolve conflicts IN the contributor's branch, push fixes TO their branch, then merge THEIR PR via `gh pr merge`. See Step 7 and Step 8 for the exact procedure.
>
> **When to close a PR**: ONLY when the user (repository owner) explicitly requests it, OR when the PR is clearly spam/malicious, OR when the author themselves asks to close it. In ALL other cases, leave it open.
## Overview
This workflow fetches all open PRs from the project's GitHub repository, performs a critical analysis of each one, generates a detailed report, and waits for user approval before proceeding with implementation. **All improvements are committed on the current release branch** (`release/vX.Y.Z`).
> **BRANCH RULE**: PRs are ALWAYS merged into the current `release/vX.Y.Z` branch, NEVER directly into `main`. The release branch acts as a staging area — only after all PRs are integrated and tests pass does the release branch get merged into `main` via the `/generate-release` workflow.
## Steps
### 1. Identify the GitHub Repository
- Read `package.json` to get the repository URL, or use the git remote origin URL
// turbo
- Run: `git -C <project_root> remote get-url origin` to extract the owner/repo
### 2. Ensure Release Branch Exists
// turbo
Before doing any work, ensure you are on the current release branch:
```bash
# Check current branch
git branch --show-current
# If on main, determine next version and create the release branch
VERSION=$(node -p "require('./package.json').version")
# Bump patch: e.g. 3.3.11 → 3.3.12
NEXT=$(node -p "const [a,b,c]=('$VERSION').split('.').map(Number); c>=9?a+'.'+(b+1)+'.0':a+'.'+b+'.'+(c+1)")
git checkout -b release/v$NEXT
npm version patch --no-git-tag-version
npm install
```
If already on a `release/vX.Y.Z` branch, continue working there.
### 3. Fetch Open Pull Requests
// turbo-all
**⚠️ CRITICAL**: The JSON output of `gh pr list` can be truncated by the tool, silently hiding PRs. You MUST use the two-step approach below to guarantee **all** PRs are fetched.
**Step 3a — Get PR numbers only** (small output, never truncated):
- Run: `gh pr list --repo <owner>/<repo> --state open --limit 500 --json number --jq '.[].number'`
- This outputs one PR number per line. Count them and confirm total.
**Step 3b — Fetch full metadata for each PR** (one call per PR):
- For each PR number from step 3a, run:
`gh pr view <NUMBER> --repo <owner>/<repo> --json number,title,author,headRefName,baseRefName,body,createdAt,additions,deletions,files`
- You may batch these into parallel calls (up to 4 at a time).
**Step 3c — Fetch diffs for each PR** (one call per PR, saved to /tmp):
- For each PR number, run:
`gh pr diff <NUMBER> --repo <owner>/<repo> > /tmp/pr<NUMBER>.diff`
- Then read each diff file with the `Read` tool.
- For each open PR, collect:
- PR number, title, author, branch, number of commits, date
- PR description/body
- Files changed (diff)
- Existing review comments (from bots or humans)
**Verification**: Confirm the count of PRs analyzed matches the count from step 3a before proceeding.
### 3.5 Redirect PR Base Branches to Release Branch
// turbo-all
**⚠️ CRITICAL**: Contributors typically open PRs targeting `main`. Before analyzing or merging, redirect ALL open PRs to target the current release branch instead.
```bash
# Get the current release branch name
RELEASE_BRANCH=$(git branch --show-current) # e.g. release/v3.5.4
# For each open PR that targets main, change its base to the release branch
for PR_NUM in $(gh pr list --repo <owner>/<repo> --state open --json number,baseRefName --jq '.[] | select(.baseRefName == "main") | .number'); do
echo "Redirecting PR #$PR_NUM$RELEASE_BRANCH"
gh pr edit "$PR_NUM" --repo <owner>/<repo> --base "$RELEASE_BRANCH"
done
```
This ensures:
1. PRs merge into the release branch, not directly into `main`
2. Merge conflict detection is accurate against the release branch
3. The release branch accumulates all changes before the final merge to `main`
4. If the release branch doesn't exist on remote yet, push it first: `git push origin $RELEASE_BRANCH`
### 4. Analyze Each PR — For each open PR, perform the following analysis:
#### 4a. Feature Assessment
- **Does it make sense?** Evaluate if the feature fills a real gap or solves a valid problem
- **Alignment** — Check if it aligns with the project's architecture and roadmap
- **Complexity** — Assess if the scope is reasonable or if it should be split
#### 4b. Code Quality Review
- Check for code duplication
- Evaluate error handling patterns (consistent with existing codebase?)
- Check naming conventions and code style
- Verify TypeScript types (any `any` usage, missing types?)
#### 4c. Security Review
- Check for missing authentication/authorization on new endpoints
- Check for injection vulnerabilities (URL params, SQL, XSS)
- Verify input validation on all user-controlled data
- Check for hardcoded secrets or credentials
#### 4d. Architecture Review
- Does the change follow existing patterns?
- Are there any breaking changes to public APIs?
- Is the database schema affected? Migration needed?
- Impact on performance (N+1 queries, missing indexes?)
#### 4e. Test Coverage
- Does the PR include tests?
- Are edge cases covered?
- Would existing tests break?
#### 4f. Cross-Layer (Global) Analysis
Perform a **global impact assessment** to verify whether the PR changes are complete across all layers of the application:
- **Backend → Frontend check**: If the PR adds or modifies backend-only resources (new endpoints, services, data models), evaluate whether corresponding frontend changes are missing:
- Does a new endpoint require a new screen/page in the dashboard?
- Should there be a new action button, menu item, or navigation link?
- Are there new data fields that should be displayed or editable in the UI?
- Does a new feature need a toggle, configuration panel, or status indicator?
- **Frontend → Backend check**: If the PR adds frontend elements, verify the backend support exists:
- Are the required API endpoints implemented?
- Is the data model sufficient for the new UI components?
- **Cross-cutting concerns**: Check shared layers (types, DTOs, validation schemas, routes, middleware) for completeness
- **Document gaps** — If missing layers are detected, list them as **IMPORTANT** issues in the report with concrete suggestions for what should be added
### 5. Generate Report — Create a markdown report for each PR including:
- **PR Summary** — What it does, files affected, commit count
- **Improvements/Benefits** — Numbered list with impact level (HIGH/MEDIUM/LOW)
- **Risks & Issues** — Categorized as CRITICAL / IMPORTANT / MINOR
- **Scoring Table** — Rate across: Feature Relevance, Code Quality, Security, Robustness, Tests
- **Verdict** — Ready to merge? With mandatory vs optional fixes
- **Next Steps** — What will happen if approved
### 6. Present to User
- Show the report in the final response and stop. This is a mandatory checkpoint awaiting explicit user approval before continuing.
- Wait for user decision:
- **Approved** → Proceed to step 7
- **Approved with changes** → Implement the fixes and corrections before merging
- **Rejected** → Close the PR or leave a review comment
### 7. Pre-Merge Fixes & CI Green-Lighting (if approved)
> **⚠️ Fixes and Conflict Resolutions MUST be pushed back to the PR branch before merging.** We want the PR itself to be green and fully valid before it integrates.
- **Sync latest fixes & Resolve Conflicts:** Merge the current `release` branch into the PR branch. If there are merge conflicts, you MUST resolve them inside the author's PR branch. NEVER resolve conflicts by closing their PR and doing the work in a separate branch, as this steals credit from the original author.
- **Implement improvements:** Apply the required fixes identified in the analysis directly on the PR branch (e.g., adding missing API routes, fixing SSRF, applying comments from other agents).
- **Pushing changes to PR branches:**
```bash
# Checkout the PR locally
gh pr checkout <NUMBER>
# Apply fixes, commit your changes
git commit -m "chore: apply review suggestions and missing layers"
# Attempt to push directly to the PR branch
git push
```
- **Fallback (ONLY for external forks without maintainer edit access):**
Using `cherry-pick` instead of fixing the contributor's PR directly is a **LAST RESORT**. You MUST ALWAYS attempt to `git push` your fixes to their branch first.
**ONLY if `git push` explicitly fails with a permission/access error** (meaning the contributor unchecked "Allow edits from maintainers" or it's a locked fork), you may use `git cherry-pick` to bring their changes into the release branch and fix the issues locally.
Even then, 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**. Leave it open so the contributor retains credit. Under NO CIRCUMSTANCES should you use `gh pr close`.
- Run the project's test suite locally to verify nothing breaks:
// turbo
- Run: `npm test` or equivalent test command
### 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.
> You MUST ALWAYS resolve conflicts and apply fixes ON THE AUTHOR'S PR BRANCH (unless explicitly locked from edits), and then merge the PR using GitHub so the contributor gets the official "Merged" badge and proper credit on their profile. **Do not use cherry-pick just because it is "easier" than resolving conflicts on their branch.**
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) 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 or cherry-picked).
- Note it will be included in the upcoming release.
- Be friendly, professional, and encouraging.
> **⚠️ MANDATORY CHANGELOG CREDIT**: When cherry-picking is used (because the PR branch couldn't be pushed to or `gh pr merge` failed), the contributor does NOT get the automatic GitHub "Merged" badge. In this case, you MUST compensate by adding an explicit entry to `CHANGELOG.md` in the `[Unreleased]` section with `(#PR_NUMBER — thanks @username)` format. This ensures the contributor gets public credit in the release notes even if GitHub doesn't auto-detect the cherry-pick. This is NOT optional — skipping it effectively erases the contributor's work from the release record.
### 9. Sync Local Release Branch
After merging PRs, sync the local release branch to include the new changes:
```bash
git fetch origin
git pull origin release/vX.Y.Z
```
### 10. Continue or Finalize
After processing all approved PRs:
- If more PRs remain, go back to step 7
- When all PRs are processed, **update CHANGELOG.md** on the release branch with all new entries
- Run **test coverage** to verify the gate (≥75% statements/lines/functions, ≥70% branches — measured ~82%):
```bash
npm run test:coverage
```
- Fix any test regressions introduced by merged PRs
- Run `/generate-release` workflow Phase 1 steps 710 (tests → commit → push → open PR to main → wait for user)
- The `/generate-release` workflow handles the final merge from `release/vX.Y.Z` → `main`

View File

@@ -0,0 +1,341 @@
---
description: Bump version, auto-generate CHANGELOG from git commits, update all versioned files, and refresh root + docs/ documentation to reflect the current project state
---
# Version Bump Workflow
Automatically bump the project version, generate CHANGELOG entries from git history since the last tag, update every file that references the version, and refresh project documentation to reflect the current state.
> **VERSION RULE: Always use PATCH bumps (3.x.y → 3.x.y+1)**
> NEVER use `npm version minor` or `npm version major`.
> Always use: `npm version patch --no-git-tag-version`
> The threshold rule: when `y` reaches 10, bump to `3.(x+1).0` — e.g. `3.4.10` → `3.5.0`.
---
## Phase 1: Determine Version
### 1. Read current version and last tag
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute
CURRENT_VERSION=$(node -p "require('./package.json').version")
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
CURRENT_BRANCH=$(git branch --show-current)
echo "Current version: $CURRENT_VERSION"
echo "Last tag: $LAST_TAG"
echo "Current branch: $CURRENT_BRANCH"
```
### 2. Calculate new version
Apply the patch bump rule:
- If the current patch number is `9`, the new version is `3.(minor+1).0`
- Otherwise, increment patch: `3.x.y``3.x.(y+1)`
If the version was ALREADY bumped (e.g. you are on a release branch and package.json already has the new version), **skip the npm version bump** and use the existing version.
### 3. Bump package.json (if needed)
// turbo
```bash
# Only if version hasn't been bumped yet
npm version patch --no-git-tag-version
```
Or for threshold (y=10):
```bash
# Manual threshold bump
VERSION="3.X.0" # compute manually
npm version "$VERSION" --no-git-tag-version
```
---
## Phase 2: Generate CHANGELOG from Git History
### 4. Collect commits since last tag
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null)
echo "=== Commits since $LAST_TAG ==="
git log "$LAST_TAG"..HEAD --pretty=format:"%h %s" --no-merges | head -100
echo ""
echo "=== Merge commits ==="
git log "$LAST_TAG"..HEAD --merges --pretty=format:"%h %s" | head -50
```
### 5. Classify commits and generate CHANGELOG section
Analyze each commit message and classify into categories based on the conventional-commit prefix and content:
| Category | Patterns |
| ------------------- | ------------------------------------------------ |
| ✨ New Features | `feat:`, `feat(*):` |
| 🐛 Bug Fixes | `fix:`, `fix(*):` |
| ⚠️ Breaking Changes | `BREAKING CHANGE`, `!:` suffix |
| 🛠️ Maintenance | `chore:`, `refactor:`, `perf:`, `build:` |
| 🧪 Tests | `test:`, `tests:` |
| 📝 Documentation | `docs:` |
| 🔒 Security | `security:`, CVE references, vulnerability fixes |
| 🌍 i18n | translation updates, locale changes |
For each category with entries, create a markdown section with descriptive bullet points. Use the commit messages but rewrite them to be human-readable and descriptive (not raw commit messages).
**If a commit references a PR number** (e.g. `#880`, `PR #885`), include it in the description.
### 6. Update CHANGELOG.md
Replace the `## [Unreleased]` section content with the generated entries, then add the new versioned section:
```markdown
## [Unreleased]
---
## [NEW_VERSION] — YYYY-MM-DD
### ✨ New Features
- **Feature name:** Description (#PR)
### 🐛 Bug Fixes
- **Fix name:** Description (#PR)
### 🛠️ Maintenance
- **Item:** Description
---
## [PREVIOUS_VERSION] — YYYY-MM-DD
...
```
The date must be today's date in `YYYY-MM-DD` format.
---
## Phase 3: Sync Version Across All Files
### 7. Update workspace package.json files and openapi.yaml
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute
VERSION=$(node -p "require('./package.json').version")
# Update docs/reference/openapi.yaml version
sed -i "s/ version: .*/ version: $VERSION/" docs/reference/openapi.yaml
echo "✓ docs/reference/openapi.yaml → $VERSION"
# Update workspace packages (open-sse, electron)
for dir in electron open-sse; do
if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then
(cd "$dir" && npm version "$VERSION" --no-git-tag-version --allow-same-version > /dev/null)
echo "$dir/package.json → $VERSION"
fi
done
echo "✓ All workspace packages synced to $VERSION"
```
### 8. Update llm.txt version references
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute
VERSION=$(node -p "require('./package.json').version")
OLD_VERSION_PATTERN='[0-9]\+\.[0-9]\+\.[0-9]\+'
# Update "Current version:" line
sed -i "s/\*\*Current version:\*\* $OLD_VERSION_PATTERN/**Current version:** $VERSION/" llm.txt
# Update "Key Features (vX.Y.Z)" header
sed -i "s/## Key Features (v$OLD_VERSION_PATTERN)/## Key Features (v$VERSION)/" llm.txt
echo "✓ llm.txt → $VERSION"
```
### 9. Regenerate lock file
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute
npm install
echo "✓ Lock file regenerated"
```
---
## Phase 4: Update Root Documentation
Based on the CHANGELOG entries generated in Phase 2, review and update these root-level files if relevant changes warrant updates:
### 10. Review and update root documentation files
For each file below, read the current content and determine if the CHANGELOG entries require any updates. Only modify files where substantive changes have occurred:
| File | When to update |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `README.md` | New providers, major features, stats changes (test count, provider count), badges, installation instructions, feature table |
| `AGENTS.md` | Architecture changes, new modules, new commands, new providers, new services/handlers/executors |
| `CONTRIBUTING.md` | Dev workflow changes, new tooling, test infrastructure changes |
| `SECURITY.md` | Security fixes, new auth mechanisms, vulnerability disclosures |
| `llm.txt` | Provider count changes, new features, architecture changes |
**Update rules:**
- **README.md**: Update provider count, test count, feature highlights table, badges if any numbers changed. If a new provider was added, add it to the provider table. If a major feature was added, add it to the features section.
- **AGENTS.md**: If new architecture components (handlers, executors, services, DB modules) were added, update the Architecture section. If new commands were added, update the Build/Test table.
- **SECURITY.md**: Add new vulnerability fixes or security improvements to the relevant section.
- **llm.txt**: Update provider count, feature list, version references.
### 11. Review and update docs/ files (excluding i18n/)
For each file in `docs/` (excluding `docs/i18n/`), review if CHANGELOG changes affect it:
| File | When to update |
| --------------------------------------------- | ------------------------------------------------------------------ |
| `docs/reference/API_REFERENCE.md` | New API endpoints, changed request/response formats |
| `docs/architecture/ARCHITECTURE.md` | New modules, new services, changed data flow |
| `docs/architecture/CODEBASE_DOCUMENTATION.md` | New files, architectural changes, module reorganization |
| `docs/architecture/REPOSITORY_MAP.md` | New folders / files / one-line descriptions |
| `docs/reference/CLI-TOOLS.md` | New CLI tool integrations, config format changes |
| `docs/guides/USER_GUIDE.md` | UX changes, new dashboard pages, settings changes |
| `docs/reference/PROVIDER_REFERENCE.md` | New providers (regenerate via `scripts/gen-provider-reference.ts`) |
| `docs/frameworks/MCP-SERVER.md` | New MCP tools, changed tool signatures, scope changes |
| `docs/frameworks/A2A-SERVER.md` | New A2A skills, protocol changes |
| `docs/frameworks/AGENT_PROTOCOLS_GUIDE.md` | New external agent protocols supported |
| `docs/frameworks/CLOUD_AGENT.md` | Cloud agent additions (codex-cloud, devin, jules) or API changes |
| `docs/architecture/AUTHZ_GUIDE.md` | New route classifications, policy changes |
| `docs/security/GUARDRAILS.md` | New guardrails registered, priority/order changes |
| `docs/security/COMPLIANCE.md` | Audit log / retention / no-log policy changes |
| `docs/frameworks/SKILLS.md` | Skill framework / registry / built-in skill changes |
| `docs/frameworks/MEMORY.md` | Memory pipeline / extraction / injection / Qdrant changes |
| `docs/frameworks/EVALS.md` | Evaluation framework changes, new evaluators |
| `docs/frameworks/WEBHOOKS.md` | New webhook events, payload schema changes |
| `docs/routing/REASONING_REPLAY.md` | Reasoning capture/replay pipeline changes |
| `docs/routing/AUTO-COMBO.md` | Routing changes, new strategies, scoring weight changes |
| `docs/architecture/RESILIENCE_GUIDE.md` | Circuit breaker / cooldown / lockout behavior changes |
| `docs/security/STEALTH_GUIDE.md` | TLS / CLI fingerprint changes |
| `docs/ops/TUNNELS_GUIDE.md` | Cloudflare tunnel feature changes |
| `docs/guides/ELECTRON_GUIDE.md` | Electron build / signing / packaging changes |
| `docs/guides/TROUBLESHOOTING.md` | New known issues, resolved problems |
| `docs/ops/RELEASE_CHECKLIST.md` | Process changes |
| `docs/ops/COVERAGE_PLAN.md` | Coverage gate adjustments, target metrics |
| `docs/reference/openapi.yaml` | Already updated in step 7 |
**Only update files where the CHANGELOG entries directly affect the documented content.** Do NOT update files just to bump a version number — only when the documented behavior, features, or architecture has actually changed.
---
## Phase 5: Verify
### 12. Run lint check
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute
npm run lint
```
### 13. Run tests
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute
npm test
```
### 14. Verify version sync across all files
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute
VERSION=$(node -p "require('./package.json').version")
echo "Expected version: $VERSION"
echo ""
echo "--- package.json ---"
grep '"version"' package.json | head -1
echo "--- open-sse/package.json ---"
grep '"version"' open-sse/package.json | head -1
echo "--- electron/package.json ---"
[ -f electron/package.json ] && grep '"version"' electron/package.json | head -1
echo "--- docs/reference/openapi.yaml ---"
grep " version:" docs/reference/openapi.yaml | head -1
echo "--- llm.txt ---"
grep "Current version:" llm.txt
echo "--- CHANGELOG.md (first versioned entry) ---"
grep "^## \[" CHANGELOG.md | head -2
```
### 15. 🛑 STOP — Present Summary to User
**STOP** and present a summary to the user including:
- Old version → New version
- CHANGELOG entries generated
- Files modified
- Test results
- Any documentation updates made
**Wait for the user to confirm before committing.**
---
## Phase 6: Commit (only after user approval)
### 16. Stage and commit
// turbo-all
```bash
cd /home/diegosouzapw/dev/proxys/OmniRoute
git add -A
VERSION=$(node -p "require('./package.json').version")
git commit -m "chore(release): bump to v$VERSION — changelog, docs, version sync"
```
---
## Notes
- This workflow does **NOT** create tags, releases, or deploy. Use `/generate-release` for the full release cycle after this.
- This workflow does **NOT** update `docs/i18n/` translations. Translation updates are handled manually or via release tooling — the `/update-i18n` command does not currently exist as a Claude Code slash command.
- The CHANGELOG generation is based on git commits since the last tag. If there are no new commits, the workflow should inform the user and stop.
- Always verify the generated CHANGELOG entries make sense — raw commit messages may need rewriting for clarity.
- If the version was already bumped (e.g. you're on a `release/vX.Y.Z` branch), skip the `npm version` step and use the existing version.
## Version Touchpoints Checklist
| File | Field/Pattern |
| ----------------------------- | ----------------------------------------------------------- |
| `package.json` | `"version": "X.Y.Z"` |
| `open-sse/package.json` | `"version": "X.Y.Z"` |
| `electron/package.json` | `"version": "X.Y.Z"` |
| `docs/reference/openapi.yaml` | `version: X.Y.Z` |
| `llm.txt` | `**Current version:** X.Y.Z` and `## Key Features (vX.Y.Z)` |
| `CHANGELOG.md` | `## [X.Y.Z] — YYYY-MM-DD` |

View File

@@ -9,7 +9,6 @@
# Dependencies and build output
node_modules
.next
.build
out
build
dist
@@ -42,18 +41,20 @@ blob-report
# Issue #2348: The Dashboard Docs viewer reads markdown from `/app/docs` at
# runtime. The previous `docs/*` block hid every file except openapi.yaml,
# so the in-product help screen failed with ENOENT for every page.
# We now keep the English markdown tree plus the docs assets imported by MDX
# during `next build`, while still dropping the bulky translated docs and
# extra raster diagram sources that account for most of the docs footprint
# We now keep the English markdown tree but drop the bulky assets
# (translations, screenshots, raster diagrams) that account for ~45 MB
# of the ~50 MB docs directory. The Docs viewer reads the default-locale
# (English) sources at runtime, so translations are not required in the
# container image.
docs/i18n/**
docs/screenshots/**
docs/diagrams/exported/**
docs/diagrams/**/*.png
docs/diagrams/**/*.jpg
docs/diagrams/**/*.jpeg
docs/diagrams/**/*.gif
docs/diagrams/**/*.webp
docs/diagrams/**/*.svg
# Note: `*.md` matches the root only (Go filepath.Match does not cross /),
# so nested docs/**/*.md is implicitly kept without a re-include rule.
*.md

View File

@@ -6,6 +6,7 @@
# │ Reference: docs/ENVIRONMENT.md for full details and usage scenarios. │
# └─────────────────────────────────────────────────────────────────────────────┘
# ═══════════════════════════════════════════════════════════════════════════════
# 1. REQUIRED SECRETS — Must be set before first run!
# ═══════════════════════════════════════════════════════════════════════════════
@@ -36,8 +37,6 @@ INITIAL_PASSWORD=CHANGEME
# Base directory for all persistent data (SQLite DB, logs, backups).
# Used by: src/lib/db/core.ts — resolves the SQLite database file path.
# Default: ~/.omniroute/ | Override for Docker or custom installations.
# Hint: When running in Docker, consider mounting a host directory here for data persistence across container restarts
# also if you want to share the same database as "npm run dev" use "./data"
# DATA_DIR=/var/lib/omniroute
# Encryption key for SQLite database encryption at rest.
@@ -61,6 +60,7 @@ DISABLE_SQLITE_AUTO_BACKUP=false
# Default: redis://localhost:6379 (or redis://redis:6379 in Docker)
REDIS_URL=redis://localhost:6379
# ═══════════════════════════════════════════════════════════════════════════════
# 3. NETWORK & PORTS
# ═══════════════════════════════════════════════════════════════════════════════
@@ -77,66 +77,10 @@ PORT=20128
# API_HOST=0.0.0.0
# DASHBOARD_PORT=20128
# Port for the real-time WebSocket live monitoring server.
# Used by: src/server/ws/liveServer.ts, src/app/api/v1/ws/route.ts
# Default: 20129
# LIVE_WS_PORT=20129
# Bind address for the live WebSocket server.
# Default: 127.0.0.1 (loopback only). Set to 0.0.0.0 to expose on LAN —
# remember to also configure LIVE_WS_ALLOWED_ORIGINS when doing so.
# LIVE_WS_HOST=127.0.0.1
# Comma-separated extra origins allowed to open a live WebSocket. The
# loopback dashboard origins are already permitted by default; use this
# var when fronting the server with a domain (e.g. https://omni.local).
# LIVE_WS_ALLOWED_ORIGINS=https://omni.local,https://dashboard.example.com
# Disable the standalone live WebSocket helper used by scripts/start-ws-server.mjs.
# Used by: scripts/start-ws-server.mjs (CI/embedded harness toggle).
# OMNIROUTE_DISABLE_LIVE_WS=0
# Enable the real-time dashboard WebSocket server.
# Used by: src/server/ws/liveServer.ts, scripts/start-ws-server.mjs
# Default: ON. Set to 0 or false to disable startup of the live WS server.
# Combine with LIVE_WS_HOST / LIVE_WS_ALLOWED_ORIGINS above when exposing
# beyond loopback.
# OMNIROUTE_ENABLE_LIVE_WS=1
# Per-(token,IP) relay rate limit, requests/minute. In-memory, per instance.
# 0 or negative disables the IP-dimension gate (per-token DB limit still applies).
# Default: 30
# Used by: src/app/api/v1/relay/chat/completions/route.ts
# RELAY_IP_PER_MINUTE=30
# Use Turbopack in local dev. Next 16.2.4 can fail to compile next/font/google
# through the custom dev runner without this on Windows.
OMNIROUTE_USE_TURBOPACK=1
# Skip the SQLite integrity health check on startup (faster boot on large DBs).
# Used by: src/lib/db/core.ts, src/lib/db/healthCheck.ts. Set to 1 to skip.
# OMNIROUTE_SKIP_DB_HEALTHCHECK=1
# Interval (ms) for the background credential health check scheduler.
# Default: 300000 (5 minutes). Minimum: 10000 (10 seconds).
# Used by: open-sse/config/constants.ts, src/lib/credentialHealth/scheduler.ts
# CREDENTIAL_HEALTH_CHECK_INTERVAL=300000
# TTL (ms) for cached credential health status.
# Default: 300000 (5 minutes).
# Used by: open-sse/config/constants.ts, src/lib/credentialHealth/cache.ts
# CREDENTIAL_HEALTH_CACHE_TTL=300000
# Set to 1 or true to disable background periodic testing of provider connections.
# Default: false
# Used by: src/lib/credentialHealth/scheduler.ts
# OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK=false
# Set to "true" to emit `[ProxyFetch]` debug logs from the Vercel relay path
# in open-sse/utils/proxyFetch.ts. Off by default to avoid leaking routing
# hints in production logs.
# OMNIROUTE_PROXY_FETCH_DEBUG=true
# Docker production port mappings (docker-compose.prod.yml only).
# These set the HOST-side published ports. Container ports use PORT/API_PORT.
# PROD_DASHBOARD_PORT=20130
@@ -157,11 +101,6 @@ OMNIROUTE_USE_TURBOPACK=1
# Values: production | development | Default: production
NODE_ENV=production
# Container runtime — controls startup script behavior (permissions, advice).
# Values: docker | podman | Default: docker
# Set to "podman" when running under rootless Podman so the entrypoint
# gives the correct fix instructions (podman unshare chown vs sudo chown).
CONTAINER_HOST=docker
# ═══════════════════════════════════════════════════════════════════════════════
# 4. SECURITY & AUTHENTICATION
@@ -200,14 +139,6 @@ ALLOW_API_KEY_REVEAL=false
# Generate: openssl rand -base64 32
# OMNIROUTE_WS_BRIDGE_SECRET=
# Per-process secret that proves the trusted peer-IP stamp came from OmniRoute's
# own HTTP server (scripts/dev/peer-stamp.mjs). The custom server stamps the real
# TCP peer IP as `<token>|<ip>`; the authz middleware trusts the locality only
# when the token matches. Used by: src/server/authz/policies/management.ts.
# Auto-generated per boot — leave UNSET in normal use. Only set it to pin a fixed
# value across processes (e.g. a multi-process setup that must share the stamp).
# OMNIROUTE_PEER_STAMP_TOKEN=
# Comma-separated API key IDs that skip request logging (GDPR/compliance).
# Used by: src/lib/compliance/index.ts — suppresses logs for specific keys.
# NO_LOG_API_KEY_IDS=key_abc123,key_def456
@@ -241,6 +172,7 @@ ALLOW_API_KEY_REVEAL=false
# When unset, OmniRoute uses the per-feature defaults. Set to "false"/"0" to disable.
# OUTBOUND_SSRF_GUARD_ENABLED=true
# ═══════════════════════════════════════════════════════════════════════════════
# 5. INPUT SANITIZATION & PII PROTECTION (FASE-01)
# ═══════════════════════════════════════════════════════════════════════════════
@@ -259,20 +191,13 @@ ALLOW_API_KEY_REVEAL=false
# Used by: src/middleware/promptInjectionGuard.ts — extends injection guard.
# PII_REDACTION_ENABLED=false
# Minimum streaming window size for PII detection (bytes). Default: 200.
# Used by: src/lib/streamingPiiTransform.ts.
# PII_WINDOW_SIZE=200
# Test bypass: allow setting PII_WINDOW_SIZE below minimum. Default: false.
# Used by: src/lib/streamingPiiTransform.ts.
# PII_TEST_BYPASS_MIN_WINDOW=false
# ── Response-Side: PII Sanitizer ──
# Scans LLM responses for leaked PII before returning to the client.
# Used by: src/lib/piiSanitizer.ts
# PII_RESPONSE_SANITIZATION=false
# PII_RESPONSE_SANITIZATION_MODE=redact # redact = mask PII | warn = log only | block = drop response
# ═══════════════════════════════════════════════════════════════════════════════
# 6. TOOL & ROUTING POLICIES
# ═══════════════════════════════════════════════════════════════════════════════
@@ -292,11 +217,6 @@ ALLOW_API_KEY_REVEAL=false
# Default: 5000 | Minimum: 1000
# OMNIROUTE_PAYLOAD_RULES_RELOAD_MS=5000
# Prefer Claude Code OAuth for unprefixed Claude-family model IDs such as
# claude-sonnet-4-6 or newly released IDs like claude-fable-5.
# Used by: open-sse/services/model.ts. Explicit provider prefixes still win.
# Default: false
# OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS=false
# ═══════════════════════════════════════════════════════════════════════════════
# 7. URLS & CLOUD SYNC
@@ -368,8 +288,7 @@ NEXT_PUBLIC_CLOUD_URL=
#OMNIROUTE_CROF_USAGE_URL=https://crof.ai/usage_api/
#OMNIROUTE_GEMINI_CLI_USAGE_URL=https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist
#OMNIROUTE_CODEWHISPERER_BASE_URL=https://codewhisperer.us-east-1.amazonaws.com
#OMNIROUTE_OPENCODE_QUOTA_URL=https://opencode.ai/zen/go/v1/quota
#OMNIROUTE_OPENCODE_GO_QUOTA_URL=https://api.z.ai/api/monitor/usage/quota/limit
# ═══════════════════════════════════════════════════════════════════════════════
# 8. OUTBOUND PROXY (Upstream Provider Calls)
@@ -393,10 +312,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# Used by: open-sse/executors — replaces Node.js default TLS fingerprint.
# ENABLE_TLS_FINGERPRINT=true
# Allow the Claude Turnstile Playwright browser context to ignore HTTPS certificate errors.
# Only enable for local debugging or trusted MITM/corporate proxy environments.
# Used by: open-sse/services/claudeTurnstileSolver.ts
# OMNIROUTE_TURNSTILE_IGNORE_TLS_ERRORS=false
# ═══════════════════════════════════════════════════════════════════════════════
# 9. CLI TOOL INTEGRATION
@@ -427,11 +342,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# CLI_QODER_BIN=qoder
# CLI_QWEN_BIN=qwen
# Override the Hermes Agent home directory (where OmniRoute reads/writes the
# Hermes CLI config). Matches the env var the Hermes PowerShell installer sets
# on Windows (%LOCALAPPDATA%\hermes); defaults to ~/.hermes when unset.
# Used by: src/lib/cli-helper/config-generator/hermesHome.ts
# HERMES_HOME=~/.hermes
# ═══════════════════════════════════════════════════════════════════════════════
# 10. INTERNAL AGENT & MCP INTEGRATIONS
@@ -482,17 +392,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# Default: 70
PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70
# Gap (ms) between consecutive OAuth quota fetches in a bulk provider-limits sync.
# OAuth providers are fetched one at a time with this spacing so a single host
# never bursts simultaneous usage/refresh requests to the same upstream. Set to 0
# to opt out (restores fully concurrent fetches). Default: 1500
PROVIDER_LIMITS_SYNC_SPACING_MS=1500
# Delay (ms) before refreshing provider limits after a real usage event (e.g. a
# completed request). Gives the upstream quota API time to register the consumption
# before the dashboard polls. Default: 5000
#PROVIDER_LIMITS_POST_USAGE_REFRESH_DELAY_MS=5000
# Disable all background services (sync, pricing, model refresh).
# Used by: src/instrumentation-node.ts, src/lib/initCloudSync.ts
# Useful for: CI builds, test environments, or resource-constrained containers.
@@ -516,13 +415,6 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
#OMNIROUTE_SPEND_FLUSH_INTERVAL_MS=60000
#OMNIROUTE_SPEND_MAX_BUFFER_SIZE=1000
# Batch request processor retry, backoff, and concurrency settings.
# Used by: open-sse/services/batchProcessor.ts. Defaults shown.
#BATCH_RETRY_DURATION_MS=86400000
#BATCH_BACKOFF_BASE_MS=5000
#BATCH_BACKOFF_MAX_MS=3600000
#BATCH_MAX_CONCURRENT=1
# Config hot-reload polling interval (ms). Default: 5000.
# Used by: src/lib/config/hotReload.ts. Lower than 1000ms is rejected.
#OMNIROUTE_CONFIG_HOT_RELOAD_MS=5000
@@ -539,10 +431,6 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
# Used by: scripts/postinstall.mjs.
#OMNIROUTE_SKIP_POSTINSTALL=0
# Skip the DB healthcheck entirely on startup (useful for short-lived tasks / tests).
# Used by: src/lib/db/core.ts, src/lib/db/healthCheck.ts. Set to 1 to disable. Default: 0.
#OMNIROUTE_SKIP_DB_HEALTHCHECK=0
# Force a DB healthcheck regardless of cadence. Default: 0.
# Used by: src/lib/db/core.ts::shouldRunDbHealthCheck().
#OMNIROUTE_FORCE_DB_HEALTHCHECK=0
@@ -568,11 +456,6 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
# Default: empty (use upstream-reported credits).
#ANTIGRAVITY_CREDITS=
# Override the path to the Antigravity CLI (agy) token file read by the
# "auto-detect local login" import. Used by:
# src/app/api/providers/agy-auth/apply-local/route.ts — for non-standard installs.
# Default: ~/.gemini/antigravity-cli/antigravity-oauth-token
#AGY_TOKEN_FILE=
# ═══════════════════════════════════════════════════════════════════════════════
# 11. OAUTH PROVIDER CREDENTIALS
@@ -590,23 +473,6 @@ CLAUDE_OAUTH_CLIENT_ID=9d1c250a-e61b-44d9-88ed-5944d1962f5e
# ── Codex / OpenAI ──
CODEX_OAUTH_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann
# Milliseconds to wait between consecutive Codex token refreshes.
# Used by: open-sse/services/refreshSerializer.ts. Default: 0 (no spacing).
# CODEX_REFRESH_SPACING_MS=0
# ── Trae (ByteDance) ──
# Trae stream idle timeout (ms). Default: 300000 (5 min).
# Used by: open-sse/executors/trae.ts.
# TRAE_STREAM_TIMEOUT_MS=300000
# Trae OAuth token override. Used by: open-sse/executors/trae.ts.
# TRAE_TOKEN=
# ── The Old LLM (theoldllm) ──
# Playwright navigation timeout (ms) for the browser-backed token capture.
# Used by: open-sse/executors/theoldllm.ts. Default: 30000 (30s).
# THEOLDLLM_NAV_TIMEOUT_MS=30000
# ── Gemini / Gemini CLI / Antigravity / Windsurf (all Google-based) ──
# These providers ship public OAuth client_id/secret values (or Firebase Web
# keys) embedded in their public CLIs/binaries. Defaults are baked into the
@@ -725,6 +591,7 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
# CLI_USER_ID= # legacy alias for OMNIROUTE_USER_ID
# SERVER_URL= # legacy alias for OMNIROUTE_SERVER
# ═══════════════════════════════════════════════════════════════════════════════
# 12. PROVIDER USER-AGENT OVERRIDES
# ═══════════════════════════════════════════════════════════════════════════════
@@ -733,32 +600,20 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
# Used by: open-sse/executors/base.ts — buildHeaders() dynamic lookup.
# Update these when providers release new CLI versions to avoid blocks.
CLAUDE_USER_AGENT="claude-cli/2.1.158 (external, cli)"
# Disable the deterministic tool-name cloak applied on both Anthropic-bound paths
# (executors/base.ts native OAuth + executors/cliproxyapi.ts CLIProxyAPI) —
# third-party-harness tool names are aliased to
# Claude Code canonical or PascalCase forms so Anthropic does not refuse the
# stream with a misleading 400 out-of-extra-usage placeholder. Set to true to
# forward the original names verbatim (debugging only).
# CLAUDE_DISABLE_TOOL_NAME_CLOAK=false
CODEX_USER_AGENT="codex-cli/0.132.0 (Windows 10.0.26200; x64)"
CLAUDE_USER_AGENT="claude-cli/2.1.143 (external, cli)"
CODEX_USER_AGENT="codex-cli/0.131.0 (Windows 10.0.26200; x64)"
GITHUB_USER_AGENT="GitHubCopilotChat/0.45.1"
ANTIGRAVITY_USER_AGENT="antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.0"
ANTIGRAVITY_USER_AGENT="antigravity/1.23.2 darwin/arm64"
KIRO_USER_AGENT="AWS-SDK-JS/3.0.0 kiro-ide/1.0.0"
# Optional override for the Kiro social device-code OAuth clientId. Kiro's
# device endpoint accepts any non-empty string and behaves like a User-Agent
# rather than a secret. Only override if AWS ever starts enforcing this field.
# Used by: src/lib/oauth/constants/oauth.ts (KIRO_CONFIG.socialClientId).
# KIRO_OAUTH_CLIENT_ID=kiro-cli
QODER_USER_AGENT="Qoder-Cli"
QWEN_USER_AGENT="QwenCode/0.15.11 (linux; x64)"
QWEN_USER_AGENT="QwenCode/0.15.9 (linux; x64)"
CURSOR_USER_AGENT="Cursor/3.4"
GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0"
# Override Codex client version sent in headers independently of the
# CODEX_USER_AGENT string. Used by: open-sse/config/codexClient.ts.
# CODEX_CLIENT_VERSION=0.132.0
# CODEX_CLIENT_VERSION=0.131.0
# ═══════════════════════════════════════════════════════════════════════════════
# 13. CLI FINGERPRINT COMPATIBILITY (Anti-Detection)
@@ -788,6 +643,7 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0"
#KIMI_CLI_VERSION=1.36.0
#KIMI_CODING_DEVICE_ID=
# ═══════════════════════════════════════════════════════════════════════════════
# 14. API KEY PROVIDERS
# ═══════════════════════════════════════════════════════════════════════════════
@@ -810,6 +666,7 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0"
# OpenAI/Mistral/Together/Fireworks/NVIDIA configured via Dashboard → Providers
# also work for embeddings.
# ═══════════════════════════════════════════════════════════════════════════════
# 15. TIMEOUT SETTINGS
# ═══════════════════════════════════════════════════════════════════════════════
@@ -849,29 +706,6 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0"
# OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS=60000
# OMNIROUTE_CLAUDE_TLS_GRACE_MS=10000
# ── Perplexity TLS sidecar (Firefox-fingerprinted client) ──
# Used by: open-sse/services/perplexityTlsClient.ts — wire-level timeout for
# the bogdanfinn/tls-client koffi binding and the JS-side grace window
# layered on top of it when the native library is wedged.
# OMNIROUTE_PPLX_TLS_TIMEOUT_MS=30000
# OMNIROUTE_PPLX_TLS_GRACE_MS=10000
# ── Grok web TLS sidecar (Chrome-fingerprinted client) ──
# Used by: open-sse/services/grokTlsClient.ts — wire-level timeout for the
# bogdanfinn/tls-client koffi binding and the JS-side grace window layered on
# top of it when the native library is wedged.
# OMNIROUTE_GROK_TLS_TIMEOUT_MS=60000
# OMNIROUTE_GROK_TLS_GRACE_MS=10000
# ── Browser-backed web-cookie chat (Playwright shared pool) ──
# Used by: open-sse/services/browserPool.ts + browserBackedChat.ts. The shared
# browser pool warms a headless context for web-cookie providers (e.g. claude-web)
# that need a real browser to satisfy anti-bot challenges. Set OMNIROUTE_BROWSER_POOL=off
# to fully disable the pool; set WEB_COOKIE_USE_BROWSER=1 to opt a web-cookie chat
# request into the browser-backed path.
# OMNIROUTE_BROWSER_POOL=on
# WEB_COOKIE_USE_BROWSER=0
# ── Circuit breaker thresholds and reset windows ──
# Used by: open-sse/config/constants.ts → src/lib/resilience/settings.ts.
# Defaults match historical PROVIDER_PROFILES values (post-scaling for
@@ -892,8 +726,8 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0"
# TLS_CLIENT_TIMEOUT_MS=600000 # Inherits from FETCH_TIMEOUT_MS by default
# ── API Bridge (/v1 proxy server) ──
# API_BRIDGE_PROXY_TIMEOUT_MS=600000 # Proxy hop timeout (default: 10min)
# API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS=600000 # Overall server request timeout (default: 10min)
# API_BRIDGE_PROXY_TIMEOUT_MS=30000 # Proxy hop timeout (default: 30s)
# API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS=300000 # Overall server request timeout
# API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS=60000 # Time to send response headers
# API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS=5000 # Keep-alive idle timeout
# API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS=0 # Raw socket timeout (0 = disabled)
@@ -904,6 +738,7 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0"
# Default: 30000 (30 seconds)
# SHUTDOWN_TIMEOUT_MS=30000
# ═══════════════════════════════════════════════════════════════════════════════
# 16. LOGGING
# ═══════════════════════════════════════════════════════════════════════════════
@@ -977,30 +812,15 @@ APP_LOG_TO_FILE=true
# Default: 100000
# PROXY_LOGS_TABLE_MAX_ROWS=100000
# ═══════════════════════════════════════════════════════════════════════════════
# 17. MEMORY OPTIMIZATION (Low-RAM / Docker)
# ═══════════════════════════════════════════════════════════════════════════════
# Node.js V8 heap limit in MB, passed to the server via --max-old-space-size.
# Used by the standalone launcher (Docker CMD) and `omniroute serve`.
# Clamped to [64, 16384]. Default: 512 (safe for a 1 GB / 1 core VPS). Size it to
# roughly half the box's RAM, leaving the rest for native memory (better-sqlite3,
# buffers — ~300 MB) and the OS:
# 1 GB RAM → 512 (default)
# 2 GB RAM → 1024
# 4 GB RAM → 2048
# In a memory-capped container, set this EXPLICITLY: Node reads the HOST's RAM,
# not the cgroup limit, so leaving it to a RAM heuristic can oversize the heap and
# get the container OOM-killed. (#2939)
# OMNIROUTE_MEMORY_MB=512
# Heap-pressure shed threshold (MB) — chatCore returns 503 when V8 heapUsed exceeds
# it, to avoid hard OOM under concurrent large-context load.
# LEAVE UNSET: it now AUTO-CALIBRATES to 85% of the actual V8 heap ceiling, so it
# tracks OMNIROUTE_MEMORY_MB above and never sits below the ~260 MB runtime baseline
# (a fixed 200 here used to reject every request). Used by: open-sse/utils/heapPressure.ts.
# Override only to hand-tune for a known workload.
# HEAP_PRESSURE_THRESHOLD_MB=
# Node.js V8 heap limit in MB.
# Used by: Docker entrypoint — sets --max-old-space-size.
# Default: 256 (Docker) | system default (npm)
# OMNIROUTE_MEMORY_MB=256
# ── CLI helpers (bin/cli/) ──
# Override UI language for CLI output. Accepts BCP-47 locale (e.g. en, pt-BR).
@@ -1026,10 +846,6 @@ APP_LOG_TO_FILE=true
# Default: ~/.omniroute/plugins/ Override in dev/CI to point at a local plugin tree.
# OMNIROUTE_PLUGIN_PATH=
# Allow plugins to request the 'exec' permission (spawn child processes from the
# plugin worker sandbox). Disabled by default; set to 1 to enable (local operator only).
# OMNIROUTE_PLUGINS_ALLOW_EXEC=0
# ── Prompt cache (system prompt deduplication) ──
# Used by: open-sse/services — caches identical system prompts across requests.
# PROMPT_CACHE_MAX_SIZE=50 # Max cached entries (default: 50)
@@ -1056,6 +872,7 @@ APP_LOG_TO_FILE=true
# Used by: open-sse/utils/usageTracking.ts
# USAGE_TOKEN_BUFFER=100
# ═══════════════════════════════════════════════════════════════════════════════
# 18. PRICING SYNC
# ═══════════════════════════════════════════════════════════════════════════════
@@ -1071,6 +888,7 @@ APP_LOG_TO_FILE=true
# Comma-separated data sources. Default: litellm
# PRICING_SYNC_SOURCES=litellm
# ═══════════════════════════════════════════════════════════════════════════════
# 19. MODEL SYNC (Dev)
# ═══════════════════════════════════════════════════════════════════════════════
@@ -1079,6 +897,7 @@ APP_LOG_TO_FILE=true
# Default: 86400 (24 hours)
# MODELS_DEV_SYNC_INTERVAL=86400
# ═══════════════════════════════════════════════════════════════════════════════
# 20. PROVIDER-SPECIFIC SETTINGS
# ═══════════════════════════════════════════════════════════════════════════════
@@ -1089,14 +908,6 @@ APP_LOG_TO_FILE=true
# Default: 86400000 (24 hours)
# OPENROUTER_CATALOG_TTL_MS=86400000
# ── Model catalog response shape ──
# Include display-friendly name fields in /v1/models responses.
# Disable for clients that expect model IDs only.
# Defined in: src/shared/constants/featureFlagDefinitions.ts
# Used by: src/app/api/v1/models/catalog.ts
# Default: true
# MODEL_CATALOG_INCLUDE_NAMES=true
# ── NanoBanana (Image Generation) ──
# Polling config for async image generation jobs.
# Used by: open-sse/handlers/imageGeneration.ts
@@ -1140,21 +951,6 @@ APP_LOG_TO_FILE=true
# Used by: src/shared/utils/featureFlags.ts
# ENABLE_CC_COMPATIBLE_PROVIDER=false
# ── 9router embedded service ──
# Override the host/port where the embedded 9router instance listens.
# Rarely needed — defaults match the bootstrap config (127.0.0.1:20130).
# Used by: open-sse/executors/ninerouter.ts
# NINEROUTER_HOST=127.0.0.1
# NINEROUTER_PORT=20130
# ── Embedded service WebSocket proxy ──
# Standalone WebSocket proxy that tunnels WS connections to embedded services.
# Binds to loopback by default. Only change EMBED_WS_PROXY_HOST if you know
# what you are doing — exposing this to non-loopback bypasses local-only policy.
# Used by: src/lib/services/embedWsProxy.ts
# EMBED_WS_PROXY_HOST=127.0.0.1
# EMBED_WS_PROXY_PORT=20131
# ── CLIProxyAPI bridge (legacy) ──
# Connection settings for external CLIProxyAPI instances.
# Used by: open-sse/executors/cliproxyapi.ts
@@ -1167,6 +963,7 @@ APP_LOG_TO_FILE=true
# Used by: open-sse/config/providerRegistry.ts — allows Docker service names.
# LOCAL_HOSTNAMES=omlx,mlx-audio
# ═══════════════════════════════════════════════════════════════════════════════
# 21. PROXY HEALTH
# ═══════════════════════════════════════════════════════════════════════════════
@@ -1188,29 +985,11 @@ APP_LOG_TO_FILE=true
# Accepted values: true|1|on (force on), false|0|off (force off), unset (use Dashboard).
# RATE_LIMIT_AUTO_ENABLE=
# Provider cooldown tracking: minimum time (ms) before a failed provider/connection
# can be retried. Prevents subsequent requests from re-walking failing providers.
# Scaled exponentially: minCooldown * 2^(failures-1), capped at maxRetryCooldownMs.
# Used by: open-sse/services/providerCooldownTracker.ts
# PROVIDER_COOLDOWN_MIN_MS=5000
# Provider cooldown tracking: maximum time (ms) before a failed provider/connection
# is retried regardless. Hard cap to prevent providers from being skipped indefinitely.
# Used by: open-sse/services/providerCooldownTracker.ts
# PROVIDER_COOLDOWN_MAX_MS=300000
# Enable/disable global provider cooldown tracking. Opt-in: this global
# cross-request cooldown overlaps the existing Connection Cooldown / Provider
# Circuit Breaker layers, so it is OFF by default. When disabled, only the
# existing per-request/per-connection cooldown state is used (previous behavior).
# Used by: open-sse/services/providerCooldownTracker.ts
# Accepted values: true|1|on (enable). Unset or anything else = disabled (default).
# PROVIDER_COOLDOWN_ENABLED=true
# Stagger interval (ms) between provider token healthchecks at startup.
# Used by: src/lib/tokenHealthCheck.ts. Default: 3000.
# HEALTHCHECK_STAGGER_MS=3000
# ═══════════════════════════════════════════════════════════════════════════════
# 22. DEBUGGING
# ═══════════════════════════════════════════════════════════════════════════════
@@ -1220,13 +999,6 @@ APP_LOG_TO_FILE=true
# CURSOR_STREAM_DEBUG is kept as a backward-compatible alias.
# Used by: open-sse/executors/cursor.ts
# CURSOR_DEBUG=1
# Enable verbose trace logging for OmniRoute internals.
# Used by: open-sse/handlers/chatCore.ts.
# OMNIROUTE_TRACE=true
# Standard DEBUG flag (same effect as OMNIROUTE_TRACE).
# DEBUG=true
# CURSOR_STREAM_DEBUG=1
# When CURSOR_DEBUG=1, also append raw decoded chunks to this file path.
@@ -1236,16 +1008,6 @@ APP_LOG_TO_FILE=true
# Used by: open-sse/executors/cursor.ts.
# CURSOR_STREAM_TIMEOUT_MS=300000
# Cursor tool-commit directive toggle. Default-on: when a request declares
# tools, a directive is prepended so composer-2.5 reliably issues tool calls
# instead of narrating intent. Set to 0 to disable.
# Used by: open-sse/executors/cursor.ts.
# CURSOR_TOOL_DIRECTIVE=1
# Per-image fetch timeout (ms) for remote image_url vision input. Default: 15000.
# Used by: open-sse/utils/cursorImages.ts.
# CURSOR_IMAGE_FETCH_TIMEOUT_MS=15000
# Cursor state DB path override (for cursor version detection).
# Used by: open-sse/utils/cursorVersionDetector.ts. Default: probed automatically.
# CURSOR_STATE_DB_PATH=
@@ -1270,6 +1032,7 @@ APP_LOG_TO_FILE=true
# Enable E2E test mode — relaxes auth and enables test harness hooks.
# NEXT_PUBLIC_OMNIROUTE_E2E_MODE=true
# ═══════════════════════════════════════════════════════════════════════════════
# 23. GITHUB INTEGRATION (Issue Reporting)
# ═══════════════════════════════════════════════════════════════════════════════
@@ -1287,6 +1050,7 @@ APP_LOG_TO_FILE=true
# GITHUB_ISSUES_TOKEN when unset.
# GITHUB_TOKEN=
# ═══════════════════════════════════════════════════════════════════════════════
# 24. PROVIDER QUOTAS, TUNNELS & SANDBOXED SKILLS
# ═══════════════════════════════════════════════════════════════════════════════
@@ -1319,13 +1083,6 @@ APP_LOG_TO_FILE=true
# Used by: src/app/api/providers/command-code/auth/shared.ts.
# COMMAND_CODE_CALLBACK_PORT=
# ── Command Code CLI version header ──
# Value sent as the x-command-code-version header to the Command Code upstream.
# Overrides the built-in default; bump if the upstream requires a newer CLI version.
# Used by: open-sse/executors/commandCode.ts
# Default: 0.33.2
# COMMAND_CODE_VERSION=0.33.2
# ── MITM debug proxy (development only) ──
# Used by: src/mitm/server.cjs — captures upstream traffic for inspection.
# MITM_LOCAL_PORT=443
@@ -1339,35 +1096,6 @@ APP_LOG_TO_FILE=true
# ONEPROXY_MAX_PROXIES=500
# ONEPROXY_MIN_QUALITY_THRESHOLD=50
# ── Free Proxy Pool (1proxy source) ──
# Used by: src/lib/freeProxyProviders/oneproxy.ts
# Set FREE_PROXY_1PROXY_ENABLED=false to disable this source.
# FREE_PROXY_1PROXY_ENABLED=true
# FREE_PROXY_1PROXY_API_URL=https://1proxy-api.aitradepulse.com/api/v1/proxies/advanced
# FREE_PROXY_1PROXY_MAX=500
# FREE_PROXY_1PROXY_MIN_QUALITY=50
# ── Free Proxy Pool (Proxifly source) ──
# Used by: src/lib/freeProxyProviders/proxifly.ts
# Enabled by default; set to false to disable.
# FREE_PROXY_PROXIFLY_ENABLED=true
# FREE_PROXY_PROXIFLY_QUANTITY=100
# FREE_PROXY_PROXIFLY_ANONYMITY=elite
# ── Free Proxy Pool (IPLocate source) ──
# Used by: src/lib/freeProxyProviders/iplocate.ts
# Opt-in only; must set FREE_PROXY_IPLOCATE_ENABLED=true to activate.
# FREE_PROXY_IPLOCATE_ENABLED=false
# FREE_PROXY_IPLOCATE_BASE_URL=https://raw.githubusercontent.com/iplocate/free-proxy-list/main/protocols
# ── Vercel Relay ──
# Used by: src/app/api/settings/proxy/vercel-deploy/route.ts
# Hides the "Deploy Relay" button when set to false.
# NEXT_PUBLIC_VERCEL_RELAY_ENABLED=true
# VERCEL_API_BASE=https://api.vercel.com
# Default project name pre-filled in the Vercel Relay deploy modal.
# NEXT_PUBLIC_VERCEL_RELAY_DEFAULT_PROJECT=omniroute-relay
# ── Tailscale tunnel binaries ──
# Optional explicit paths to tailscale/tailscaled binaries used by the
# dashboard's tunnel manager. Used by: src/lib/tailscaleTunnel.ts.
@@ -1398,6 +1126,7 @@ APP_LOG_TO_FILE=true
# SKILLS_SANDBOX_NETWORK_ENABLED=0
# SKILLS_ALLOWED_SANDBOX_IMAGES=
# ═══════════════════════════════════════════════════════════════════════════════
# 25. TEST & E2E
# ═══════════════════════════════════════════════════════════════════════════════
@@ -1419,12 +1148,6 @@ APP_LOG_TO_FILE=true
# Disable the OAuth token healthcheck loop during tests (default: true).
# OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK=true
# Exclude specific providers from the PROACTIVE token-refresh sweep (comma-separated,
# case-insensitive). Targeted alternative to OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK: keeps
# rotating-cascade providers (Codex/OpenAI share one Auth0 family) on the reactive 401
# path only, while short-TTL providers like Kimi-coding keep being refreshed proactively.
# OMNIROUTE_HEALTHCHECK_SKIP_PROVIDERS=codex,openai
# Silence healthcheck noise in Playwright stdout (default: true).
# OMNIROUTE_HIDE_HEALTHCHECK_LOGS=true
@@ -1450,31 +1173,6 @@ APP_LOG_TO_FILE=true
# Number of parallel translation requests (default 4).
# OMNIROUTE_TRANSLATION_CONCURRENCY=4
# ─── Cloud Sync hardening (v3.8.6) ──────────────────────────────────────────
# Shared secret used to verify the HMAC-SHA256 of the Cloud sync response body
# (the Cloud endpoint must sign each response with the same secret and place
# the hex digest in the X-Cloud-Sig header). When unset, v3.8.6 logs a warning
# but accepts unsigned responses for back-compat. v3.9 will make this required.
# OMNIROUTE_CLOUD_SYNC_SECRET=
#
# Set to "true" to allow the Cloud Sync endpoint to overwrite local OAuth
# tokens (accessToken / refreshToken / providerSpecificData). Default OFF —
# only non-credential metadata is synced. See docs/security/SOCKET_DEV_FINDINGS.md §5.
# OMNIROUTE_CLOUD_SYNC_SECRETS=false
# ─── Zed import legacy compat (v3.8.6) ──────────────────────────────────────
# Set to "true" to fall back to the v3.8.5 one-step "import everything from
# the keychain" behaviour. Default OFF — the new 2-step confirmation flow
# requires `confirmedAccounts` in the request body. See SOCKET_DEV_FINDINGS.md §2.
# OMNIROUTE_ZED_IMPORT_LEGACY_ONE_STEP=false
# ─── Build profile (build-time only) ────────────────────────────────────────
# Set to "minimal" before `npm run build` to physically remove four optional
# privileged modules (MITM cert install, Zed keychain import, Cloud Sync,
# 9router installer) from the standalone bundle. The resulting artifact is
# intended to be published as `omniroute-secure`. See SECURITY.md.
# OMNIROUTE_BUILD_PROFILE=full
# Electron smoke harness (used by scripts/dev/smoke-electron-packaged.mjs).
# ELECTRON_SMOKE_URL=http://127.0.0.1:20128/login
# ELECTRON_SMOKE_TIMEOUT_MS=45000
@@ -1483,51 +1181,3 @@ APP_LOG_TO_FILE=true
# ELECTRON_SMOKE_DATA_DIR=
# ELECTRON_SMOKE_KEEP_DATA=0
# ELECTRON_SMOKE_STREAM_LOGS=0
# Playground Studio
# Default model used by the improve-prompt route (optional; falls back to model in request body).
PLAYGROUND_IMPROVE_PROMPT_DEFAULT_MODEL=
# Maximum number of parallel compare columns in the Compare tab.
PLAYGROUND_COMPARE_MAX_COLUMNS=4
# Memory engine (plan 21)
# MEMORY_EMBEDDING_CACHE_TTL_MS=300000 # default 5 min
# MEMORY_EMBEDDING_CACHE_MAX=1000 # default 1000 entries
# MEMORY_TRANSFORMERS_MODEL=Xenova/all-MiniLM-L6-v2
# MEMORY_STATIC_MODEL=minishlab/potion-base-8M # HF repo id (download once)
# MEMORY_STATIC_CACHE_DIR= # default <DATA_DIR>/embeddings
# MEMORY_VEC_TOP_K=20 # default top-K for vector search
# MEMORY_RRF_K=60 # RRF k constant (sqlite-vec hybrid recipe)
# HF_HUB_ENDPOINT=https://huggingface.co # override Hugging Face Hub base URL for static potion downloads
# AgentBridge + Traffic Inspector (Group A)
# AgentBridge
AGENTBRIDGE_UPSTREAM_CA_CERT=
# Inspector
INSPECTOR_BUFFER_SIZE=1000
INSPECTOR_HTTP_PROXY_PORT=8080
INSPECTOR_HTTP_PROXY_AUTOSTART=false
INSPECTOR_TLS_INTERCEPT=false
INSPECTOR_SYSTEM_PROXY_GUARD_MINUTES=30
INSPECTOR_MAX_BODY_KB=1024
INSPECTOR_MASK_SECRETS=true
INSPECTOR_LLM_HOSTS_EXTRA=
INSPECTOR_INTERNAL_INGEST_TOKEN=
# Quota Sharing (Group B — planos 16+22)
QUOTA_STORE_DRIVER=sqlite # sqlite | redis
# QUOTA_STORE_REDIS_URL= # ex.: redis://localhost:6379 (apenas quando driver=redis)
# QUOTA_SATURATION_THRESHOLD=0.5 # 0..1; >= threshold ativa modo strict (sem empréstimo)
# QUOTA_SOFT_DEPRIORITIZE_FACTOR=0.7 # 0..1; multiplicador do score quando soft policy ativa
# QUOTA_CONSUMPTION_RETENTION_DAYS=14 # GC de buckets quota_consumption.updated_at antigos
# ─── OpenCode config regeneration (scripts/ad-hoc/regen-opencode-config.ts) ───
# Base URL of the OmniRoute instance to query for /v1/models when regenerating
# an opencode.json with accurate limit.context values. Used by:
# scripts/ad-hoc/regen-opencode-config.ts. Default: http://localhost:20128
# OMNIROUTE_URL=
# API key to authenticate against the OmniRoute /v1/models endpoint. Falls back
# to OPENCODE_API_KEY when unset. Used by: scripts/ad-hoc/regen-opencode-config.ts.
# OMNIROUTE_KEY=
# OpenCode-style API key (sk-...) for the regenerated opencode.json. Used by:
# scripts/ad-hoc/regen-opencode-config.ts. Falls back to OMNIROUTE_KEY.
# OPENCODE_API_KEY=

View File

@@ -1,30 +1,23 @@
name: Publish Fork Image to GHCR
name: Build Fork Image (ghcr.io)
on:
push:
branches: [main]
tags:
- "v*"
workflow_dispatch:
permissions:
contents: read
packages: write
env:
IMAGE_NAME: ghcr.io/kang-heewon/omniroute
jobs:
build:
name: Build and Push Fork Image
if: github.repository == 'kang-heewon/OmniRoute'
name: Build and Push to ghcr.io
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
ref: main
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
@@ -36,30 +29,14 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,prefix=sha-
type=ref,event=tag
labels: |
org.opencontainers.image.title=omniroute
org.opencontainers.image.description=Unified AI proxy/router — fork image
org.opencontainers.image.url=https://github.com/kang-heewon/OmniRoute
org.opencontainers.image.source=https://github.com/kang-heewon/OmniRoute
org.opencontainers.image.licenses=MIT
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
target: runner-base
platforms: linux/amd64,linux/arm64
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
tags: |
ghcr.io/gi99lin/omniroute:latest
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -24,12 +24,6 @@ jobs:
lint:
name: Lint
runs-on: ubuntu-latest
env:
# tsx gates below (known-symbols, route-guard-membership) import modules that
# open SQLite on load; provide DB env so a fresh CI DB initializes cleanly.
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-lint-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
@@ -43,64 +37,9 @@ jobs:
- run: npm run check:cycles
- run: npm run check:route-validation:t06
- run: npm run check:any-budget:t11
- run: npm run check:provider-consistency
- run: npm run check:fetch-targets
- run: npm run check:deps
- run: npm run check:file-size
- run: npm run check:error-helper
- run: npm run check:migration-numbering
- run: npm run check:public-creds
- run: npm run check:db-rules
- run: npm run check:known-symbols
- run: npm run check:route-guard-membership
- run: npm run check:test-discovery
- run: npm run check:docs-sync
- run: npm run typecheck:core
# typecheck:noimplicit:core is a forward-looking gate (noImplicitAny).
# Run informationally for now — many pre-existing call sites still need
# explicit annotations; track in a dedicated follow-up.
- run: npm run typecheck:noimplicit:core
continue-on-error: true
quality-gate:
name: Quality Ratchet
runs-on: ubuntu-latest
needs: test-coverage
if: ${{ always() && needs.test-coverage.result == 'success' }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
# Coverage mergeada (coverage-summary.json) p/ o ratchet de cobertura.
- uses: actions/download-artifact@v8
with:
name: coverage-report
path: coverage/
- run: npm run quality:collect
# Catraca: falha se qualquer métrica regredir vs quality-baseline.json (commitado).
# Hoje: contagem de warnings do ESLint. Fase 4 estende com cobertura (lida do
# coverage mergeado). Tamanho de arquivo e duplicação têm gates dedicados.
- name: Ratchet check
run: node scripts/quality/check-quality-ratchet.mjs --summary .artifacts/quality-ratchet.md
# Catraca de duplicação (jscpd@4 sobre src+open-sse). Roda neste job (paralelo)
# para não pesar no caminho crítico do lint.
- name: Duplication ratchet
run: npm run check:duplication
- name: Complexity ratchet
run: npm run check:complexity
- name: Append summary
if: always()
run: cat .artifacts/quality-ratchet.md >> "$GITHUB_STEP_SUMMARY"
- name: Upload ratchet report
if: always()
uses: actions/upload-artifact@v7
with:
name: quality-ratchet
path: .artifacts/quality-ratchet.md
if-no-files-found: warn
docs-sync-strict:
name: Docs Sync (Strict)
@@ -113,21 +52,8 @@ jobs:
cache: npm
- run: npm ci
- run: npm run check:docs-all
# Previously-orphaned contract gates (existed as files, never wired anywhere).
# All exit 0 today: cli-i18n is a hard gate, openapi-coverage is a ratchet
# (floor ~36), openapi-security-tiers is advisory (Hard Rules #15/#17).
- name: CLI i18n consistency
run: npm run check:cli-i18n
- name: OpenAPI route coverage (ratchet)
run: npm run check:openapi-coverage
- name: OpenAPI security-tier consistency (advisory)
run: npm run check:openapi-security-tiers
- name: OpenAPI spec paths resolve to real routes (anti-hallucination)
run: npm run check:openapi-routes
- name: Doc /api refs resolve to real routes (anti-hallucination)
run: npm run check:docs-symbols
- name: i18n translation drift (warn)
run: node scripts/i18n/check-translation-drift.mjs --warn
- name: i18n translation drift (strict)
run: node scripts/i18n/check-translation-drift.mjs
i18n-ui-coverage:
name: i18n UI Coverage
@@ -139,7 +65,7 @@ jobs:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- run: node scripts/i18n/check-ui-keys-coverage.mjs --threshold=65
- run: node scripts/i18n/check-ui-keys-coverage.mjs --threshold=80
i18n-matrix:
name: Build language matrix
@@ -195,9 +121,6 @@ jobs:
run: git fetch --no-tags origin "${GITHUB_BASE_REF}" --depth=1
- name: Validate source changes include tests
run: node scripts/check/check-pr-test-policy.mjs --summary-file .artifacts/pr-test-policy.md
# Anti test-masking: flag net assert removal / new assert.ok(true) in changed tests.
- name: Detect test-masking (weakened assertions)
run: npm run check:test-masking
- name: Publish PR test policy summary
if: always()
run: |
@@ -217,21 +140,6 @@ jobs:
- run: npm ci
- run: npm run check:node-runtime
- run: npm run build
- name: Archive Next.js build for E2E shards
# Use tar so the archive preserves paths relative to CWD (.build/next/...).
# upload-artifact path-stripping is ambiguous when exclude patterns are used;
# an explicit tar avoids the double-nesting issue (.build/next/next/...).
run: |
tar -czf /tmp/e2e-build.tar.gz \
--exclude='.build/next/standalone/node_modules' \
--exclude='.build/next/cache' \
.build/next
- name: Upload Next.js build for E2E shards
uses: actions/upload-artifact@v4
with:
name: e2e-next-build
path: /tmp/e2e-build.tar.gz
retention-days: 1
package-artifact:
name: Package Artifact
@@ -247,11 +155,7 @@ jobs:
cache: npm
- run: npm ci
- run: npm run check:node-runtime
# build:cli runs a clean build into .build/next and assembles dist/
# For release builds prefer: npm run build:release (clean rebuild + HEAD sentinel)
- run: npm run build:cli
- name: Assert dist/server.js exists
run: test -f dist/server.js || (echo "dist/server.js missing — build:cli did not assemble correctly" && exit 1)
- run: npm run check:pack-artifact
electron-package-smoke:
@@ -283,14 +187,14 @@ jobs:
run: xvfb-run -a npm run electron:smoke:packaged
test-unit:
name: Unit Tests (${{ matrix.shard }}/8)
name: Unit Tests (${{ matrix.shard }}/2)
runs-on: ubuntu-latest
timeout-minutes: 15
needs: build
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8]
shard: [1, 2]
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
@@ -303,32 +207,7 @@ jobs:
cache: npm
- run: npm ci
- run: npm run check:node-runtime
- run: node --max-old-space-size=4096 --import tsx --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,compression,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
test-vitest:
name: Vitest (MCP / autoCombo / UI components)
runs-on: ubuntu-latest
timeout-minutes: 15
needs: build
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
# The second test runner (CLAUDE.md: "Both test runners must pass") — was never
# wired into CI until the 2026-06-09 quality audit (Fase 6A.2).
- run: npm run test:vitest
# vitest:ui is RED today (14 fails — UI component drift accumulated while the
# suite never ran in CI). Informational until the Fase 6A triage (2026-06-16+)
# fixes the components/tests; then drop continue-on-error to make it blocking.
- run: npm run test:vitest:ui
continue-on-error: true
- run: node --import tsx --test --test-concurrency=1 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts
node-24-compat:
name: Node 24 Compatibility (${{ matrix.shard }}/2)
@@ -352,7 +231,7 @@ jobs:
- run: npm ci
- run: npm run check:node-runtime
- run: npm run build
- run: node --import tsx --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,compression,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
- run: node --import tsx --test --test-concurrency=1 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts
node-26-compat:
name: Node 26 Compatibility (${{ matrix.shard }}/2)
@@ -376,17 +255,13 @@ jobs:
- run: npm ci
- run: npm run check:node-runtime
- run: npm run build
- run: node --import tsx --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,compression,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
- run: node --import tsx --test --test-concurrency=1 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts
test-coverage-shard:
name: Coverage Shard (${{ matrix.shard }}/8)
test-coverage:
name: Coverage
runs-on: ubuntu-latest
timeout-minutes: 25
timeout-minutes: 30
needs: build
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8]
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
@@ -399,82 +274,8 @@ jobs:
cache: npm
- run: npm ci
- run: npm run check:node-runtime
- name: Run c8 over shard ${{ matrix.shard }}/8
run: |
rm -rf coverage-shard coverage-shard-report
# `--temp-directory` (writable via NODE_V8_COVERAGE) is what the merge
# job reads with `c8 report --temp-directory ...`. Using `--output-dir`
# only produces the final json *report* and leaves the raw v8 files in
# `coverage/tmp`, so uploading `coverage-shard/` was empty. Pin the temp
# dir so the raw coverage files live there and the artifact upload picks
# them up regardless of `--test-force-exit` timing.
npx c8 \
--temp-directory=coverage-shard \
--reports-dir=coverage-shard-report \
--reporter=json \
--exclude=tests/** \
--exclude=**/*.test.* \
node --max-old-space-size=4096 --import tsx --test --test-force-exit --test-concurrency=4 \
--test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,compression,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
- name: Upload raw shard coverage
if: always()
uses: actions/upload-artifact@v7
with:
name: coverage-shard-${{ matrix.shard }}
path: coverage-shard/*.json
if-no-files-found: error
test-coverage:
name: Coverage
runs-on: ubuntu-latest
timeout-minutes: 10
needs: test-coverage-shard
if: ${{ always() && needs.test-coverage-shard.result == 'success' }}
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- name: Download all shard coverage
uses: actions/download-artifact@v8
with:
pattern: coverage-shard-*
path: coverage-shards/
merge-multiple: true
- name: Merge + report + gate
# Merging 8 shards of raw v8 coverage is memory-heavy; the default Node
# heap OOMs (exit 134). Raise it for the c8 merge/report step.
env:
NODE_OPTIONS: --max-old-space-size=6144
run: |
mkdir -p coverage
if [ ! -d coverage-shards ] || ! find coverage-shards -maxdepth 1 -type f -name '*.json' | grep -q .; then
echo "::error::No raw coverage shard data was downloaded."
find . -maxdepth 3 -type f | sort
exit 1
fi
# Gate aligned to the project's local coverage bar: `npm run test:coverage`
# gates at 60/60/60/60, so CI must match it (the previous CI floor of 40
# silently undershot the local bar — a real drift). Real merged coverage is
# ~79/79/82/75, so 60 is a conservative floor with headroom; the Fase-4
# coverage ratchet (quality-baseline.json) layers "must not drop vs baseline"
# on top of this floor.
npx c8 report \
--temp-directory coverage-shards \
--reports-dir coverage \
--reporter=text-summary \
--reporter=html \
--reporter=json-summary \
--reporter=lcov \
--exclude=tests/** \
--exclude=**/*.test.* \
--check-coverage \
--statements 60 --lines 60 --functions 60 --branches 60
- name: Run coverage gate
run: npm run test:coverage
- name: Build coverage summary
if: always()
run: |
@@ -613,18 +414,14 @@ jobs:
}
test-e2e:
name: E2E Tests (${{ matrix.shard }}/9)
name: E2E Tests (${{ matrix.shard }}/6)
runs-on: ubuntu-latest
# Build artifact from the `build` job is downloaded instead of rebuilding
# (~5min saved per shard). 9 shards (up from 6) reduces tests per shard by
# ~33%. Playwright browser is cached across runs (~1.5min saved per shard).
# Heavy shard target: ≤20min (was ~40min). Timeout 45min to cover slow runners.
timeout-minutes: 45
timeout-minutes: 20
needs: build
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9]
shard: [1, 2, 3, 4, 5, 6]
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
@@ -638,23 +435,9 @@ jobs:
cache: npm
- run: npm ci
- run: npm run check:node-runtime
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: playwright-chromium-${{ runner.os }}-
- run: npx playwright install --with-deps chromium
- name: Download Next.js build artifact
uses: actions/download-artifact@v4
with:
name: e2e-next-build
path: /tmp/
- name: Extract Next.js build and restore standalone node_modules
run: |
tar -xzf /tmp/e2e-build.tar.gz
cp -r node_modules .build/next/standalone/node_modules
- run: npx playwright test tests/e2e/*.spec.ts --shard=${{ matrix.shard }}/9
- run: npm run build
- run: npx playwright test tests/e2e/*.spec.ts --shard=${{ matrix.shard }}/6
test-integration:
name: Integration Tests (${{ matrix.shard }}/2)

View File

@@ -0,0 +1,43 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: "https://github.com/anthropics/claude-code.git"
plugins: "code-review@claude-code-plugins"
prompt: "/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}"
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options

View File

@@ -17,82 +17,27 @@ jobs:
name: Deploy OmniRoute to VPS
runs-on: ubuntu-latest
steps:
- name: Check VPS SSH reachability from runner
id: reach
env:
# Pass the host via env (never interpolate a secret straight into the
# script body) so /dev/tcp gets a shell variable, not inlined text.
VPS_HOST: ${{ secrets.VPS_HOST }}
run: |
set -uo pipefail
# A GitHub-hosted runner can only deploy when it can actually open a TCP
# connection to the VPS SSH port. The Local VPS lives on a private LAN and
# the Akamai host firewalls :22 to known IPs, so the runner is routinely
# unable to reach it (`dial tcp ***:22: i/o timeout`). Treat "unreachable
# from the runner" as a SKIP — the real deploys are run manually from an
# allowed network via the deploy-vps-local / deploy-vps-akamai skills — so
# an unreachable host no longer red-fails every release/push pipeline.
# When the host IS reachable, the deploy step below still runs in full and
# its health gate surfaces any genuine deploy failure.
if timeout 15 bash -c 'exec 3<>"/dev/tcp/${VPS_HOST}/22"' 2>/dev/null; then
echo "reachable=true" >> "$GITHUB_OUTPUT"
echo "✅ VPS_HOST:22 reachable from the runner — proceeding with deploy."
else
echo "reachable=false" >> "$GITHUB_OUTPUT"
echo "::warning title=Auto-deploy skipped::VPS_HOST:22 is not reachable from this GitHub runner (private LAN / firewalled). Deploy manually with the deploy-vps-local or deploy-vps-akamai skill."
fi
- name: Deploy via SSH
if: steps.reach.outputs.reachable == 'true'
uses: appleboy/ssh-action@v1
continue-on-error: true
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: 22
timeout: 60s
command_timeout: 15m
timeout: 30s
command_timeout: 5m
script: |
set -euo pipefail
echo "=== Updating OmniRoute ==="
npm install -g omniroute@latest
INSTALLED_VERSION=$(omniroute --version 2>/dev/null | tr -d '[:space:]' || echo "unknown")
echo "Installed CLI version: $INSTALLED_VERSION"
npm install -g omniroute@latest 2>&1
INSTALLED_VERSION=$(omniroute --version 2>/dev/null || echo "unknown")
echo "Installed version: $INSTALLED_VERSION"
# Recreate the PM2 process instead of `pm2 restart`. A bare restart
# re-runs whatever script path was saved earlier; after the build-output
# reorg (app/ -> dist/, .next -> .build/next) a process pinned to the old
# app/server-ws.mjs path can no longer start, and the node process dies
# while PM2 still reports "online" — so the box never binds :20128.
# Always launch via the `omniroute` bin so .env is loaded and the dist/
# layout is resolved correctly.
echo "=== (Re)creating PM2 process via bin ==="
pm2 delete omniroute 2>/dev/null || true
pm2 start omniroute --name omniroute -- --port 20128
echo "=== Restarting PM2 ==="
pm2 restart omniroute || pm2 start omniroute --name omniroute -- --port 20128
pm2 save
# Health gate: fail the deploy unless the box actually reports healthy.
# Poll /api/monitoring/health for "status":"healthy" (a deeper signal than
# a static page 200 — it confirms the app booted, not just that a port is
# bound). Boot can take a while after a native-module/build-layout change,
# so poll up to ~3min before giving up.
echo "=== Health Check (gates the deploy) ==="
ok=0
for i in $(seq 1 36); do
BODY=$(curl -sf -m 5 http://localhost:20128/api/monitoring/health 2>/dev/null || true)
if printf '%s' "$BODY" | grep -q '"status":"healthy"'; then
ok=1
echo "✅ /api/monitoring/health -> healthy (attempt $i) — version $INSTALLED_VERSION"
break
fi
echo "… not healthy yet (attempt $i/36), retrying in 5s"
sleep 5
done
if [ "$ok" != "1" ]; then
echo "❌ Health check failed — /api/monitoring/health never reported healthy after ~3min"
echo "--- recent PM2 logs ---"
pm2 logs omniroute --lines 40 --nostream || true
exit 1
fi
echo "=== Health Check ==="
sleep 3
curl -sf http://localhost:20128/api/settings > /dev/null && echo "✅ OmniRoute is healthy" || echo "❌ Health check failed"
echo "=== Deploy complete ==="

View File

@@ -8,35 +8,23 @@ on:
- "v*"
paths-ignore:
- ".github/workflows/**"
# Use 'released' instead of 'published' so editing/re-publishing old releases
# does NOT re-trigger this workflow. 'released' fires only on the initial
# release publication (and pre-release → release transition).
release:
types: [released]
types: [published]
workflow_dispatch:
inputs:
version:
description: "Version tag to build (e.g. 3.8.4)"
description: "Version tag to build (e.g. 2.6.0)"
required: true
type: string
promote_latest:
description: "Also tag :latest (only if this is the highest semver)"
required: false
type: boolean
default: false
permissions:
contents: read
packages: write
jobs:
prepare:
name: Resolve Docker release metadata
docker:
name: Build and Push Docker (multi-arch)
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
promote_latest: ${{ steps.version.outputs.promote_latest }}
skip: ${{ steps.version.outputs.skip }}
env:
IMAGE_NAME: diegosouzapw/omniroute
steps:
@@ -44,104 +32,9 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
# Need full tag history for semver comparison when deciding :latest.
fetch-depth: 0
- name: Resolve version, latest-promotion, and skip flag
id: version
env:
EVENT_NAME: ${{ github.event_name }}
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
INPUT_VERSION: ${{ inputs.version }}
PROMOTE_INPUT: ${{ inputs.promote_latest }}
run: |
set -euo pipefail
# 1) Resolve version string from the trigger (all inputs come via env).
case "$EVENT_NAME" in
workflow_dispatch)
VERSION="${INPUT_VERSION#v}"
;;
push)
if [ "$REF_TYPE" = "tag" ]; then
VERSION="${REF_NAME#v}"
else
# Push to main → build & tag as `main` only. Never touch :latest.
VERSION="main"
fi
;;
release)
VERSION="${REF_NAME#v}"
;;
*)
VERSION="${REF_NAME#v}"
;;
esac
# Sanity-check: only allow [A-Za-z0-9._-] in VERSION (defense in depth).
if ! printf '%s' "$VERSION" | grep -qE '^[A-Za-z0-9._-]+$'; then
echo "Refusing to use unsafe VERSION value: $VERSION" >&2
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
# 2) Decide whether to promote :latest.
PROMOTE="false"
if [ "$VERSION" = "main" ]; then
PROMOTE="false"
elif printf '%s' "$VERSION" | grep -qE -- '-(rc|alpha|beta|pre|next)'; then
echo "Pre-release identifier detected — skipping :latest."
PROMOTE="false"
elif [ "$EVENT_NAME" = "workflow_dispatch" ]; then
PROMOTE="${PROMOTE_INPUT:-false}"
else
git fetch --tags --quiet || true
HIGHEST=$(git tag -l 'v[0-9]*' | sed 's/^v//' | grep -vE -- '-(rc|alpha|beta|pre|next)' | sort -V | tail -1 || echo "")
if [ -n "$HIGHEST" ] && [ "$VERSION" = "$HIGHEST" ]; then
PROMOTE="true"
else
echo "Version $VERSION is not the highest semver tag (highest=${HIGHEST:-<none>}). Not promoting :latest."
fi
fi
echo "promote_latest=$PROMOTE" >> "$GITHUB_OUTPUT"
# 3) Skip if this exact version is already published in Docker Hub.
# `main` is always rebuilt (mutable floating tag).
SKIP="false"
if [ "$VERSION" != "main" ]; then
if docker manifest inspect "diegosouzapw/omniroute:${VERSION}" >/dev/null 2>&1; then
echo "Image diegosouzapw/omniroute:${VERSION} already exists on Docker Hub — skipping rebuild."
SKIP="true"
fi
fi
echo "skip=$SKIP" >> "$GITHUB_OUTPUT"
echo "Publishing diegosouzapw/omniroute:$VERSION (promote_latest=$PROMOTE, skip=$SKIP)"
build:
name: Build Docker (${{ matrix.platform }})
needs: prepare
if: needs.prepare.outputs.skip != 'true'
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-24.04
arch: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
arch: arm64
env:
IMAGE_NAME: diegosouzapw/omniroute
GHCR_IMAGE_NAME: ghcr.io/diegosouzapw/omniroute
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
fetch-depth: 0
- name: Set up QEMU (for multi-arch builds)
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
@@ -159,172 +52,41 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push platform image by digest
id: build
- name: Extract version from release tag or input
id: version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${{ inputs.version }}"
else
VERSION="${GITHUB_REF_NAME}"
VERSION="${VERSION#v}"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Publishing Docker image: $IMAGE_NAME:$VERSION"
- name: Build and push multi-arch image
uses: docker/build-push-action@v7
with:
context: .
target: runner-base
platforms: ${{ matrix.platform }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.IMAGE_NAME }}
${{ env.GHCR_IMAGE_NAME }}
cache-from: type=gha,scope=docker-${{ matrix.arch }}
cache-to: type=gha,scope=docker-${{ matrix.arch }},mode=max
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
${{ env.IMAGE_NAME }}:latest
ghcr.io/diegosouzapw/omniroute:${{ steps.version.outputs.version }}
ghcr.io/diegosouzapw/omniroute:latest
cache-from: type=gha
cache-to: type=gha,mode=max
no-cache: false
env:
DOCKER_BUILDKIT_INLINE_CACHE: 1
- name: Build and push WEB platform image by digest
id: build-web
uses: docker/build-push-action@v7
with:
context: .
target: runner-web
platforms: ${{ matrix.platform }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
tags: |
${{ env.IMAGE_NAME }}
${{ env.GHCR_IMAGE_NAME }}
cache-from: type=gha,scope=docker-web-${{ matrix.arch }}
cache-to: type=gha,scope=docker-web-${{ matrix.arch }},mode=max
no-cache: false
env:
DOCKER_BUILDKIT_INLINE_CACHE: 1
- name: Export digests
env:
DIGEST_BASE: ${{ steps.build.outputs.digest }}
DIGEST_WEB: ${{ steps.build-web.outputs.digest }}
run: |
set -euo pipefail
mkdir -p /tmp/digests/base /tmp/digests/web
touch "/tmp/digests/base/${DIGEST_BASE#sha256:}"
touch "/tmp/digests/web/${DIGEST_WEB#sha256:}"
- name: Upload base digests
uses: actions/upload-artifact@v7
with:
name: digests-base-${{ matrix.arch }}
path: /tmp/digests/base/*
if-no-files-found: error
retention-days: 1
- name: Upload web digests
uses: actions/upload-artifact@v7
with:
name: digests-web-${{ matrix.arch }}
path: /tmp/digests/web/*
if-no-files-found: error
retention-days: 1
merge:
name: Publish multi-arch manifests
needs:
- prepare
- build
if: needs.prepare.outputs.skip != 'true'
runs-on: ubuntu-latest
env:
IMAGE_NAME: diegosouzapw/omniroute
GHCR_IMAGE_NAME: ghcr.io/diegosouzapw/omniroute
VERSION: ${{ needs.prepare.outputs.version }}
PROMOTE_LATEST: ${{ needs.prepare.outputs.promote_latest }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Download base digests
uses: actions/download-artifact@v8
with:
pattern: digests-base-*
path: /tmp/digests/base
merge-multiple: true
- name: Download web digests
uses: actions/download-artifact@v8
with:
pattern: digests-web-*
path: /tmp/digests/web
merge-multiple: true
- name: Create Docker Hub manifest
run: |
set -euo pipefail
create_manifest() {
local image="$1" suffix="$2" dir="$3"
local tags=(-t "${image}:${VERSION}${suffix}")
if [ "$PROMOTE_LATEST" = "true" ]; then
tags+=(-t "${image}:latest${suffix}")
fi
local refs=()
while IFS= read -r digest_file; do
refs+=("${image}@sha256:$(basename "$digest_file")")
done < <(find "$dir" -type f | sort)
if [ "${#refs[@]}" -eq 0 ]; then
echo "No image digests in $dir" >&2
exit 1
fi
docker buildx imagetools create "${tags[@]}" "${refs[@]}"
}
create_manifest "${IMAGE_NAME}" "" /tmp/digests/base
create_manifest "${IMAGE_NAME}" "-web" /tmp/digests/web
- name: Create GHCR manifest
run: |
set -euo pipefail
create_manifest() {
local image="$1" suffix="$2" dir="$3"
local tags=(-t "${image}:${VERSION}${suffix}")
if [ "$PROMOTE_LATEST" = "true" ]; then
tags+=(-t "${image}:latest${suffix}")
fi
local refs=()
while IFS= read -r digest_file; do
refs+=("${image}@sha256:$(basename "$digest_file")")
done < <(find "$dir" -type f | sort)
if [ "${#refs[@]}" -eq 0 ]; then
echo "No image digests in $dir" >&2
exit 1
fi
docker buildx imagetools create "${tags[@]}" "${refs[@]}"
}
create_manifest "${GHCR_IMAGE_NAME}" "" /tmp/digests/base
create_manifest "${GHCR_IMAGE_NAME}" "-web" /tmp/digests/web
- name: Inspect image
if: needs.prepare.outputs.version != 'main'
run: |
docker buildx imagetools inspect "${IMAGE_NAME}:${VERSION}"
docker buildx imagetools inspect "${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}"
- name: Update Docker Hub description
# Only refresh README/description when we actually promote :latest
# (avoids overwriting from main pushes or back-fill builds).
if: needs.prepare.outputs.promote_latest == 'true'
uses: peter-evans/dockerhub-description@v5
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}

View File

@@ -104,7 +104,6 @@ jobs:
- name: Build Next.js standalone
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
NODE_OPTIONS: "--max_old_space_size=6144"
run: npm run build
- name: Sync version in electron/package.json
@@ -136,16 +135,9 @@ jobs:
- name: Smoke packaged Electron app
if: matrix.platform != 'linux'
# Best-effort smoke on Windows + macos-arm64:
# - Windows: requestSingleInstanceLock() fails due to USERPROFILE
# sanitization needed for the build step.
# - macos-arm64: the headless GitHub arm64 runner crashes Electron's GPU
# process (gpu_process_host exit_code=15 → network service crash →
# "No rendezvous client, terminating process"), so the app can't bind
# 127.0.0.1:20128 in time. The identical bundle is smoke-gated on
# macos-intel + linux, so packaging is still verified per-OS; we don't
# let the arm64 runner's GPU flakiness block the desktop release.
continue-on-error: ${{ matrix.platform == 'windows' || matrix.platform == 'macos-arm64' }}
# Windows CI: requestSingleInstanceLock() fails due to USERPROFILE
# sanitization needed for the build step. Smoke is best-effort there.
continue-on-error: ${{ matrix.platform == 'windows' }}
env:
ELECTRON_SMOKE_TIMEOUT_MS: 60000
ELECTRON_SMOKE_STREAM_LOGS: "1"

View File

@@ -1,125 +0,0 @@
name: Lock released branch
# Two responsibilities (defense in depth — Hard Rule #18 enforcement):
#
# 1. `on: release: published` — when a GitHub Release publishes tag v3.X.Y,
# apply branch protection (lock_branch + enforce_admins) to release/v3.X.Y
# so no further commits can land on a shipped version. To reopen later:
# gh api -X DELETE repos/<owner>/<repo>/branches/release/<tag>/protection
#
# 2. `on: push: branches: ['release/v*']` — verify that no push lands on a
# release/* branch whose matching tag already exists. This is the preventive
# guard: if the lock didn't apply (workflow bug, missing PAT, race), this
# job FAILS the push run so the operator gets paged immediately.
#
# `permissions:` cannot grant the `Administration` scope to GITHUB_TOKEN — that
# scope only exists on PATs. Set BRANCH_LOCK_TOKEN as a repo secret pointing to
# a PAT/fine-grained token with `Administration: read & write`. Without it, the
# lock step will fail loudly (which is what we want — silent failure caused the
# v3.8.3 incident on 2026-05-26 where 6 commits landed post-release).
on:
release:
types: [published]
push:
branches:
- "release/v*"
workflow_dispatch:
inputs:
tag:
description: "Tag of the released version (e.g. v3.8.2)"
required: true
type: string
permissions:
contents: read
jobs:
# ─────────────────────────────────────────────────────────────────────────
# Job 1 — Lock the release branch when a Release is published.
# ─────────────────────────────────────────────────────────────────────────
lock-branch:
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Lock release/<tag> branch
env:
# Administration scope is required to PUT branch protection. Default
# GITHUB_TOKEN cannot do this — operator must provision BRANCH_LOCK_TOKEN.
GH_TOKEN: ${{ secrets.BRANCH_LOCK_TOKEN }}
TAG: ${{ github.event.release.tag_name || inputs.tag }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
if [ -z "${GH_TOKEN}" ]; then
echo "::error::BRANCH_LOCK_TOKEN secret is not set. Create a PAT with Administration:write and add it as repo secret."
exit 1
fi
if [ -z "${TAG}" ]; then
echo "::error::No tag provided; cannot determine release branch."
exit 1
fi
BRANCH="release/${TAG}"
echo "Target branch: ${BRANCH} (repo: ${REPO})"
if ! gh api "repos/${REPO}/branches/${BRANCH}" >/dev/null 2>&1; then
echo "::warning::Branch ${BRANCH} not found — nothing to lock."
exit 0
fi
echo "Applying lock_branch protection to ${BRANCH}..."
gh api -X PUT "repos/${REPO}/branches/${BRANCH}/protection" --input - <<'JSON'
{
"required_status_checks": null,
"enforce_admins": true,
"required_pull_request_reviews": null,
"restrictions": null,
"lock_branch": true,
"allow_force_pushes": false,
"allow_deletions": false
}
JSON
LOCKED=$(gh api "repos/${REPO}/branches/${BRANCH}/protection" \
--jq '.lock_branch.enabled')
if [ "${LOCKED}" != "true" ]; then
echo "::error::Failed to confirm lock on ${BRANCH} (lock_branch=${LOCKED})."
exit 1
fi
echo "✅ ${BRANCH} is now locked (read-only)."
# ─────────────────────────────────────────────────────────────────────────
# Job 2 — Preventive guard: fail if a push lands on release/vX.Y.Z whose
# tag already exists. This catches the case where the lock didn't apply
# (PAT missing, race window, workflow bug) and pages the operator.
# ─────────────────────────────────────────────────────────────────────────
guard-no-push-after-release:
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Reject push if matching release tag exists
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
REF: ${{ github.ref_name }}
run: |
set -euo pipefail
# Extract version from ref: release/v3.8.3 -> v3.8.3
if [[ ! "${REF}" =~ ^release/(v[0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
echo "Ref ${REF} does not match release/vX.Y.Z — nothing to guard."
exit 0
fi
TAG="${BASH_REMATCH[1]}"
echo "Checking if tag ${TAG} already exists on ${REPO}..."
if gh api "repos/${REPO}/git/refs/tags/${TAG}" >/dev/null 2>&1; then
echo "::error::Hard Rule #18 violation — push to ${REF} but tag ${TAG} is already released."
echo "::error::Hotfixes for a released version must go on a NEW branch: release/v$(echo "${TAG#v}" | awk -F. '{$3=$3+1; print $1"."$2"."$3}' OFS=.)"
echo "::error::To undo this push: revert the offending commits, or contact an admin to lock the branch if it wasn't already."
exit 1
fi
echo "✅ No release tag for ${TAG} yet — push is OK."

View File

@@ -1,11 +1,8 @@
name: Publish to npm
on:
# 'released' (not 'published') so editing/re-publishing old releases does NOT
# re-trigger this workflow. Pairs with the semver guard below as defense in
# depth against accidental dist-tag clobbering by old releases.
release:
types: [released]
types: [published]
workflow_dispatch:
inputs:
version:
@@ -13,15 +10,13 @@ on:
required: true
type: string
tag:
description: "npm dist-tag (auto / latest / next / historic)"
description: "npm dist-tag (latest / next)"
required: false
default: "auto"
default: "latest"
type: choice
options:
- auto
- latest
- next
- historic
workflow_call:
inputs:
version:
@@ -29,9 +24,9 @@ on:
required: true
type: string
tag:
description: "npm dist-tag (auto / latest / next / historic)"
description: "npm dist-tag (latest / next)"
required: false
default: "auto"
default: "latest"
type: string
secrets:
NPM_TOKEN:
@@ -48,13 +43,10 @@ env:
jobs:
publish:
runs-on: ubuntu-latest
environment: NPM_TOKEN
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# Need full tag history to compare against highest semver when
# deciding whether this release should claim dist-tag `latest`.
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -65,148 +57,77 @@ jobs:
- name: Install dependencies (skip scripts to avoid heavy build)
run: npm install --ignore-scripts --no-audit --no-fund
- name: Resolve version, dist-tag and skip flag
- name: Resolve version and dist-tag
id: resolve
env:
EVENT_NAME: ${{ github.event_name }}
REF_NAME: ${{ github.ref_name }}
INPUT_VERSION: ${{ inputs.version }}
INPUT_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
VERSION="${{ inputs.version }}"
TAG="${{ inputs.tag }}"
# 1) Resolve VERSION from the trigger (all inputs come via env).
VERSION="${INPUT_VERSION:-}"
if [ -z "$VERSION" ] && [ "$EVENT_NAME" = "release" ]; then
VERSION="$REF_NAME"
fi
VERSION="${VERSION#v}"
if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.-]+)?$'; then
echo "Refusing to publish unsafe VERSION value: $VERSION" >&2
exit 1
fi
# 2) Resolve dist-tag.
# - explicit 'latest'/'next'/'historic' is honored
# - 'auto' (or empty): pre-release identifiers → 'next';
# stable versions → 'latest' only if VERSION is the highest
# stable semver among `v*` tags (otherwise → 'historic').
REQUESTED_TAG="${INPUT_TAG:-auto}"
TAG="$REQUESTED_TAG"
if [ "$TAG" = "auto" ] || [ -z "$TAG" ]; then
if printf '%s' "$VERSION" | grep -qE -- '-(rc|alpha|beta|pre|next)'; then
TAG="next"
else
git fetch --tags --quiet || true
HIGHEST=$(git tag -l 'v[0-9]*' | sed 's/^v//' | grep -vE -- '-(rc|alpha|beta|pre|next)' | sort -V | tail -1 || echo "")
if [ -n "$HIGHEST" ] && [ "$VERSION" = "$HIGHEST" ]; then
TAG="latest"
else
echo "Version $VERSION is not the highest semver tag (highest=${HIGHEST:-<none>}). Using dist-tag 'historic' to avoid clobbering @latest."
TAG="historic"
fi
if [ -z "$VERSION" ]; then
if [ "${{ github.event_name }}" = "release" ]; then
VERSION="${GITHUB_REF_NAME}"
fi
fi
# 3) Skip-if-already-published. NOTE: do NOT pass `--silent` to
# `npm view` — it suppresses stdout and breaks the grep, which
# caused old releases (3.2.8) to be re-published and steal
# dist-tag `latest`. See incident notes in CHANGELOG.
PUBLISHED="$(npm view "omniroute@${VERSION}" version 2>/dev/null || true)"
SKIP="false"
if [ "$PUBLISHED" = "$VERSION" ]; then
echo "⚠️ omniroute@${VERSION} is already on npm — skipping publish."
SKIP="true"
fi
# Strip v prefix if present
VERSION="${VERSION#v}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "skip=$SKIP" >> "$GITHUB_OUTPUT"
echo "📦 Resolved omniroute@$VERSION dist-tag=$TAG skip=$SKIP"
# Default dist-tag logic
if [ -z "$TAG" ]; then
if [[ "$VERSION" == *-* ]]; then
TAG="next"
else
TAG="latest"
fi
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "📦 Publishing omniroute@$VERSION with tag=$TAG"
- name: Sync package.json version
if: steps.resolve.outputs.skip != 'true'
env:
VERSION: ${{ steps.resolve.outputs.version }}
run: |
npm version "$VERSION" --no-git-tag-version --allow-same-version
npm version "${{ steps.resolve.outputs.version }}" --no-git-tag-version --allow-same-version
- name: Build CLI bundle (standalone app)
if: steps.resolve.outputs.skip != 'true'
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
run: npm run build:cli
- name: Validate npm package artifact
if: steps.resolve.outputs.skip != 'true'
run: npm run check:pack-artifact
- name: Publish to npm
if: steps.resolve.outputs.skip != 'true'
env:
VERSION: ${{ steps.resolve.outputs.version }}
TAG: ${{ steps.resolve.outputs.tag }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
# Always pass --tag explicitly. Defense in depth: even if VERSION is
# accidentally an older release, `npm publish --tag historic` will
# NOT promote it to `@latest`.
npm publish --access public --tag "$TAG"
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG)"
- name: Publish to GitHub Packages
if: steps.resolve.outputs.skip != 'true'
env:
VERSION: ${{ steps.resolve.outputs.version }}
TAG: ${{ steps.resolve.outputs.tag }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
echo "Configuring for GitHub Packages..."
echo "//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}" > .npmrc
npm pkg set name="@diegosouzapw/omniroute"
npm publish --registry=https://npm.pkg.github.com --tag "$TAG" \
|| echo "⚠️ omniroute@${VERSION} might already be published on GitHub Packages."
echo "✅ Action finished for GitHub Packages"
publish-opencode-plugin:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
registry-url: https://registry.npmjs.org
- name: Install plugin dependencies
working-directory: "@omniroute/opencode-plugin"
run: npm install --no-audit --no-fund
- name: Build plugin
working-directory: "@omniroute/opencode-plugin"
run: npm run clean && npm run build
- name: Test plugin
working-directory: "@omniroute/opencode-plugin"
run: npm test
- name: Publish @omniroute/opencode-plugin to npm
working-directory: "@omniroute/opencode-plugin"
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
PKG_VERSION=$(node -p "require('./package.json').version")
PKG_NAME=$(node -p "require('./package.json').name")
# Same hardened skip-check as the main job (no --silent flag).
PUBLISHED="$(npm view "${PKG_NAME}@${PKG_VERSION}" version 2>/dev/null || true)"
if [ "$PUBLISHED" = "$PKG_VERSION" ]; then
echo "⚠️ ${PKG_NAME}@${PKG_VERSION} is already published on npm — skipping."
VERSION="${{ steps.resolve.outputs.version }}"
TAG="${{ steps.resolve.outputs.tag }}"
# Check if this version is already published — skip instead of failing with E403
if npm view "omniroute@${VERSION}" version --silent 2>/dev/null | grep -q "^${VERSION}$"; then
echo "⚠️ Version ${VERSION} is already published on npm — skipping."
exit 0
fi
npm publish --access public --ignore-scripts
echo "✅ Published ${PKG_NAME}@${PKG_VERSION}"
if [ "$TAG" = "latest" ]; then
npm publish --access public
else
npm publish --access public --tag "$TAG"
fi
echo "✅ Published omniroute@$VERSION (tag: $TAG)"
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish to GitHub Packages
run: |
VERSION="${{ steps.resolve.outputs.version }}"
TAG="${{ steps.resolve.outputs.tag }}"
echo "Configuring for GitHub Packages..."
echo "//npm.pkg.github.com/:_authToken=${{ secrets.GITHUB_TOKEN }}" > .npmrc
npm pkg set name="@diegosouzapw/omniroute"
if [ "$TAG" = "latest" ]; then
npm publish --registry=https://npm.pkg.github.com || echo "⚠️ Version ${VERSION} might already be published on GitHub."
else
npm publish --registry=https://npm.pkg.github.com --tag "$TAG" || echo "⚠️ Version ${VERSION} might already be published on GitHub."
fi
echo "✅ Action finished for GitHub Packages"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,62 +0,0 @@
name: opencode-plugin CI
on:
push:
branches: [main, release/v3.8.2]
paths:
- "@omniroute/opencode-plugin/**"
pull_request:
branches: [main, release/v3.8.2]
paths:
- "@omniroute/opencode-plugin/**"
types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
defaults:
run:
working-directory: "@omniroute/opencode-plugin"
jobs:
test:
name: Test (Node ${{ matrix.node }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ["22", "24"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
cache-dependency-path: "@omniroute/opencode-plugin/package-lock.json"
- run: npm install --no-audit --no-fund
- run: npm run build
- run: npm test
build:
name: Build
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v6
with:
node-version: "22"
cache: npm
cache-dependency-path: "@omniroute/opencode-plugin/package-lock.json"
- run: npm install --no-audit --no-fund
- run: npm run build
- uses: actions/upload-artifact@v7
with:
name: opencode-plugin-dist
path: "@omniroute/opencode-plugin/dist"
retention-days: 7

View File

@@ -33,7 +33,7 @@ jobs:
node: ["20", "22", "24"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
@@ -47,14 +47,14 @@ jobs:
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: "@omniroute/opencode-provider/package-lock.json"
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v4
with:
name: opencode-provider-dist
path: "@omniroute/opencode-provider/dist"

117
.gitignore vendored
View File

@@ -4,37 +4,12 @@
.omnivscodeagent/
omnirouteCloud/
omnirouteSite/
_cache/
_ideia/
_mono_repo/
_references/
_tasks/
.agents/**
.claude/**
.gemini/**
.config/**
.data/**
.logs/**
.tests/**
.coverage/**
coverage/
.dist/**
.next/**
.build/**
.out/**
# Memory Bank and Cursor rules (local-only AI agent context)
memory-bank/
.cursor/rules/core.mdc
.cursor/rules/memory-bank.mdc
# Claude Code local state — runtime files only; shared commands at .claude/commands/ are tracked
.claude/scheduled_tasks.lock
.claude/scheduled_tasks/
.claude/sessions/
.claude/state.json
.claude/settings.local.json
# Root-level underscore-prefixed directories (private/draft — never commit)
/_*/
@@ -44,15 +19,32 @@ docs/new-features/
# dependencies
node_modules/
*.map
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
.data/
.next-playwright/
# testing
coverage/
coverage**
# next.js
.next/
/out/
# production
/build
/app
cloud/*
# misc
.DS_Store
# Obsidian sync plugin — committed for community distribution
!obsidian-plugin/
obsidian-plugin/node_modules/
# Serena AI assistant config (local-only tool, not project code)
.serena/
*.pem
# debug
npm-debug.log*
@@ -63,9 +55,6 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# Provider API keys (never commit)
*.api-key
.nvidia-api-key
# vercel
.vercel
@@ -108,22 +97,15 @@ clipr/
app.log
*.tgz
.gh-discussions.json
deploy.sh
docker-compose.minimal.yml
# Backup directories
app.__qa_backup/
.app-build-backup-*/
backup/
# Build intermediates (.build/) and shippable standalone (dist/).
# These are fully reproducible from source; never committed.
# Layer 1: Next.js now writes to .build/next (was .next); assembled bundle → dist/
# (Previously /app/ was the standalone output; renamed to /dist/ in Layer 1.)
/.build/
/dist/
/.next/
# Production standalone build (created by scripts/prepublish.mjs)
# Conflicts with Next.js App Router detection in dev (root app/ shadows src/app/)
# npm publish still includes it via package.json "files" field
/app/
# Electron
electron/dist-electron/
@@ -138,6 +120,9 @@ vscode-extension/
*.sqlite-wal
*.sqlite-journal
# Compiled npm-package build artifact (not source, should not be in git)
/app
# IDEA
.idea/
@@ -166,8 +151,8 @@ bun.lock
# Private environment variables for .http-client
http-client.private.env.json
# Note: _ideia/ (feature-triage drafts) is fully covered by the /_*/ rule above
# and kept as a separate local-only git repo. Never committed to OmniRoute.
# Feature-triage ephemeral artifact (regenerated each run)
_ideia/_triage.json
# i18n audit artifact (generated by scripts/i18n/audit-dashboard-pages.mjs)
scripts/i18n/_audit.json
@@ -176,39 +161,5 @@ scripts/i18n/_pending-keys.json
# Private workflow / skill / command implementations
# These contain proprietary multi-phase logic and should not be committed
.agents/workflows/implement-features-ag.md
.agents/workflows/port-upstream-features-ag.md
.agents/workflows/port-upstream-issues-ag.md
.agents/skills/implement-features/
.claude/commands/implement-features-cc.md
.claude/commands/port-upstream-features-cc.md
.claude/commands/port-upstream-issues-cc.md
.claude/worktrees/
.codegraph/
# Fumadocs generated source
.source/
# AI agent local settings and configs
.agents/
.antigravitycli/
.claude/
# PR Reviews and local feedback files
pr_reviews*.json
#hidden local data directories (never commit)
.local-data/
.data-dev/
/.junie/
# internal setup prompts with personal credentials — never commit
CODEX-SETUP-PROMPT.md
# Quality ratchet — métricas efêmeras (baseline é commitado, métricas não)
quality-metrics.json
-home-diegosouzapw-dev-automações-bots-yt-downloader-20260504 .txt
-home-diegosouzapw-dev-automações-bots-yt-downloader-20260410 .txt
docs/prompts/AGENT-OWNERSHIP-PROTOCOL.omniroute.md
docs/prompts/AGENT-OWNERSHIP-PROTOCOL.md
docs/prompts/AGENT-OWNERSHIP-PROTOCOL.omniroute-mim.md
docs/prompts/AGENT-OWNERSHIP-PROTOCOL.omniroute-mid.md
omniroute.md

View File

@@ -5,8 +5,20 @@ if ! command -v npx >/dev/null 2>&1; then
exit 0
fi
# Cheap, deterministic local gates (re-enabled). Slower checks (i18n drift,
# openapi coverage/security-tiers, env-doc sync) run in CI to keep commits fast.
npx lint-staged
node scripts/check/check-docs-sync.mjs
npm run check:any-budget:t11
# Strict env-doc sync (FASE 2)
node scripts/check/check-env-doc-sync.mjs
# CLI i18n consistency check — all t() keys must exist in en.json (FASE 8.3)
node scripts/check/check-cli-i18n.mjs
# i18n docs drift advisory (FASE 5) — warn-only on pre-commit; CI enforces strict.
node scripts/i18n/check-translation-drift.mjs --warn || \
echo "⚠️ i18n drift detected. Run 'npm run i18n:run' to update locale mirrors."
# i18n UI coverage advisory (FASE 6) — pre-commit warns; CI enforces strict.
node scripts/i18n/check-ui-keys-coverage.mjs --threshold=80 || \
echo "⚠️ UI i18n coverage below 80% for at least one locale."

219
.i18n-state.json Normal file
View File

@@ -0,0 +1,219 @@
{
"sources": {
"CLAUDE.md": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"locales": {
"pt-BR": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "6ed62a4b8e85b48db9dbd3c5f00cec0a423742dd4ac518ff0206d6bf1732e714",
"updated_at": "2026-05-18T22:01:38.515Z"
},
"az": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "b3da42c255bf9d70ac862ae083ad2c15193dfcd69407db70bb452bc03b735283",
"updated_at": "2026-05-18T21:47:44.228Z"
},
"bn": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "cbb9eb8a552e7f6ceb0d45fdd115db2d88e90c420944f27fdfce8a461603c50a",
"updated_at": "2026-05-18T21:48:17.508Z"
},
"ar": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "179543e6015946c2409a72ad87519c71a86086982fa573061a9d958675face59",
"updated_at": "2026-05-18T21:48:47.676Z"
},
"cs": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "ee2d587bb968619c4791ee7fa75909f40aa1d6fb5e97c293f8ff2ac4bc22167c",
"updated_at": "2026-05-18T21:51:04.860Z"
},
"da": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "4672b5643ab8f9adfcf8a14199c127dcc54380ed4cbaccd3d946f0db25fbead3",
"updated_at": "2026-05-18T21:51:13.777Z"
},
"de": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "1a36bebb379ff38306b0e850d2460f4cd9c5bc70b8b1f7ef9c42b84c833e22cd",
"updated_at": "2026-05-18T21:51:16.409Z"
},
"es": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "269c2b0842485060471ff2a7179fd16cc40273e7c8d27f28477d8724a1333b26",
"updated_at": "2026-05-18T21:51:22.611Z"
},
"fa": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "8b4603ebc4875f4af2ca67c571282038f566dce5b62d17caa361c0ec879c2bea",
"updated_at": "2026-05-18T21:52:38.812Z"
},
"fi": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "16a3a0b0df89e37808d7567e502944fb6792e14eb8c61e4b474b0fe79b310d63",
"updated_at": "2026-05-18T21:52:44.086Z"
},
"fr": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "1a8bb2fdc17927047842bf6806787e9cea83d8f2912bc0291836574af877cde1",
"updated_at": "2026-05-18T21:52:44.715Z"
},
"he": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "446414fae3f108ec2ac0fea09d826fab6a6dcfe2189e33f63d0068d7ffe945b2",
"updated_at": "2026-05-18T21:54:06.000Z"
},
"hi": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "24ce173a1252a9849917324395ae5712e7d03d98ab9e9119a98c12c405e5fd96",
"updated_at": "2026-05-18T21:54:16.102Z"
},
"gu": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "4d49e4be59a029c9407ca023a7f7ef21533e2ccf9c1b857a84eec0d5c1e0b1eb",
"updated_at": "2026-05-18T21:55:11.836Z"
},
"hu": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "85881f57fc6a606e122466a368b159f3b7f6a8f0fa90aa7fe039f0127e436d44",
"updated_at": "2026-05-18T21:55:54.603Z"
},
"id": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "e970c79fe15c9f78745b1b53e5eed3b8914641a0a3f81ce2b5207a21dd850d4e",
"updated_at": "2026-05-18T21:56:05.289Z"
},
"in": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "9127b1ab7c33307a62ba12adc36ae0f70809b1ff21cd562b0d2f4c52ba5aae0d",
"updated_at": "2026-05-18T21:56:08.126Z"
},
"it": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "949279fa3953e26345303fdb0defb959d722cfd8e0858a2fd1353384bdee2d66",
"updated_at": "2026-05-18T21:56:31.825Z"
},
"ja": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "e2eec56e1fa368594e32e6184defc91bccf6b086f84de8a03774efa3b4b21cc7",
"updated_at": "2026-05-18T21:57:34.374Z"
},
"mr": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "c795db0cde7b51a434a5bf06384fbec09073a2b7418fb0dddd90fa12f46fb7e1",
"updated_at": "2026-05-18T21:58:25.858Z"
},
"ko": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "8d3f5cfda2ad3ce01e882cf2008de9f2106279e0f3a180cd8fa70280870b9761",
"updated_at": "2026-05-18T21:58:25.863Z"
},
"ms": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "f2e13499c45cea20bcf178eac842ba923859bcc25822c6b4b9cf627512f3cce9",
"updated_at": "2026-05-18T21:58:33.906Z"
},
"nl": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "81f851a5aff422692c62fd65c1289598bfc779f8ca02af7083215a0d0cf42575",
"updated_at": "2026-05-18T21:59:16.560Z"
},
"phi": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "bf3fb36abd19f82bb296fe9fada413368b06b60d7931c352ebd1da8c3ff2c9fa",
"updated_at": "2026-05-18T21:59:52.998Z"
},
"no": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "3263bf8e5287023b7e50f1a2e65c069b2a3719cbf58b1454e451b19ac56b2c93",
"updated_at": "2026-05-18T22:00:47.607Z"
},
"pl": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "549129cf48c266b953b13e32efdbd8b7ba09848d68f76a984471c8f3b7c15338",
"updated_at": "2026-05-18T22:01:03.762Z"
},
"ro": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "6391e37e4289f64b4e58a2922f42d0163f50dd836f55dbad1aa2d39020c27a68",
"updated_at": "2026-05-18T22:02:54.102Z"
},
"ru": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "6aa38bc3c3c835bcd5ddf37d9a1ed29bf57d3234ee910f87c92b1363254ad4e6",
"updated_at": "2026-05-18T22:02:57.045Z"
},
"pt": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "3a15114c2839e513ce1983571ab7ac312856a29f1284a49d80eba3860b06b3ed",
"updated_at": "2026-05-18T22:03:11.851Z"
},
"sk": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "d7bf32867f17562967d0af5b78ae4c71abd4af3c7523128b041739dd9b02131b",
"updated_at": "2026-05-18T22:04:00.222Z"
},
"sw": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "ae3337d4ef14ab15a25a60f2d74db80be8b2e82cdb3911f9a92b22fa5fd5395b",
"updated_at": "2026-05-18T22:04:46.376Z"
},
"sv": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "36f1ca81330f13a3489523711dcf9b3a051518164345dfefc3097dbc1459e1b3",
"updated_at": "2026-05-18T22:05:02.302Z"
},
"ta": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "12f7f379ca9970a561aa71a56f58734f6d454bf47f99bc240e52c2943aa585bb",
"updated_at": "2026-05-18T22:05:29.419Z"
},
"te": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "7334cc88818d46644f138e8c5aee8045346cc6a9e98f597bd616ff8de02d17e9",
"updated_at": "2026-05-18T22:06:17.933Z"
},
"tr": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "7284ba38f5a9b81f70926c923870f6ee1d29e9e34e7074a087e5ad9812794c91",
"updated_at": "2026-05-18T22:07:15.730Z"
},
"th": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "b300d8668da2de9dd5e3c80b3a6d138b677662965be6c1459dcc145fbe88c062",
"updated_at": "2026-05-18T22:07:44.472Z"
},
"ur": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "c2dfcb2125306a21b293a054f72d0946af4c9f29058fb679bcca3fbda7608a5a",
"updated_at": "2026-05-18T22:08:34.169Z"
},
"vi": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "ece90f91f228a0c504e4537d55e951b48763cafc14531a81dfc42d8a1ad45771",
"updated_at": "2026-05-18T22:09:55.405Z"
},
"zh-CN": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "780e02b27f556c748090474ad9081d8b3fd92018b14329407ac54a8f7e27688b",
"updated_at": "2026-05-18T22:10:23.922Z"
},
"uk-UA": {
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
"target_hash": "9a7a645713fb8b23de2a1a9b24317376a7a83fee994830bb81a2889e34efc0ee",
"updated_at": "2026-05-18T22:18:32.917Z"
}
}
},
"docs/architecture/ARCHITECTURE.md": {
"source_hash": "a065bbff5e461cca6cd1a81d2923626040548c3b3af98ecb2a69a99709bd8059",
"locales": {
"pt-BR": {
"source_hash": "a065bbff5e461cca6cd1a81d2923626040548c3b3af98ecb2a69a99709bd8059",
"target_hash": "7ee10dc8cba2668fd279af64ecca3a1760e974b6dc9672aee81e69c63ee41175",
"updated_at": "2026-05-20T05:34:41.826Z"
}
}
}
}
}

View File

@@ -9,16 +9,6 @@ app/vscode-extension/
**/db.json
# Source code (pre-built app/ is published instead)
#
# NOTE (#3578 / #3821-review): package.json "files" is the source of truth for what
# ships. It now allowlists the backend source closure the MCP server needs at runtime
# (open-sse/, src/lib, src/server, ...) and OVERRIDES the broad src/ + open-sse/ excludes
# below — npm honors files[] over .npmignore for inclusion. These lines are kept only as
# intent/back-stop: if files[] is ever trimmed back to specific paths, they must NOT be
# allowed to re-hide the MCP closure (that would silently reintroduce the --mcp
# ERR_MODULE_NOT_FOUND #3578 fixed). The closure gate in
# tests/unit/mcp-published-files-closure-3578.test.ts asserts the real `npm pack` output
# in both directions (closure present + zero test files), catching such a regression.
src/
open-sse/
docs/
@@ -28,17 +18,6 @@ images/
logs/
scripts/
# Co-located tests must never ship even when their parent dir is allowlisted by files[].
# (Primary guard is the "!**/*.test.*" negations in package.json files[]; this is defense
# in depth for any nested dir the allowlist pulls in.)
**/__tests__/
**/*.test.ts
**/*.test.tsx
**/*.test.js
**/*.test.mjs
**/*.spec.ts
**/*.spec.tsx
# Config/dev files
*.md
!README.md

View File

@@ -0,0 +1,31 @@
rules:
- id: cli-no-sqlite-direct
patterns:
- pattern: new Database(...)
paths:
include:
- "/bin/**"
exclude:
- "/bin/cli/sqlite.mjs"
message: >
Direct SQLite access in bin/ is banned. Use src/lib/db/* helpers or
withRuntime() from bin/cli/runtime.mjs. See CLAUDE.md hard rule #5 and
bin/cli/CONVENTIONS.md.
languages: [js]
severity: ERROR
- id: cli-no-raw-sql
patterns:
- pattern: $DB.prepare("INSERT INTO ...")
- pattern: $DB.prepare("DELETE FROM ...")
- pattern: $DB.prepare("UPDATE $TABLE SET ...")
paths:
include:
- "/bin/**"
exclude:
- "/bin/cli/sqlite.mjs"
message: >
Raw SQL in bin/ is banned. Use src/lib/db/* helpers. See CLAUDE.md
hard rule #5 and bin/cli/CONVENTIONS.md.
languages: [js]
severity: ERROR

View File

@@ -1,8 +0,0 @@
// @ts-nocheck
import { dynamic } from 'fumadocs-mdx/runtime/dynamic';
import * as Config from '../source.config';
const create = await dynamic<typeof Config, import("fumadocs-mdx/runtime/types").InternalTypeConfig & {
DocData: {
}
}>(Config, {"configPath":"source.config.ts","environment":"next","outDir":".source"}, {"doc":{"passthroughs":["extractedReferences"]}});

View File

@@ -1,23 +0,0 @@
// source.config.ts
import { defineDocs, defineConfig } from "fumadocs-mdx/config";
var docs = defineDocs({
dir: "docs",
docs: {
files: [
"./getting-started/**/*.md",
"./architecture/**/*.md",
"./guides/**/*.md",
"./reference/**/*.md",
"./frameworks/**/*.md",
"./routing/**/*.md",
"./security/**/*.md",
"./compression/**/*.md",
"./ops/**/*.md"
]
}
});
var source_config_default = defineConfig();
export {
source_config_default as default,
docs
};

31
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,31 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Dev Server",
"type": "node",
"request": "launch",
"runtimeExecutable": "${env:HOME}/.nvm/versions/node/v22.22.2/bin/node",
"program": "${workspaceFolder}/scripts/run-next.mjs",
"args": ["dev"],
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**", "node_modules/**"]
},
{
"name": "Debug Prod Server",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/scripts/run-next.mjs",
"args": ["start"],
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**", "node_modules/**"]
},
{
"name": "Attach to Running Server",
"type": "node",
"request": "attach",
"port": 9229,
"skipFiles": ["<node_internals>/**", "node_modules/**"]
}
]
}

24
.vscode/settings.json vendored
View File

@@ -1,5 +1,4 @@
{
"workbench.sideBar.location": "left",
"css.lint.unknownAtRules": "ignore",
"sonarlint.rules": {
"css:S4662": {
@@ -36,18 +35,7 @@
"**/node_modules/**": true,
"**/.next/**": true,
"**/coverage/**": true,
"**/_tasks/**": true,
"**/.git/objects/**": true,
"**/dist/**": true,
"**/build/**": true,
"**/out/**": true,
"**/.cache/**": true,
"**/.turbo/**": true,
"**/OmniRoute-*/**": true,
"**/*-merge-*/**": true,
"**/*-worktree-*/**": true,
"**/*-issues-*/**": true,
"**/*-reorg*/**": true
"**/_tasks/**": true
},
"search.exclude": {
"**/_references": true,
@@ -56,14 +44,6 @@
"**/node_modules": true,
"**/.next": true,
"**/coverage": true,
"**/_tasks": true,
"**/dist": true,
"**/build": true,
"**/out": true,
"**/OmniRoute-*": true,
"**/*-merge-*": true,
"**/*-worktree-*": true,
"**/*-issues-*": true,
"**/*-reorg*": true
"**/_tasks": true
}
}

View File

@@ -1,4 +0,0 @@
node_modules
dist
*.log
.DS_Store

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 OmniRoute contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,269 +0,0 @@
# @omniroute/opencode-plugin
> **Recommended way to use OmniRoute with OpenCode.** Pulls a live model catalog from `/v1/models` (including `-low`/`-medium`/`-high`/`-thinking` variants as first-class IDs), aggregates combos via `/api/combos` using a least-common-denominator capability/limit join, sanitizes Gemini tool schemas in flight, and supports multiple side-by-side OmniRoute instances out of the box.
## Why this and not `@omniroute/opencode-provider`?
`@omniroute/opencode-provider` is the legacy config-generator package — it writes a frozen `provider.omniroute` block into `opencode.json` with a **hardcoded list of 8 models** ([`OMNIROUTE_DEFAULT_OPENCODE_MODELS`](https://github.com/diegosouzapw/OmniRoute/blob/main/%40omniroute/opencode-provider/src/index.ts#L48-L56)). It works on the CLI but in the **OpenCode Desktop / Web** builds (Tauri / Electron) the runtime re-runs the model picker and the static block surfaces only a few of those — and they drift behind the live OmniRoute catalog.
This plugin solves that by:
- Fetching `/v1/models` and `/api/combos` **at OpenCode startup, in Node.js** — no CORS, no WebView restrictions
- Emitting the provider block **dynamically** in the plugin's `config`/`provider` hook — so `opencode.json` only needs the plugin entry, not a static `provider.omniroute`
- Re-fetching on a configurable TTL (default 5 min), so new models / combo changes in the OmniRoute UI appear without restarting OpenCode
- Computing `limit.context` for combos as `min(member.context_length)` from the live catalog (no more `null` values that cause 4K-token truncation)
- **Auto-pickup of `interleaved` capability** for thinking models (merged via PR #3138)
**If you only have the legacy `opencode-provider` block in your `opencode.json`, replace it with a single plugin entry.** No other config changes required — the same `auth.json` API key works.
## Install
Once published to npm:
```sh
npm install @omniroute/opencode-plugin
```
Until then (or for local development), reference the built artifact directly. Either extract the package into your OpenCode plugins dir and point at the extracted `dist/index.js`:
```sh
# from inside the OmniRoute repo
cd @omniroute/opencode-plugin && npm run build && npm pack
# then extract into ~/.config/opencode/plugins/omniroute-opencode-plugin/
```
Peer dep: `@opencode-ai/plugin` (managed by your OpenCode install).
## Quick start (single instance)
```jsonc
// opencode.json
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
[
"@omniroute/opencode-plugin",
{
"providerId": "omniroute",
"baseURL": "https://or.example.com",
},
],
],
}
```
```sh
opencode auth login --provider omniroute
# prompts for the OmniRoute API key, writes to ~/.local/share/opencode/auth.json
```
> ⚠ Use the `--provider` flag explicitly. `opencode auth login omniroute` is parsed as a positional `url` argument by current OC releases (≤1.15.5) and fails with `fetch() URL is invalid`. Tracked upstream.
Restart OpenCode. `/models` lists the full live catalog. Variants (`-low`, `-medium`, `-high`, `-thinking`) and combos appear as first-class IDs — OmniRoute is the source of truth, no client-side synthesis.
## Multi-instance (prod + preprod side-by-side)
> ⚠ OC ≤1.15.5 dedupes plugin loads by absolute module path. Two `plugin:` entries pointing at the same `dist/index.js` collapse into one (last-listed options win). Workaround: install the plugin twice into separate directories so each entry resolves to a distinct module file. v0.2.x will introduce an `instances: [...]` shape that registers N providers from a single load.
### Dual-install workaround (works today on OC ≤1.15.5)
Pack the plugin once, extract it twice into named directories, then point each `plugin:` entry at its own copy:
```sh
# 1. Build + pack the plugin (run from the plugin worktree)
cd /path/to/OmniRoute/@omniroute/opencode-plugin
npm run build
npm pack
# produces omniroute-opencode-plugin-0.1.0.tgz
# 2. Extract one copy per OmniRoute endpoint
mkdir -p ~/.config/opencode/plugins/omniroute-opencode-plugin-prod
mkdir -p ~/.config/opencode/plugins/omniroute-opencode-plugin-preprod
tar -xzf omniroute-opencode-plugin-0.1.0.tgz -C ~/.config/opencode/plugins/omniroute-opencode-plugin-prod --strip-components=1
tar -xzf omniroute-opencode-plugin-0.1.0.tgz -C ~/.config/opencode/plugins/omniroute-opencode-plugin-preprod --strip-components=1
```
Then in `~/.config/opencode/opencode.json` reference each directory by absolute path:
```jsonc
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
[
"./plugins/omniroute-opencode-plugin-prod/dist/index.js",
{
"providerId": "omniroute",
"displayName": "OmniRoute",
"baseURL": "https://or.example.com",
},
],
[
"./plugins/omniroute-opencode-plugin-preprod/dist/index.js",
{
"providerId": "omniroute-preprod",
"displayName": "OmniRoute Preprod",
"baseURL": "https://or-preprod.example.com",
},
],
],
}
```
Paths are relative to `~/.config/opencode/`. Each entry now resolves to a distinct module file, so OC loads them as two separate plugin instances. Authenticate each:
```sh
opencode auth login --provider omniroute
opencode auth login --provider omniroute-preprod
```
Each entry gets its own provider id, its own model picker entry, its own slot in `auth.json`, and its own TTL cache. Closures are isolated per plugin instance — no cross-talk.
### After publish (`@omniroute/opencode-plugin` npm)
Once the package is published, the dual-install becomes two `npm install --prefix` commands instead of `tar -xzf`:
```sh
mkdir -p ~/.config/opencode/plugins/omniroute-opencode-plugin-prod
mkdir -p ~/.config/opencode/plugins/omniroute-opencode-plugin-preprod
npm install --prefix ~/.config/opencode/plugins/omniroute-opencode-plugin-prod @omniroute/opencode-plugin
npm install --prefix ~/.config/opencode/plugins/omniroute-opencode-plugin-preprod @omniroute/opencode-plugin
```
`opencode.json` paths become `./plugins/omniroute-opencode-plugin-prod/node_modules/@omniroute/opencode-plugin/dist/index.js` (and the preprod equivalent).
## Features
| Feature | What it does | Hook |
| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
| Dynamic `/v1/models` | Pulls live catalog (455+ entries on prod) on each refresh, TTL-cached | `provider.models` |
| Variants pass-through | `-low`/`-medium`/`-high`/`-thinking` ship as first-class IDs from OmniRoute (no client synthesis) | `provider.models` |
| Combo LCD aggregation | Combos appear with intersected capabilities + min context/output across members | `provider.models` + `config` |
| `combo/<slug>` namespace + `Combo: ` prefix | Combos surface under `combo/claude-primary` (not the upstream UUID) and the picker shows `Combo: claude-primary` so they stand apart from raw provider/model pairs | both hooks |
| Nice names + cost | `/api/pricing/models` display names AND `/api/pricing` per-million-token cost overlaid onto the live catalog | both hooks |
| Canonical-twin dedup + alias-fallback | `/v1/models` exposes the same upstream model under both short alias (`cc/claude-opus-4-7`) and canonical name (`claude/claude-opus-4-7`); the plugin drops the canonical twin when an alias twin exists (no duplicate rows in the picker) and reverse-maps canonical → alias to pick up enrichment for short aliases (`dg/nova-3 → Deepgram - Nova 3`) that `/api/pricing/models` only indexes by canonical | both hooks |
| Compression pipeline tags | Combo names get tagged with their compression pipeline (e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`) when `features.compressionMetadata: true`. Intensity tokens render as a traffic-light emoji: 🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra | both hooks |
| Provider-tag prefix | Prepend short upstream-provider label to enriched names (e.g. `Claude - Claude Opus 4.7` vs `Kiro - Claude Opus 4.7`, `GHM - GPT 5`) so same-id models routed via different upstream connections group visibly in the picker (default-on, opt-out via `features.providerTag: false`) | both hooks |
| Usable-only filter | Filter to providers with at least one healthy connection in `/api/providers` (opt-in via `features.usableOnly`) | both hooks |
| Disk-cache fallback | Last-known-good catalog persisted to disk; hydrates on a cold start when `/v1/models` is unreachable (default-on, opt-out via `features.diskCache: false`) | `config` |
| Bearer injection + suffix-spoof guard | Adds `Authorization` on baseURL-matched requests only | `auth.loader.fetch` |
| Gemini schema sanitization | Strips `$schema`/`$ref`/`additionalProperties` for `gemini-*`/`google-vertex-gemini/*` | `auth.loader.fetch` wrap |
| Multi-instance | Each plugin entry binds to its own `providerId`; closures isolated | factory |
| Config-hook shim | OC ≤1.15.5 fallback: writes static catalog into `config.provider[id]` (config hook is the only one that fires in `serve` mode on these versions) | `config` |
## Plugin options
| Option | Type | Default | Description |
| --------------- | -------- | ------------------------------------------ | ---------------------------------------------------------- |
| `providerId` | `string` | `"omniroute"` | OpenCode provider id; must be unique across plugin entries |
| `displayName` | `string` | `"OmniRoute"` or `OmniRoute (<id>)` | Label in the OC UI |
| `modelCacheTtl` | `number` | `300000` (5 min) | `/v1/models` TTL in ms |
| `baseURL` | `string` | resolved from `auth.json` after `/connect` | Override OmniRoute base URL |
| `features` | `object` | see below | Feature toggles (all opt-in/out, defaults preserve v0.1.0) |
### `features` block
Every field is optional. Defaults mirror v0.1.0 behaviour so existing `opencode.json` files do not need to change.
| Feature | Type | Default | What it does |
| --------------------- | --------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `combos` | `boolean` | `true` | Discover `/api/combos` and surface them as pseudo-models with LCD capabilities. Combos are keyed under the `combo/<slug>` namespace and labelled `Combo: <name>` in the model picker so they're distinguishable from raw provider/model pairs. |
| `enrichment` | `boolean` | `true` | Pull display names from `/api/pricing/models` AND per-million-token pricing (`input`, `output`, `cached``cacheRead`, `cache_creation``cacheWrite`) from `/api/pricing`, then overlay both onto the live catalog (so the UI shows `Claude 4.7 Opus` with `cost.input: 5`, `cost.output: 25` instead of raw IDs and zeroed cost). |
| `compressionMetadata` | `boolean` | `false` | Pull `/api/context/combos` so combo names get tagged with their compression pipeline, e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`. Intensity tokens render as traffic-light emoji (🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra) so the picker advertises "how compressed" each combo is at a glance. |
| `providerTag` | `boolean` | `true` | Prepend a short upstream-provider label to the enriched display name with `" - "` separator, so `cc/claude-opus-4-7 → Claude - Claude Opus 4.7` differs visibly from `kr/claude-opus-4-7 → Kiro - Claude Opus 4.7` in the OC TUI model picker. Label resolution: use `/api/pricing/models[<alias>].name` verbatim when ≤8 chars (e.g. `Claude`, `Kiro`, `Codex`, `Qwen`), otherwise fall back to `UPPER(alias)` (e.g. `GitHub Models``GHM`, `Gemini-cli``GEMINI-CLI`). Idempotent. Combos intentionally skipped (the `Combo: ` prefix already conveys multi-upstream). |
| `usableOnly` | `boolean` | `false` | Read `/api/providers` and filter the catalog to providers that have at least one connection with `isActive: true` AND `testStatus: 'active'`. Subtract-filter semantics: providers unknown to BOTH the pricing-models catalog AND the connection table pass through (so synthetic prefixes like `agentrouter/*` survive). On fetch failure the filter is disabled for the refresh — never hides the whole catalog. |
| `diskCache` | `boolean` | `true` | Persist the last successful `/v1/models` + `/api/combos` + enrichment + connections + compression snapshot to `${OPENCODE_DATA_DIR ?? ~/.local/share/opencode}/plugins/omniroute-<providerId>.json`. On a subsequent cold start where `/v1/models` throws (network down / IP whitelist drop / 5xx) the static block hydrates from the snapshot so OC's model picker survives offline. Soft-fail on read/write — never blocks publishing. |
| `geminiSanitization` | `boolean` | `true` | Strip `$schema`/`$ref`/`additionalProperties` from tool params when the model id matches `gemini` |
| `mcpAutoEmit` | `boolean` | `false` | Auto-write an `mcp.<providerId>` remote entry into the OC config pointing at `<baseURL>/api/mcp/stream` with the resolved Bearer token |
| `mcpToken` | `string` | _unset_ | Optional separate Bearer for the auto-emitted MCP entry. Falls back to the provider's `apiKey` (from `auth.json`) when unset |
| `fetchInterceptor` | `boolean` | `true` | Inject `Authorization: Bearer` + default `Content-Type` on every outbound request targeting `baseURL` (suffix-spoof guarded) |
#### Example — enrichment + compression tags + MCP auto-emit
```jsonc
{
"plugin": [
[
"@omniroute/opencode-plugin",
{
"providerId": "omniroute",
"baseURL": "https://or.example.com",
"features": {
"combos": true,
"enrichment": true,
"compressionMetadata": true,
"mcpAutoEmit": true,
},
},
],
],
}
```
With `mcpAutoEmit: true`, the plugin synthesises an `mcp.omniroute` entry equivalent to a manual:
```jsonc
"mcp": {
"omniroute": {
"type": "remote",
"url": "https://or.example.com/api/mcp/stream",
"enabled": true,
"headers": { "Authorization": "Bearer <apiKey-from-auth.json>" }
}
}
```
If you want a narrower-scoped Bearer for MCP (different from the chat/inference key), set `features.mcpToken`. Operator overrides win: if you already set `mcp.omniroute` in `opencode.json`, the plugin will not overwrite it.
#### Example — production-leaning defaults (clean picker, offline resilience)
```jsonc
{
"plugin": [
[
"@omniroute/opencode-plugin",
{
"providerId": "omniroute",
"baseURL": "https://or.example.com",
"features": {
"combos": true,
"enrichment": true,
"compressionMetadata": true,
"usableOnly": true,
"diskCache": true,
},
},
],
],
}
```
- `usableOnly: true` drops models whose canonical provider has no healthy connection in your OmniRoute instance — your `/models` picker stays focused on what you can actually call.
- `diskCache: true` (default) writes a snapshot to `${OPENCODE_DATA_DIR}/plugins/omniroute-<providerId>.json` on every healthy refresh. On a cold start where `/v1/models` is unreachable (laptop offline, IP whitelist drop), the snapshot hydrates the static block so OC still shows the catalog instead of a stub.
- `compressionMetadata: true` annotates combo display names with their pipeline using traffic-light emoji for intensity (e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`) so the picker advertises which compression each combo applies and how heavy it is at a glance. Palette: 🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra. Unknown intensities fall through to raw text (`[rtk:custom-thing]`) so the plugin never hides a value OmniRoute knows but the plugin doesn't.
- `providerTag: true` (default) prepends a short upstream-provider label so the picker shows `Claude - Claude Opus 4.7` for `cc/claude-opus-4-7`, `Kiro - Claude Opus 4.7` for `kr/claude-opus-4-7`, and `GHM - GPT 5` for `ghm/gpt-5` (slot.name `GitHub Models` > 8 chars → abbreviated). Critical when the same model id is sold through multiple upstream connections with different cost/auth/rate-limit profiles. Set to `false` to keep the pre-v3.8.3 unsuffixed format.
## Comparison vs `@omniroute/opencode-provider`
[`@omniroute/opencode-provider`](https://github.com/diegosouzapw/OmniRoute/tree/main/%40omniroute/opencode-provider) is the existing config-generator package — it writes a frozen `provider.<id>` block into `opencode.json` at build time. This plugin is the runtime integration.
| | `@omniroute/opencode-plugin` (this) | `@omniroute/opencode-provider` |
| ----------------- | ----------------------------------- | --------------------------------- |
| Type | OC plugin | Config generator (CLI/build-time) |
| Models | Live from `/v1/models` | Frozen at scaffold |
| Combos | LCD-aggregated live | None |
| Gemini sanitize | Yes | N/A |
| OC UI integration | `/connect`, `/models` | None |
| Multi-instance | Native | Manual |
Both can coexist; pick the one that fits your environment.
## Requirements
- Node `>=22.22.3` (per `engines.node`); tested on Node 22 and 24.
- OpenCode: verified end-to-end against `opencode@1.15.5` with `@opencode-ai/plugin@1.15.6`.
- OC plugin peer (`@opencode-ai/plugin`) `>=1.14.49` for the full feature set (provider hook surfaces models in `/models`). On `<=1.14.48`, the plugin falls back to its `config` hook, writing a static catalog snapshot into `config.provider[id]` so models still appear.
- The plugin uses the OC v1 plugin shape (`default: { id, server }`) — older OC releases that only walk named exports will reject it. Stay on OC ≥1.15.
## License
MIT. See [LICENSE](./LICENSE).

File diff suppressed because it is too large Load Diff

View File

@@ -1,73 +0,0 @@
{
"name": "@omniroute/opencode-plugin",
"version": "0.1.0",
"description": "OpenCode plugin for the OmniRoute AI Gateway. Drives dynamic model discovery, /connect auth flow, and multi-instance OmniRoute providers via the official @opencode-ai/plugin contract.",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"scripts": {
"build": "tsup",
"clean": "rm -rf dist",
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts",
"prepublishOnly": "npm run clean && npm run build && npm test"
},
"keywords": [
"omniroute",
"opencode",
"opencode-plugin",
"ai-sdk",
"openai-compatible",
"provider",
"gemini",
"combos",
"mcp"
],
"author": "OmniRoute contributors",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/diegosouzapw/OmniRoute.git",
"directory": "@omniroute/opencode-plugin"
},
"bugs": {
"url": "https://github.com/diegosouzapw/OmniRoute/issues"
},
"homepage": "https://github.com/diegosouzapw/OmniRoute/tree/main/%40omniroute/opencode-plugin#readme",
"engines": {
"node": ">=22.22.3"
},
"publishConfig": {
"access": "public"
},
"peerDependencies": {
"@opencode-ai/plugin": "*"
},
"dependencies": {
"zod": "^4.4.3"
},
"devDependencies": {
"@opencode-ai/plugin": "^1.15.6",
"@types/node": "^22.19.19",
"tsup": "^8.5.1",
"tsx": "^4.22.3",
"typescript": "^5.9.3"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,74 +0,0 @@
/**
* Structured logger for the OmniRoute plugin.
*
* Levels: error < warn < info < debug
* Default: warn (matches current console.warn behavior)
* Set via features.logLevel in plugin options.
*/
export type LogLevel = "error" | "warn" | "info" | "debug";
const LEVEL_ORDER: Record<LogLevel, number> = {
error: 0,
warn: 1,
info: 2,
debug: 3,
};
const TAG = "[omniroute-plugin]";
function shouldLog(current: LogLevel, target: LogLevel): boolean {
return LEVEL_ORDER[current] >= LEVEL_ORDER[target];
}
let _level: LogLevel = "warn";
export function setLogLevel(level: LogLevel): void {
_level = level;
}
export function getLogLevel(): LogLevel {
return _level;
}
function fmt(level: LogLevel, msg: string, tag?: string): string {
const prefix = tag ? `${TAG}${tag}` : TAG;
return `${prefix} [${level.toUpperCase()}] ${msg}`;
}
export const logger = {
error(msg: string, ...args: unknown[]): void {
if (shouldLog(_level, "error")) console.error(fmt("error", msg), ...args);
},
warn(msg: string, ...args: unknown[]): void {
if (shouldLog(_level, "warn")) console.warn(fmt("warn", msg), ...args);
},
info(msg: string, ...args: unknown[]): void {
if (shouldLog(_level, "info")) console.warn(fmt("info", msg), ...args);
},
debug(msg: string, ...args: unknown[]): void {
if (shouldLog(_level, "debug")) console.warn(fmt("debug", msg), ...args);
},
/** Always emit regardless of level (for critical init breadcrumbs). */
always(msg: string, ...args: unknown[]): void {
console.warn(TAG, msg, ...args);
},
// ── Tagged child loggers ──────────────────────────────────────────────
child(tag: string) {
return {
error: (msg: string, ...args: unknown[]) =>
shouldLog(_level, "error") &&
console.error(fmt("error", msg, tag), ...args),
warn: (msg: string, ...args: unknown[]) =>
shouldLog(_level, "warn") &&
console.warn(fmt("warn", msg, tag), ...args),
info: (msg: string, ...args: unknown[]) =>
shouldLog(_level, "info") &&
console.warn(fmt("info", msg, tag), ...args),
debug: (msg: string, ...args: unknown[]) =>
shouldLog(_level, "debug") &&
console.warn(fmt("debug", msg, tag), ...args),
};
},
};

View File

@@ -1,301 +0,0 @@
/**
* Universal model naming template for the OmniRoute plugin.
*
* Naming pipeline:
* [tag] <provider-label><separator><display-name><suffix>
*
* [Free] <provider> - <name> · <budget> ← free model
* Auto: <variant> (<N>p) ← auto combo
* Combo: <name> ← DB combo
* <provider> - <name> ← regular model
*/
// ── Constants ────────────────────────────────────────────────────────────
/** Separator between provider label and model display name. */
export const PROVIDER_TAG_SEPARATOR = " - ";
/** Threshold beyond which providerDisplayName is abbreviated. */
const PROVIDER_LABEL_MAX_CHARS = 12;
/** Aliases longer than this get title-case instead of UPPER. */
const ALIAS_UPPER_MAX_CHARS = 5;
// ── Auto Combo Types ─────────────────────────────────────────────────────
export type AutoVariant =
| "coding"
| "fast"
| "cheap"
| "offline"
| "smart"
| "lkgp";
export const AUTO_VARIANTS: AutoVariant[] = [
"coding",
"fast",
"cheap",
"offline",
"smart",
"lkgp",
];
export const AUTO_VARIANT_DESCRIPTIONS: Record<
AutoVariant | "default",
string
> = {
default: "Best provider via scoring",
coding: "Quality-first for code tasks",
fast: "Latency-optimized routing",
cheap: "Cost-optimized routing",
offline: "Offline-friendly providers",
smart: "Quality-first with exploration",
lkgp: "Last-Known-Good-Provider routing",
};
// ── Free Model Types ─────────────────────────────────────────────────────
export type FreeModelFreeType =
| "recurring-daily"
| "recurring-monthly"
| "recurring-credit"
| "one-time-initial"
| "keyless"
| "discontinued";
// ── Provider Label ────────────────────────────────────────────────────────
/**
* Title-case a long, lowercase-looking alias.
* `antigravity` → `Antigravity`
*/
function titleCaseAlias(alias: string): string {
if (alias.length === 0) return alias;
return alias.charAt(0).toUpperCase() + alias.slice(1).toLowerCase();
}
/**
* Pick the short label for an upstream provider.
*
* Rules:
* 1. Trim `providerDisplayName`. If ≤12 chars → use verbatim.
* 2. Alias ≤5 chars → UPPER(alias). Alias >5 → titleCase.
* 3. Neither → undefined.
*/
export function shortProviderLabel(
enrichment:
| { providerDisplayName?: string; providerAlias?: string }
| undefined,
): string | undefined {
if (!enrichment) return undefined;
const raw =
typeof enrichment.providerDisplayName === "string"
? enrichment.providerDisplayName.trim()
: "";
if (raw.length > 0 && raw.length <= PROVIDER_LABEL_MAX_CHARS) return raw;
const alias =
typeof enrichment.providerAlias === "string"
? enrichment.providerAlias.trim()
: "";
if (alias.length > 0) {
return alias.length <= ALIAS_UPPER_MAX_CHARS
? alias.toUpperCase()
: titleCaseAlias(alias);
}
// Long displayName with no alias to fall back on: keep the long label
// rather than dropping the provider prefix entirely.
return raw.length > 0 ? raw : undefined;
}
// ── Free Label ────────────────────────────────────────────────────────────
/**
* Normalise display name so free-tier models get a consistent `[Free] ` prefix.
*
* "GPT-4.1 (Free)" → "[Free] GPT-4.1"
* "DeepSeek V4 Flash Free" → "[Free] DeepSeek V4 Flash"
* "Claude Opus 4.7" → "Claude Opus 4.7" (unchanged)
*/
export function normaliseFreeLabel(name: string): string {
// Bounded whitespace quantifiers ({0,8}/{1,8}) avoid the polynomial-ReDoS
// backtracking that unbounded \s* before an anchored \s*$ would allow on
// attacker-influenced display names. 8 covers any realistic label spacing.
const cleaned = name
.replace(/\s{0,8}\(free\)\s{0,8}$/i, "")
.replace(/[\s-]{1,8}free\s{0,8}$/i, "")
.trim();
const wasFree = cleaned.length < name.trim().length;
if (!wasFree) return name;
return `[Free] ${cleaned}`;
}
// ── Free Budget Formatting ────────────────────────────────────────────────
function fmtTokens(n: number): string {
if (n >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, "") + "B";
if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, "") + "M";
if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, "") + "K";
return String(n);
}
/**
* Format a free model budget into a short human-readable suffix.
*
* recurring-daily → "25M tokens/day"
* recurring-monthly → "25M tokens/month"
* recurring-credit → "10M credits"
* one-time-initial → "1M credits (one-time)"
* keyless → "(keyless)"
* discontinued → "(discontinued)"
*/
export function formatFreeBudget(params: {
freeType: FreeModelFreeType;
monthlyTokens?: number;
creditTokens?: number;
}): string {
const { freeType, monthlyTokens = 0, creditTokens = 0 } = params;
switch (freeType) {
case "recurring-daily":
return `${fmtTokens(monthlyTokens)} tokens/day`;
case "recurring-monthly":
return `${fmtTokens(monthlyTokens)} tokens/month`;
case "recurring-credit":
return `${fmtTokens(creditTokens)} credits`;
case "one-time-initial":
return `${fmtTokens(creditTokens)} credits (one-time)`;
case "keyless":
return "(keyless)";
case "discontinued":
return "(discontinued)";
default:
return "";
}
}
// ── Auto Combo Naming ─────────────────────────────────────────────────────
/**
* Format auto combo display name.
*
* "Auto: Coding (4p)"
* "Auto: Default (6p)"
* "Auto" (no candidate count when unknown)
*/
export function formatAutoComboName(
variant: AutoVariant | undefined,
candidateCount?: number,
): string {
const label = variant
? variant.charAt(0).toUpperCase() + variant.slice(1)
: "Default";
const count =
typeof candidateCount === "number" && candidateCount > 0
? ` (${candidateCount}p)`
: "";
return `Auto: ${label}${count}`;
}
/**
* Build the model ID for an auto combo entry.
* "auto/coding", "auto/fast", "auto" (default).
*/
export function autoComboModelId(variant: AutoVariant | undefined): string {
return variant ? `auto/${variant}` : "auto";
}
// ── Universal Display Name Builder ────────────────────────────────────────
export interface ModelDisplayNameParams {
/** Raw model ID (e.g. "cc/claude-sonnet-4-6"). */
rawId: string;
/** Enrichment display name (e.g. "Claude Sonnet 4.6"). */
enrichmentName?: string;
/** Provider tag enrichment. */
providerAlias?: string;
/** Human-readable upstream provider label. */
providerDisplayName?: string;
/** Whether model is free tier. */
isFree?: boolean;
/** Free model budget info. */
freeType?: FreeModelFreeType;
/** Monthly token budget (for recurring free models). */
monthlyTokens?: number;
/** Credit token budget (for credit-based free models). */
creditTokens?: number;
/** Whether this is a combo entry (skip provider tag). */
isCombo?: boolean;
/** Whether this is an auto combo entry. */
isAutoCombo?: boolean;
/** Auto combo variant. */
autoVariant?: AutoVariant;
/** Auto combo candidate count. */
autoCandidateCount?: number;
}
/**
* Build the final display name following the universal template.
*
* Priority:
* 1. Auto combo → "Auto: <variant> (<N>p)"
* 2. DB combo → "Combo: <name>"
* 3. Free + enrichment + provider tag → "[Free] <label> - <name> · <budget>"
* 4. Free + enrichment → "[Free] <name> · <budget>"
* 5. Free + raw → "[Free] <rawId> · <budget>"
* 6. Enrichment + provider tag → "<label> - <name>"
* 7. Enrichment only → "<name>"
* 8. Raw fallback → normaliseFreeLabel(rawId)
*/
export function buildModelDisplayName(params: ModelDisplayNameParams): string {
// Auto combos
if (params.isAutoCombo) {
return formatAutoComboName(params.autoVariant, params.autoCandidateCount);
}
// Determine base name — strip any existing free suffix first
const rawBase =
params.enrichmentName && params.enrichmentName.trim().length > 0
? params.enrichmentName
: params.rawId;
const cleanedBase = rawBase
.replace(/\s*\(free\)\s*$/i, "")
.replace(/[\s-]+free\s*$/i, "")
.trim();
const wasFree = cleanedBase.length < rawBase.trim().length;
const isFree = !!params.isFree || wasFree;
let baseName = cleanedBase;
// Provider tag (skip for combos)
if (!params.isCombo) {
const label = shortProviderLabel({
providerDisplayName: params.providerDisplayName,
providerAlias: params.providerAlias,
});
if (label) {
const prefix = `${label}${PROVIDER_TAG_SEPARATOR}`;
if (!baseName.startsWith(prefix)) {
baseName = `${prefix}${baseName}`;
}
}
}
// Prepend [Free] if applicable (AFTER provider tag for correct ordering)
if (isFree) {
baseName = `[Free] ${baseName}`;
}
// Free budget suffix
if (isFree && params.freeType) {
const budget = formatFreeBudget({
freeType: params.freeType,
monthlyTokens: params.monthlyTokens,
creditTokens: params.creditTokens,
});
if (budget) {
baseName = `${baseName} · ${budget}`;
}
}
return baseName;
}

View File

@@ -1,147 +0,0 @@
/**
* T-02 auth-hook contract tests.
*
* Covers the `createOmniRouteAuthHook(opts)` factory and its loader behaviour
* against every Auth flavor (`api`, `oauth`, null, empty key). Validates the
* multi-instance fix: provider id flows from plugin options, not a module
* constant.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { createOmniRouteAuthHook } from "../src/index.js";
test("createOmniRouteAuthHook: default providerId is 'omniroute'", () => {
const hook = createOmniRouteAuthHook();
assert.equal(hook.provider, "omniroute");
});
test("createOmniRouteAuthHook: custom providerId binds to hook.provider (multi-instance)", () => {
const hook = createOmniRouteAuthHook({ providerId: "omniroute-preprod" });
assert.equal(hook.provider, "omniroute-preprod");
});
test("createOmniRouteAuthHook: methods[0] is type 'api' with label including displayName", () => {
const hook = createOmniRouteAuthHook();
assert.equal(Array.isArray(hook.methods), true);
assert.equal(hook.methods.length, 1);
const m = hook.methods[0];
assert.equal(m.type, "api");
assert.equal(m.label, "OmniRoute API Key");
const custom = createOmniRouteAuthHook({ providerId: "omniroute-preprod" });
assert.equal(custom.methods[0].label, "OmniRoute (omniroute-preprod) API Key");
});
test("createOmniRouteAuthHook: prompts[0] uses key='apiKey' per @opencode-ai/plugin contract", () => {
// NOTE: spec referenced `name: "apiKey"`; the official
// @opencode-ai/plugin@1.15.6 prompt shape uses `key` + `message` (no
// `name`/`label`/`mask` fields). Asserting against the real type contract.
const hook = createOmniRouteAuthHook();
const m = hook.methods[0];
assert.equal(m.type, "api");
// narrow: api method may carry prompts
const prompts = "prompts" in m ? m.prompts : undefined;
assert.ok(Array.isArray(prompts) && prompts.length === 1, "expected one prompt");
const p = prompts![0];
assert.equal(p.type, "text");
assert.equal((p as { key: string }).key, "apiKey");
assert.ok(
typeof (p as { message: string }).message === "string" &&
(p as { message: string }).message.includes("omniroute"),
"prompt message should mention provider id"
);
});
test("loader: valid api auth → {apiKey} when no baseURL option (T-04: fetch omitted)", async () => {
// T-04 changed the loader return shape: without a resolvable baseURL the
// interceptor cannot gate-keep requests, so the loader falls back to
// apiKey-only and the AI-SDK uses its default fetch. See fetch-interceptor
// tests for the wired-fetch branches.
const hook = createOmniRouteAuthHook();
assert.ok(hook.loader, "loader must be defined");
const result = await hook.loader!(
async () => ({ type: "api", key: "sk-test" }) as never,
{} as never
);
assert.deepEqual(result, { apiKey: "sk-test" });
});
test("loader: valid api auth → {apiKey, baseURL, fetch} when baseURL option set (T-04)", async () => {
const hook = createOmniRouteAuthHook({ baseURL: "https://or.example.com/v1" });
const result = await hook.loader!(
async () => ({ type: "api", key: "sk-x" }) as never,
{} as never
);
assert.equal((result as { apiKey: string }).apiKey, "sk-x");
assert.equal((result as { baseURL: string }).baseURL, "https://or.example.com/v1");
assert.equal(
typeof (result as { fetch?: unknown }).fetch,
"function",
"T-04: loader must wire fetch interceptor when baseURL resolves"
);
});
test("loader: features.fetchInterceptor=false AND geminiSanitization=false → no custom fetch (flags honored)", async () => {
// Regression: both fetch-layer flags were documented + schema-validated but
// silently ignored. Disabling both must fall back to the SDK default fetch.
const hook = createOmniRouteAuthHook({
baseURL: "https://or.example.com/v1",
features: { fetchInterceptor: false, geminiSanitization: false },
});
const result = await hook.loader!(
async () => ({ type: "api", key: "sk-x" }) as never,
{} as never
);
assert.deepEqual(result, { apiKey: "sk-x", baseURL: "https://or.example.com/v1" });
assert.equal(
(result as { fetch?: unknown }).fetch,
undefined,
"both flags off must omit the custom fetch"
);
});
test("loader: features.fetchInterceptor=false but geminiSanitization=true → fetch still wired (sanitizer only)", async () => {
const hook = createOmniRouteAuthHook({
baseURL: "https://or.example.com/v1",
features: { fetchInterceptor: false, geminiSanitization: true },
});
const result = await hook.loader!(
async () => ({ type: "api", key: "sk-x" }) as never,
{} as never
);
assert.equal(
typeof (result as { fetch?: unknown }).fetch,
"function",
"geminiSanitization alone must still provide a fetch wrapper"
);
});
test("loader: null/undefined auth → {} (no creds yet, OC surfaces /connect)", async () => {
const hook = createOmniRouteAuthHook();
const r1 = await hook.loader!(async () => null as never, {} as never);
assert.deepEqual(r1, {});
const r2 = await hook.loader!(async () => undefined as never, {} as never);
assert.deepEqual(r2, {});
});
test("loader: oauth-flavored auth → {} (wrong method type, ignored)", async () => {
const hook = createOmniRouteAuthHook();
const result = await hook.loader!(
async () =>
({
type: "oauth",
refresh: "r",
access: "a",
expires: 0,
}) as never,
{} as never
);
assert.deepEqual(result, {});
});
test("loader: api auth with empty key → {} (empty creds rejected)", async () => {
const hook = createOmniRouteAuthHook();
const result = await hook.loader!(async () => ({ type: "api", key: "" }) as never, {} as never);
assert.deepEqual(result, {});
});

View File

@@ -1,74 +0,0 @@
/**
* TDD regression — auto combos must never advertise `limit.context: 0`.
*
* opencode's overflow guard (packages/opencode/src/session/overflow.ts)
* short-circuits when `model.limit.context === 0`:
*
* if (input.model.limit.context === 0) return false // never overflow
*
* so a zero context silently DISABLES opencode's smart auto-compaction for
* auto combos. The session then grows unbounded until OmniRoute's
* server-side purifyHistory() destructively drops old messages — the
* "coding agent keeps forgetting things" bug.
*
* Fix under test: mapAutoComboToStaticEntry consumes the context_length /
* max_output_tokens now served by GET /api/combos/auto, and falls back to a
* safe positive default (128000 / 8192) for older servers that do not send
* the fields yet.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { mapAutoComboToStaticEntry } from "../src/index.ts";
import type { OmniRouteRawAutoCombo } from "../src/index.ts";
test("uses server-provided context_length and max_output_tokens", () => {
const raw = {
id: "auto/coding",
name: "Auto Coding",
variant: "coding",
candidateCount: 5,
context_length: 1048576,
max_output_tokens: 65536,
} as OmniRouteRawAutoCombo;
const entry = mapAutoComboToStaticEntry(raw);
assert.equal(entry.limit?.context, 1048576);
assert.equal(entry.limit?.output, 65536);
});
test("falls back to a safe positive default when the server omits limits (old servers)", () => {
const raw = {
id: "auto",
name: "Auto",
candidateCount: 3,
} as OmniRouteRawAutoCombo;
const entry = mapAutoComboToStaticEntry(raw);
assert.ok(
typeof entry.limit?.context === "number" && entry.limit.context > 0,
`context must be a positive number (never 0 — zero disables opencode auto-compaction), got ${entry.limit?.context}`
);
assert.ok(
typeof entry.limit?.output === "number" && entry.limit.output > 0,
`output must be a positive number, got ${entry.limit?.output}`
);
});
test("ignores non-positive server values and keeps the safe fallback", () => {
const raw = {
id: "auto/fast",
name: "Auto Fast",
variant: "fast",
candidateCount: 2,
context_length: 0,
max_output_tokens: -1,
} as OmniRouteRawAutoCombo;
const entry = mapAutoComboToStaticEntry(raw);
assert.ok(
typeof entry.limit?.context === "number" && entry.limit.context > 0,
"zero/negative server values must not propagate"
);
assert.ok(typeof entry.limit?.output === "number" && entry.limit.output > 0);
});

View File

@@ -1,641 +0,0 @@
/**
* T-05 combo-discovery contract tests.
*
* Covers:
* - `defaultOmniRouteCombosFetcher(baseURL, apiKey, timeoutMs?)`
* — envelope tolerance (`{combos: [...]}` and bare array), non-2xx errors.
* - `mapComboToModelV2(combo, members, providerId, baseURL)`
* — LCD policy across capabilities, limits, modalities; defensive
* posture on empty members; nice-name preference.
* - `createOmniRouteProviderHook(opts, deps)` extension
* — combos merged into the models map; collision resolution (combo
* wins, warn-once); soft-fail when the combos fetcher throws;
* combos cached + reused under the same TTL key as models.
*
* Mocking strategy mirrors `provider.test.ts`: both fetchers are
* dependency-injected at hook construction, no `fetch` monkey-patch.
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
createOmniRouteProviderHook,
defaultOmniRouteCombosFetcher,
mapComboToModelV2,
type OmniRouteCombosFetcher,
type OmniRouteModelsFetcher,
type OmniRouteRawCombo,
type OmniRouteRawModelEntry,
} from "../src/index.js";
// ────────────────────────────────────────────────────────────────────────────
// Fixtures
// ────────────────────────────────────────────────────────────────────────────
const MODEL_PRIMARY: OmniRouteRawModelEntry = {
id: "claude-primary",
capabilities: {
tool_calling: true,
reasoning: true,
vision: true,
thinking: true,
temperature: true,
},
context_length: 200_000,
max_output_tokens: 64_000,
max_input_tokens: 180_000,
input_modalities: ["text", "image"],
output_modalities: ["text"],
};
const MODEL_SECONDARY: OmniRouteRawModelEntry = {
id: "claude-secondary",
capabilities: {
tool_calling: true,
reasoning: false,
vision: true,
thinking: false,
temperature: true,
},
context_length: 100_000,
max_output_tokens: 32_000,
max_input_tokens: 96_000,
input_modalities: ["text", "image"],
output_modalities: ["text"],
};
const MODEL_NO_TOOLS: OmniRouteRawModelEntry = {
id: "gemini-3-flash",
capabilities: { tool_calling: false, reasoning: false, vision: false, thinking: false },
context_length: 1_000_000,
max_output_tokens: 8_192,
input_modalities: ["text"],
output_modalities: ["text"],
};
const COMBO_CLAUDE_TIER: OmniRouteRawCombo = {
id: "combo-claude-tier",
name: "Claude Tier",
strategy: "priority",
models: [
{ id: "s1", kind: "model", model: "claude-primary", weight: 100 },
{ id: "s2", kind: "model", model: "claude-secondary", weight: 80 },
],
};
// ────────────────────────────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────────────────────────────
function stubModelsFetcher(
payload: OmniRouteRawModelEntry[]
): OmniRouteModelsFetcher & { callCount: () => number } {
let n = 0;
const f: OmniRouteModelsFetcher = async () => {
n++;
return payload;
};
return Object.assign(f, { callCount: () => n });
}
function stubCombosFetcher(
payload: OmniRouteRawCombo[]
): OmniRouteCombosFetcher & { callCount: () => number; callsBy: () => Array<[string, string]> } {
let n = 0;
const calls: Array<[string, string]> = [];
const f: OmniRouteCombosFetcher = async (baseURL, apiKey) => {
n++;
calls.push([baseURL, apiKey]);
return payload;
};
return Object.assign(f, {
callCount: () => n,
callsBy: () => calls,
});
}
function failingCombosFetcher(
err = new Error("boom")
): OmniRouteCombosFetcher & { callCount: () => number } {
let n = 0;
const f: OmniRouteCombosFetcher = async () => {
n++;
throw err;
};
return Object.assign(f, { callCount: () => n });
}
const apiAuth = (key: string): unknown => ({ type: "api", key });
// Capture console.warn invocations for the duration of a callback, then
// restore the original. Needed because the collision + soft-fail paths
// emit warnings we want to assert on.
async function withWarnCapture<T>(
fn: (warnings: Array<{ args: unknown[] }>) => Promise<T>
): Promise<{ result: T; warnings: Array<{ args: unknown[] }> }> {
const original = console.warn;
const warnings: Array<{ args: unknown[] }> = [];
console.warn = (...args: unknown[]) => {
warnings.push({ args });
};
try {
const result = await fn(warnings);
return { result, warnings };
} finally {
console.warn = original;
}
}
// ────────────────────────────────────────────────────────────────────────────
// defaultOmniRouteCombosFetcher — envelope tolerance + error surfacing
// ────────────────────────────────────────────────────────────────────────────
test("defaultOmniRouteCombosFetcher: parses {combos:[…]} envelope", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: unknown) => {
const url = typeof input === "string" ? input : (input as { url: string }).url;
assert.equal(url, "https://or.example.com/api/combos");
return new Response(
JSON.stringify({
combos: [
{ id: "c1", name: "Combo One", strategy: "priority", models: [] },
{ id: "c2", name: "Combo Two", strategy: "weighted", models: [] },
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}) as typeof fetch;
try {
const combos = await defaultOmniRouteCombosFetcher("https://or.example.com", "sk-test");
assert.equal(combos.length, 2);
assert.equal(combos[0].id, "c1");
assert.equal(combos[1].id, "c2");
} finally {
globalThis.fetch = originalFetch;
}
});
test("defaultOmniRouteCombosFetcher: parses bare array envelope", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
return new Response(JSON.stringify([{ id: "c1" }, { id: "c2" }, { not_an_id: 42 }]), {
status: 200,
});
}) as typeof fetch;
try {
const combos = await defaultOmniRouteCombosFetcher("https://or.example.com/v1", "sk-test");
// Strip /v1 before /api/combos, AND filter out entries with no string id.
assert.equal(combos.length, 2);
assert.equal(combos[0].id, "c1");
assert.equal(combos[1].id, "c2");
} finally {
globalThis.fetch = originalFetch;
}
});
test("defaultOmniRouteCombosFetcher: strips trailing /v1 before /api/combos", async () => {
const originalFetch = globalThis.fetch;
let observedUrl = "";
globalThis.fetch = (async (input: unknown) => {
observedUrl = typeof input === "string" ? input : (input as { url: string }).url;
return new Response(JSON.stringify({ combos: [] }), { status: 200 });
}) as typeof fetch;
try {
await defaultOmniRouteCombosFetcher("https://or.example.com/v1/", "sk-test");
assert.equal(observedUrl, "https://or.example.com/api/combos");
} finally {
globalThis.fetch = originalFetch;
}
});
test("defaultOmniRouteCombosFetcher: throws on non-2xx with status code in message", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
return new Response(JSON.stringify({ error: "Invalid token" }), {
status: 403,
statusText: "Forbidden",
});
}) as typeof fetch;
try {
await assert.rejects(
async () => {
await defaultOmniRouteCombosFetcher("https://or.example.com", "sk-bad");
},
(err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
assert.match(msg, /403/, "status code must appear in message");
assert.match(msg, /\/api\/combos/, "url must appear in message");
return true;
}
);
} finally {
globalThis.fetch = originalFetch;
}
});
test("defaultOmniRouteCombosFetcher: throws when apiKey missing", async () => {
await assert.rejects(
async () => defaultOmniRouteCombosFetcher("https://or.example.com", ""),
/apiKey required/
);
});
test("defaultOmniRouteCombosFetcher: throws when baseURL missing", async () => {
await assert.rejects(
async () => defaultOmniRouteCombosFetcher("", "sk-test"),
/baseURL required/
);
});
// ────────────────────────────────────────────────────────────────────────────
// mapComboToModelV2 — LCD semantics
// ────────────────────────────────────────────────────────────────────────────
test("mapComboToModelV2: empty members → capabilities all false (defensive)", () => {
const m = mapComboToModelV2(
{ id: "combo-empty", name: "Empty Combo" },
[],
"omniroute",
"https://or.example.com/v1"
);
assert.equal(m.id, "combo-empty");
assert.equal(m.name, "Empty Combo");
assert.equal(m.capabilities.temperature, false);
assert.equal(m.capabilities.reasoning, false);
assert.equal(m.capabilities.attachment, false);
assert.equal(m.capabilities.toolcall, false);
assert.equal(m.capabilities.input.text, false);
assert.equal(m.capabilities.output.text, false);
assert.equal(m.limit.context, 0);
assert.equal(m.limit.output, 0);
assert.equal(m.limit.input, undefined);
assert.deepEqual(m.cost, { input: 0, output: 0, cache: { read: 0, write: 0 } });
});
test("mapComboToModelV2: all members reasoning=true → combo reasoning=true", () => {
const m = mapComboToModelV2(
{ id: "c", models: [] },
[
MODEL_PRIMARY,
{
...MODEL_PRIMARY,
id: "p2",
capabilities: { ...MODEL_PRIMARY.capabilities, thinking: false, reasoning: true },
},
],
"omniroute",
"https://or.example.com/v1"
);
assert.equal(m.capabilities.reasoning, true);
});
test("mapComboToModelV2: any member reasoning=false → combo reasoning=false", () => {
const m = mapComboToModelV2(
{ id: "c", models: [] },
[MODEL_PRIMARY, MODEL_NO_TOOLS], // gemini-3-flash has reasoning:false, thinking:false
"omniroute",
"https://or.example.com/v1"
);
assert.equal(m.capabilities.reasoning, false);
});
test("mapComboToModelV2: limit.context is min of members'", () => {
const m = mapComboToModelV2(
{ id: "c", models: [] },
[MODEL_PRIMARY, MODEL_SECONDARY, MODEL_NO_TOOLS],
"omniroute",
"https://or.example.com/v1"
);
// min(200_000, 100_000, 1_000_000) = 100_000
assert.equal(m.limit.context, 100_000);
// min(64_000, 32_000, 8_192) = 8_192
assert.equal(m.limit.output, 8_192);
});
test("mapComboToModelV2: limit.input only emitted when EVERY member declares one", () => {
const m1 = mapComboToModelV2(
{ id: "c", models: [] },
[MODEL_PRIMARY, MODEL_SECONDARY],
"omniroute",
"https://or.example.com/v1"
);
// Both declare max_input_tokens → limit.input = min(180000, 96000)
assert.equal(m1.limit.input, 96_000);
const m2 = mapComboToModelV2(
{ id: "c", models: [] },
[MODEL_PRIMARY, MODEL_NO_TOOLS], // gemini-3-flash doesn't declare max_input_tokens
"omniroute",
"https://or.example.com/v1"
);
assert.equal(m2.limit.input, undefined);
});
test("mapComboToModelV2: nice name preferred from combo.name", () => {
const m1 = mapComboToModelV2(
{ id: "combo-x", name: "Pretty Name" },
[MODEL_PRIMARY],
"omniroute",
"https://or.example.com/v1"
);
assert.equal(m1.name, "Pretty Name");
// Falls back to id when name is absent or empty.
const m2 = mapComboToModelV2(
{ id: "combo-y" },
[MODEL_PRIMARY],
"omniroute",
"https://or.example.com/v1"
);
assert.equal(m2.name, "combo-y");
const m3 = mapComboToModelV2(
{ id: "combo-z", name: " " },
[MODEL_PRIMARY],
"omniroute",
"https://or.example.com/v1"
);
assert.equal(m3.name, "combo-z");
});
test("mapComboToModelV2: attachment AND vision flag both honored across members", () => {
// MODEL_PRIMARY: vision=true; MODEL_SECONDARY: vision=true → combo attachment=true
const yes = mapComboToModelV2(
{ id: "c1", models: [] },
[MODEL_PRIMARY, MODEL_SECONDARY],
"omniroute",
"https://or.example.com/v1"
);
assert.equal(yes.capabilities.attachment, true);
// Add a member with no vision/attachment → AND collapses to false
const no = mapComboToModelV2(
{ id: "c2", models: [] },
[MODEL_PRIMARY, MODEL_NO_TOOLS],
"omniroute",
"https://or.example.com/v1"
);
assert.equal(no.capabilities.attachment, false);
});
test("mapComboToModelV2: modalities AND'd across members", () => {
const m = mapComboToModelV2(
{ id: "c", models: [] },
[MODEL_PRIMARY, MODEL_SECONDARY], // both have text+image
"omniroute",
"https://or.example.com/v1"
);
assert.equal(m.capabilities.input.text, true);
assert.equal(m.capabilities.input.image, true);
assert.equal(m.capabilities.input.audio, false);
// Add a text-only member → image collapses to false.
const m2 = mapComboToModelV2(
{ id: "c", models: [] },
[MODEL_PRIMARY, MODEL_NO_TOOLS],
"omniroute",
"https://or.example.com/v1"
);
assert.equal(m2.capabilities.input.text, true);
assert.equal(m2.capabilities.input.image, false);
});
test("mapComboToModelV2: api block matches providerId + baseURL", () => {
const m = mapComboToModelV2(
{ id: "c" },
[MODEL_PRIMARY],
"omniroute-preprod",
"https://or-preprod.example.com/v1"
);
assert.equal(m.providerID, "omniroute-preprod");
assert.equal(m.api.id, "openai-compatible");
assert.equal(m.api.url, "https://or-preprod.example.com/v1");
assert.equal(m.api.npm, "@ai-sdk/openai-compatible");
assert.equal(m.status, "active");
});
test("mapComboToModelV2: explicit member temperature=false drops combo temperature=false", () => {
const tempFalse: OmniRouteRawModelEntry = {
id: "no-temp",
capabilities: { tool_calling: true, temperature: false },
context_length: 100_000,
max_output_tokens: 8_000,
input_modalities: ["text"],
output_modalities: ["text"],
};
const m = mapComboToModelV2(
{ id: "c" },
[MODEL_PRIMARY, tempFalse],
"omniroute",
"https://or.example.com/v1"
);
assert.equal(m.capabilities.temperature, false);
});
// ────────────────────────────────────────────────────────────────────────────
// createOmniRouteProviderHook — combos merge + collision + soft-fail + cache
// ────────────────────────────────────────────────────────────────────────────
test("models() returns combo entries merged into the map", async () => {
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY, MODEL_SECONDARY, MODEL_NO_TOOLS]);
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
const hook = createOmniRouteProviderHook(
{ baseURL: "https://or.example.com/v1" },
{ fetcher: modelsFetcher, combosFetcher }
);
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
// 3 raw models + 1 combo = 4 entries
assert.equal(Object.keys(out).length, 4);
assert.ok(out["claude-primary"]);
assert.ok(out["claude-secondary"]);
assert.ok(out["gemini-3-flash"]);
assert.ok(out["combo/claude-tier"]);
const combo = out["combo/claude-tier"];
assert.equal(combo.name, "Combo: Claude Tier");
assert.equal(combo.providerID, "omniroute");
// LCD over claude-primary (200k, reasoning) + claude-secondary (100k, no reasoning)
assert.equal(combo.limit.context, 100_000);
assert.equal(combo.capabilities.reasoning, false);
assert.equal(combo.capabilities.toolcall, true);
});
test("models(): combo with unknown member ids degrades to all-false LCD posture", async () => {
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY]); // catalog only has claude-primary
const combosFetcher = stubCombosFetcher([
{
id: "phantom",
name: "Phantom Combo",
models: [
{ id: "s1", kind: "model", model: "does-not-exist-1", weight: 50 },
{ id: "s2", kind: "model", model: "does-not-exist-2", weight: 50 },
],
},
]);
const hook = createOmniRouteProviderHook(
{ baseURL: "https://or.example.com/v1" },
{ fetcher: modelsFetcher, combosFetcher }
);
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
assert.ok(out["combo/phantom-combo"]);
// With zero resolvable members, LCD = all-false (defensive posture).
assert.equal(out["combo/phantom-combo"].capabilities.toolcall, false);
assert.equal(out["combo/phantom-combo"].capabilities.reasoning, false);
assert.equal(out["combo/phantom-combo"].limit.context, 0);
});
test("models(): hidden combos are excluded from the map", async () => {
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY]);
const combosFetcher = stubCombosFetcher([
{
id: "visible",
name: "Visible",
models: [{ id: "s1", kind: "model", model: "claude-primary", weight: 100 }],
},
{
id: "hidden",
name: "Hidden",
isHidden: true,
models: [{ id: "s1", kind: "model", model: "claude-primary", weight: 100 }],
},
]);
const hook = createOmniRouteProviderHook(
{ baseURL: "https://or.example.com/v1" },
{ fetcher: modelsFetcher, combosFetcher }
);
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
assert.ok(out["combo/visible"]);
assert.ok(!out["combo/hidden"], "hidden combo must be omitted");
});
test("models(): combo name exactly matches raw model id → raw deleted, combo lives at combo/ key, no warn", async () => {
// Combo.name === raw model id triggers the dedup deletion. This mirrors
// the real OmniRoute payload where /v1/models pre-mirrors combos as
// no-slash raw entries whose ids match /api/combos friendly names.
const colliderCombo: OmniRouteRawCombo = {
id: "uuid-collider",
name: "claude-primary", // EXACT match to MODEL_PRIMARY.id
models: [{ id: "s1", kind: "model", model: "claude-secondary", weight: 100 }],
};
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY, MODEL_SECONDARY]);
const combosFetcher = stubCombosFetcher([colliderCombo]);
const hook = createOmniRouteProviderHook(
{ baseURL: "https://or.example.com/v1" },
{ fetcher: modelsFetcher, combosFetcher }
);
const { result: out, warnings } = await withWarnCapture(async (_w) => {
return hook.models!({} as never, { auth: apiAuth("sk-z") as never });
});
// Raw model deleted by combo-name dedup; combo surfaces under combo/<slug>.
assert.equal(out["claude-primary"], undefined, "raw deleted by combo-name dedup");
assert.ok(out["combo/claude-primary"], "combo surfaces under combo/ namespace");
assert.equal(out["combo/claude-primary"].name, "Combo: claude-primary");
// No collision warning fires — dedup makes keys disjoint.
const collisionWarns = warnings.filter((w) => {
const msg = w.args[0];
return typeof msg === "string" && msg.includes("collides");
});
assert.equal(collisionWarns.length, 0, "no collision warn after dedup");
});
test("models(): two combos with same slug → second gets disambiguator suffix", async () => {
// Both combos slug to `claude` — second must get `combo/claude-<id-prefix>`.
const combos: OmniRouteRawCombo[] = [
{
id: "uuid-a",
name: "Claude",
models: [{ id: "s", kind: "model", model: "claude-primary", weight: 1 }],
},
{
id: "uuid-b",
name: "Claude",
models: [{ id: "s", kind: "model", model: "claude-secondary", weight: 1 }],
},
];
const hook = createOmniRouteProviderHook(
{ baseURL: "https://or.example.com/v1" },
{
fetcher: stubModelsFetcher([MODEL_PRIMARY, MODEL_SECONDARY]),
combosFetcher: stubCombosFetcher(combos),
}
);
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
// First combo gets the bare slug; second gets disambiguated.
assert.ok(out["combo/claude"], "first combo at bare slug");
assert.ok(out["combo/claude-uuid"], "second combo disambiguated by id prefix");
});
test("models(): combos fetch fails → falls back to models-only, warn emitted, no throw", async () => {
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY, MODEL_SECONDARY]);
const combosFetcher = failingCombosFetcher(new Error("ECONNRESET"));
const hook = createOmniRouteProviderHook(
{ baseURL: "https://or.example.com/v1" },
{ fetcher: modelsFetcher, combosFetcher }
);
const { result: out, warnings } = await withWarnCapture(async () => {
return hook.models!({} as never, { auth: apiAuth("sk-z") as never });
});
// Catalog includes the models but NOT any combo entries.
assert.equal(Object.keys(out).length, 2);
assert.ok(out["claude-primary"]);
assert.ok(out["claude-secondary"]);
// Soft-fail warning surfaced.
const softFail = warnings.find((w) => {
const msg = w.args[0];
return typeof msg === "string" && msg.includes("combos fetch failed");
});
assert.ok(softFail, "soft-fail warning must be emitted on combos fetch error");
assert.equal(combosFetcher.callCount(), 1);
});
test("models(): combos cached + reused within TTL (one combo fetch per TTL window)", async () => {
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY, MODEL_SECONDARY]);
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
let nowMs = 1_000_000;
const hook = createOmniRouteProviderHook(
{ baseURL: "https://or.example.com/v1", modelCacheTtl: 60_000 },
{ fetcher: modelsFetcher, combosFetcher, now: () => nowMs }
);
await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
nowMs += 30_000; // half the TTL
const second = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
assert.equal(combosFetcher.callCount(), 1, "combos fetched only once within TTL");
assert.equal(modelsFetcher.callCount(), 1, "models fetched only once within TTL");
assert.ok(second["combo/claude-tier"]);
});
test("models(): combos refetched after TTL expiry (same key as models)", async () => {
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY]);
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
let nowMs = 1_000_000;
const hook = createOmniRouteProviderHook(
{ baseURL: "https://or.example.com/v1", modelCacheTtl: 60_000 },
{ fetcher: modelsFetcher, combosFetcher, now: () => nowMs }
);
await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
nowMs += 60_001;
await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
assert.equal(combosFetcher.callCount(), 2, "combos must refetch past TTL");
assert.equal(modelsFetcher.callCount(), 2, "models must refetch past TTL");
});
test("models(): combos fetcher receives the resolved baseURL + apiKey", async () => {
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY]);
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
const hook = createOmniRouteProviderHook(
{ baseURL: "https://or.example.com/v1" },
{ fetcher: modelsFetcher, combosFetcher }
);
await hook.models!({} as never, { auth: apiAuth("sk-spy") as never });
assert.deepEqual(combosFetcher.callsBy()[0], ["https://or.example.com/v1", "sk-spy"]);
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,66 +0,0 @@
/**
* Regression test for the disk-snapshot file permissions (release/v3.8.2
* review finding C2). The snapshot embeds provider topology + connection
* records and lives alongside auth.json (0o600), so it must NOT be readable by
* group/other. Before the fix it was written with the default (typically
* world-readable 0o644) mode.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
defaultDiskSnapshotWriter,
diskSnapshotPath,
type OmniRouteFetchCacheEntry,
} from "../src/index.js";
function makeEntry(): Omit<OmniRouteFetchCacheEntry, "expiresAt"> {
return {
rawModels: [],
rawCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
};
}
test("defaultDiskSnapshotWriter writes an owner-only (no group/other) snapshot", async (t) => {
// POSIX-only assertion; Windows does not honor numeric file modes.
if (process.platform === "win32") {
t.skip("file mode semantics are POSIX-only");
return;
}
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-disk-perms-"));
const prevDataDir = process.env.OPENCODE_DATA_DIR;
process.env.OPENCODE_DATA_DIR = tmp;
try {
await defaultDiskSnapshotWriter("perm-test", makeEntry());
const file = diskSnapshotPath("perm-test");
assert.ok(fs.existsSync(file), "snapshot file should be written");
const fileMode = fs.statSync(file).mode & 0o777;
assert.equal(
fileMode & 0o077,
0,
`snapshot must not be group/other accessible (got ${fileMode.toString(8)})`
);
const dirMode = fs.statSync(path.dirname(file)).mode & 0o777;
assert.equal(
dirMode & 0o077,
0,
`plugins dir must not be group/other accessible (got ${dirMode.toString(8)})`
);
} finally {
if (prevDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = prevDataDir;
fs.rmSync(tmp, { recursive: true, force: true });
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,269 +0,0 @@
/**
* T-04 fetch-interceptor contract tests.
*
* Covers `createOmniRouteFetchInterceptor` (URL-prefix gating, header merge,
* Content-Type defaulting, input-shape polymorphism) plus the loader
* integration that wires it into the AuthHook return shape.
*
* Strategy: replace `globalThis.fetch` with a closure-based recorder for the
* duration of each test (saved-and-restored in try/finally — node:test has
* no built-in spy/restore lifecycle). The recorder captures `(input, init)`
* as observed by the wrapped global call so we can assert on what was
* forwarded after header injection.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { createOmniRouteAuthHook, createOmniRouteFetchInterceptor } from "../src/index.js";
type FetchCall = { input: Parameters<typeof fetch>[0]; init?: RequestInit };
function installFetchRecorder(response: Response = new Response("ok")) {
const calls: FetchCall[] = [];
const original = globalThis.fetch;
globalThis.fetch = (async (input: any, init?: any) => {
calls.push({ input, init });
return response;
}) as typeof fetch;
const restore = () => {
globalThis.fetch = original;
};
return { calls, restore };
}
const BASE = "https://or.example.com/v1";
const KEY = "sk-test-fetch";
test("createOmniRouteFetchInterceptor: targets baseURL → Authorization header injected", async () => {
const { calls, restore } = installFetchRecorder();
try {
const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE });
await f(`${BASE}/chat/completions`, {
method: "POST",
body: JSON.stringify({ x: 1 }),
});
assert.equal(calls.length, 1);
const sent = calls[0]!;
const sentHeaders = new Headers((sent.init as RequestInit).headers);
assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`);
} finally {
restore();
}
});
test("createOmniRouteFetchInterceptor: targets baseURL → Authorization OVERRIDES caller-supplied Bearer", async () => {
const { calls, restore } = installFetchRecorder();
try {
const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE });
await f(`${BASE}/chat/completions`, {
method: "POST",
body: "{}",
headers: { Authorization: "Bearer attacker-key" },
});
const sent = calls[0]!;
const sentHeaders = new Headers((sent.init as RequestInit).headers);
// We own the apiKey for this provider — caller-supplied Bearer must lose.
assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`);
} finally {
restore();
}
});
test("createOmniRouteFetchInterceptor: targets baseURL + body → Content-Type defaults to application/json", async () => {
const { calls, restore } = installFetchRecorder();
try {
const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE });
await f(`${BASE}/chat/completions`, {
method: "POST",
body: JSON.stringify({ m: "x" }),
});
const sent = calls[0]!;
const sentHeaders = new Headers((sent.init as RequestInit).headers);
assert.equal(sentHeaders.get("Content-Type"), "application/json");
} finally {
restore();
}
});
test("createOmniRouteFetchInterceptor: caller-set Content-Type is NOT overwritten", async () => {
const { calls, restore } = installFetchRecorder();
try {
const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE });
await f(`${BASE}/v2/whatever`, {
method: "POST",
body: "raw",
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
const sent = calls[0]!;
const sentHeaders = new Headers((sent.init as RequestInit).headers);
assert.equal(sentHeaders.get("Content-Type"), "text/plain; charset=utf-8");
} finally {
restore();
}
});
test("createOmniRouteFetchInterceptor: non-baseURL host → passthrough, no Authorization injected", async () => {
const { calls, restore } = installFetchRecorder();
try {
const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE });
await f("https://third-party.example.org/v1/chat", {
method: "POST",
body: "{}",
headers: { "X-Caller": "yes" },
});
const sent = calls[0]!;
// Init forwarded verbatim — no header injection.
const sentHeaders = new Headers((sent.init as RequestInit | undefined)?.headers);
assert.equal(sentHeaders.get("Authorization"), null, "MUST NOT leak apiKey");
assert.equal(sentHeaders.get("X-Caller"), "yes");
} finally {
restore();
}
});
test("createOmniRouteFetchInterceptor: refuses suffix-spoof — `${base}-attacker.evil` does NOT match baseURL", async () => {
const { calls, restore } = installFetchRecorder();
try {
const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE });
// baseURL is `https://or.example.com/v1`. A spoofed
// `https://or.example.com/v1-attacker.evil/chat` shares the literal prefix
// but is NOT under our origin path — must be treated as passthrough.
await f("https://or.example.com/v1-attacker.evil/chat", {
method: "POST",
body: "{}",
});
const sent = calls[0]!;
const sentHeaders = new Headers((sent.init as RequestInit | undefined)?.headers);
assert.equal(sentHeaders.get("Authorization"), null);
} finally {
restore();
}
});
test("createOmniRouteFetchInterceptor: URL object input is handled", async () => {
const { calls, restore } = installFetchRecorder();
try {
const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE });
await f(new URL(`${BASE}/models`), {});
const sent = calls[0]!;
const sentHeaders = new Headers((sent.init as RequestInit).headers);
assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`);
} finally {
restore();
}
});
test("createOmniRouteFetchInterceptor: Request input is handled (reads .url)", async () => {
const { calls, restore } = installFetchRecorder();
try {
const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE });
const req = new Request(`${BASE}/chat/completions`, {
method: "POST",
body: JSON.stringify({ a: 1 }),
headers: { "X-Caller": "preserved" },
});
await f(req);
const sent = calls[0]!;
// The interceptor forwards the original Request as `input` but layers our
// headers into the `init`. We assert against the init view since fetch()
// resolves headers from init first when both are present.
const sentHeaders = new Headers((sent.init as RequestInit).headers);
assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`);
assert.equal(
sentHeaders.get("X-Caller"),
"preserved",
"Request-attached headers must survive the merge"
);
} finally {
restore();
}
});
test("createOmniRouteFetchInterceptor: trailing slash in baseURL is normalized", async () => {
const { calls, restore } = installFetchRecorder();
try {
const f = createOmniRouteFetchInterceptor({
apiKey: KEY,
baseURL: `${BASE}////`,
});
await f(`${BASE}/models`, {});
const sent = calls[0]!;
const sentHeaders = new Headers((sent.init as RequestInit).headers);
assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`);
} finally {
restore();
}
});
test("createOmniRouteFetchInterceptor: GET without body does NOT set Content-Type", async () => {
const { calls, restore } = installFetchRecorder();
try {
const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE });
await f(`${BASE}/models`); // no init at all
const sent = calls[0]!;
const sentHeaders = new Headers((sent.init as RequestInit).headers);
assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`);
assert.equal(
sentHeaders.get("Content-Type"),
null,
"Content-Type should only default when a body exists"
);
} finally {
restore();
}
});
// ----------------------------------------------------------------------------
// loader integration
// ----------------------------------------------------------------------------
test("loader: returns fetch fn when apiKey + baseURL both present (via opts)", async () => {
const hook = createOmniRouteAuthHook({ baseURL: BASE });
const result = await hook.loader!(async () => ({ type: "api", key: KEY }) as never, {} as never);
assert.equal((result as { apiKey: string }).apiKey, KEY);
assert.equal((result as { baseURL: string }).baseURL, BASE);
assert.equal(
typeof (result as { fetch?: unknown }).fetch,
"function",
"loader must wire fetch interceptor when baseURL resolves"
);
});
test("loader: returns fetch fn when baseURL is stashed on the auth credential", async () => {
// Some auth backends attach baseURL alongside the key (post-/connect flow).
// The loader should pick it up even when plugin opts.baseURL is unset.
const hook = createOmniRouteAuthHook();
const result = await hook.loader!(
async () => ({ type: "api", key: KEY, baseURL: BASE }) as never,
{} as never
);
assert.equal((result as { baseURL?: string }).baseURL, BASE);
assert.equal(typeof (result as { fetch?: unknown }).fetch, "function");
});
test("loader: omits fetch fn when baseURL missing (apiKey-only return)", async () => {
const hook = createOmniRouteAuthHook(); // no baseURL opt
const result = await hook.loader!(async () => ({ type: "api", key: KEY }) as never, {} as never);
// Interceptor needs a baseURL to gate-keep; without one, fall back to
// apiKey-only and let the SDK use its default fetch.
assert.deepEqual(result, { apiKey: KEY });
});
test("loader integration: wired interceptor actually injects Bearer when invoked", async () => {
// End-to-end: pull the fetch fn out of the loader return and exercise it,
// proving the wiring matches the standalone interceptor's contract.
const { calls, restore } = installFetchRecorder();
try {
const hook = createOmniRouteAuthHook({ baseURL: BASE });
const result = await hook.loader!(
async () => ({ type: "api", key: KEY }) as never,
{} as never
);
const wiredFetch = (result as { fetch: typeof fetch }).fetch;
await wiredFetch(`${BASE}/v1/models`, {});
assert.equal(calls.length, 1);
const sentHeaders = new Headers((calls[0]!.init as RequestInit).headers);
assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`);
} finally {
restore();
}
});

View File

@@ -1,291 +0,0 @@
/**
* Tests for the 3 mrmm-fork features backported to @omniroute/opencode-plugin:
*
* 1. `normaliseFreeLabel` — free-tier model display names get a consistent
* `[Free] ` prefix instead of trailing "(Free)" or ad-hoc "free" words.
*
* 2. `resolveApiBlock` — per-provider-prefix API format routing. Anthropic
* prefixes (`cc/`, `claude/`, `anthropic/`, `kiro/`, `kr/`) get the
* Anthropic SDK block; everything else gets OpenAI-compat.
*
* 3. `debugLog` — JSONL request/response capture, gated by
* `features.debugLog` and togglable at runtime via
* `debugLogEnabled/SetEnabled`.
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import {
normaliseFreeLabel,
resolveApiBlock,
DEFAULT_ANTHROPIC_PREFIXES,
ensureV1Suffix,
debugLogEnabled,
debugLogSetEnabled,
debugLogClear,
debugLogRead,
debugLogAppend,
createDebugLoggingFetch,
DebugLogEntry,
} from "../src/index.js";
// ── 1. normaliseFreeLabel ────────────────────────────────────────────────────
test("normaliseFreeLabel: '(Free)' suffix becomes [Free] prefix", () => {
assert.equal(normaliseFreeLabel("GPT-4.1 (Free)"), "[Free] GPT-4.1");
});
test("normaliseFreeLabel: trailing ' Free' word becomes [Free] prefix", () => {
assert.equal(
normaliseFreeLabel("DeepSeek V4 Flash Free"),
"[Free] DeepSeek V4 Flash"
);
});
test("normaliseFreeLabel: trailing '-free' (hyphen) becomes [Free] prefix", () => {
assert.equal(normaliseFreeLabel("Llama 4 Scout-free"), "[Free] Llama 4 Scout");
});
test("normaliseFreeLabel: case-insensitive (FREE, Free, free all match)", () => {
assert.equal(normaliseFreeLabel("Model A FREE"), "[Free] Model A");
assert.equal(normaliseFreeLabel("Model A free"), "[Free] Model A");
assert.equal(normaliseFreeLabel("Model A Free"), "[Free] Model A");
});
test("normaliseFreeLabel: names without 'free' pass through unchanged", () => {
assert.equal(normaliseFreeLabel("Claude 4.7 Opus"), "Claude 4.7 Opus");
assert.equal(normaliseFreeLabel("GPT-5"), "GPT-5");
});
test("normaliseFreeLabel: 'free' in the middle of a name is NOT rewritten", () => {
// Only trailing/standalone "free" markers count; embedded "freedom" stays
assert.equal(
normaliseFreeLabel("Freedom Model"),
"Freedom Model"
);
});
test("normaliseFreeLabel: empty / whitespace-only inputs are handled", () => {
// Empty input returns empty; pure whitespace input passes through (no Free marker)
assert.equal(normaliseFreeLabel(""), "");
assert.equal(normaliseFreeLabel(" "), " ");
});
// ── 2. resolveApiBlock ───────────────────────────────────────────────────────
test("resolveApiBlock: cc/* models get the Anthropic SDK block (no /v1)", () => {
const block = resolveApiBlock("cc/claude-opus-4-7", "https://api.example.com");
assert.equal(block.id, "anthropic");
assert.equal(block.npm, "@ai-sdk/anthropic");
assert.equal(block.url, "https://api.example.com"); // NO /v1 suffix
});
test("resolveApiBlock: claude/*, anthropic/*, kiro/*, kr/* all route to Anthropic", () => {
for (const id of [
"claude/claude-opus-4-7",
"anthropic/claude-sonnet-4",
"kiro/claude-sonnet-4-5",
"kr/claude-opus-4-6",
]) {
const block = resolveApiBlock(id, "https://api.example.com");
assert.equal(block.id, "anthropic", `${id} should route to Anthropic`);
assert.equal(block.npm, "@ai-sdk/anthropic");
}
});
test("resolveApiBlock: non-Anthropic models get OpenAI-compat with /v1", () => {
const block = resolveApiBlock("gpt-4o", "https://api.example.com");
assert.equal(block.id, "openai-compatible");
assert.equal(block.npm, "@ai-sdk/openai-compatible");
assert.equal(block.url, "https://api.example.com/v1");
});
test("resolveApiBlock: user can override anthropicPrefixes to add custom prefixes", () => {
const block = resolveApiBlock("myproxy/claude-opus", "https://api.example.com", {
anthropicPrefixes: ["myproxy"],
});
assert.equal(block.id, "anthropic");
assert.equal(block.npm, "@ai-sdk/anthropic");
});
test("resolveApiBlock: empty anthropicPrefixes forces OpenAI-compat for everything", () => {
const block = resolveApiBlock("cc/claude-opus", "https://api.example.com", {
anthropicPrefixes: [],
});
assert.equal(block.id, "openai-compatible");
});
test("resolveApiBlock: baseURL that already ends in /v1 is not double-suffixed (OpenAI path)", () => {
const block = resolveApiBlock("gpt-4o", "https://api.example.com/v1");
assert.equal(block.url, "https://api.example.com/v1"); // idempotent
});
test("resolveApiBlock: model id without '/' uses the id as prefix", () => {
const block = resolveApiBlock("claude-opus-4-7", "https://api.example.com");
// The whole id is the prefix, which doesn't match "cc"/"claude" etc.
// So it falls through to OpenAI-compat.
assert.equal(block.id, "openai-compatible");
});
test("DEFAULT_ANTHROPIC_PREFIXES: contains the canonical Anthropic aliases", () => {
assert.deepEqual(DEFAULT_ANTHROPIC_PREFIXES, [
"cc",
"claude",
"anthropic",
"kiro",
"kr",
]);
});
test("ensureV1Suffix: idempotent for URLs that already end in /v1", () => {
assert.equal(ensureV1Suffix("https://api.example.com/v1"), "https://api.example.com/v1");
assert.equal(
ensureV1Suffix("https://api.example.com/v1/"),
"https://api.example.com/v1" // trailing slash is stripped
);
});
test("ensureV1Suffix: appends /v1 when missing", () => {
assert.equal(ensureV1Suffix("https://api.example.com"), "https://api.example.com/v1");
assert.equal(ensureV1Suffix("https://api.example.com/"), "https://api.example.com/v1");
});
// ── 3. debugLog ──────────────────────────────────────────────────────────────
test("debugLog: default state is disabled", () => {
debugLogClear("test-provider-disabled-default");
assert.equal(debugLogEnabled("test-provider-disabled-default"), false);
});
test("debugLogSetEnabled + debugLogEnabled: roundtrip", () => {
debugLogSetEnabled("test-provider-toggle", true);
assert.equal(debugLogEnabled("test-provider-toggle"), true);
debugLogSetEnabled("test-provider-toggle", false);
assert.equal(debugLogEnabled("test-provider-toggle"), false);
});
test("debugLogAppend + debugLogRead: roundtrip preserves entry shape", () => {
const providerId = "test-provider-readroundtrip";
debugLogClear(providerId);
const entry: DebugLogEntry = {
reqId: "req-1",
providerId,
ts: 1700000000000,
url: "https://api.example.com/v1/chat",
method: "POST",
reqHeaders: { "content-type": "application/json" },
reqBody: { model: "gpt-4o", messages: [] },
resStatus: 200,
resHeaders: { "content-type": "application/json" },
resBody: { choices: [] },
durationMs: 42,
};
debugLogAppend(entry);
const read = debugLogRead(providerId, 10);
assert.equal(read.length, 1);
assert.deepEqual(read[0], entry);
});
test("createDebugLoggingFetch: passes through when disabled", async () => {
const providerId = "test-provider-passthrough";
debugLogClear(providerId);
debugLogSetEnabled(providerId, false);
const calls: unknown[] = [];
const inner: typeof fetch = async (input) => {
calls.push(input);
return new Response("ok", { status: 200 });
};
const wrapped = createDebugLoggingFetch(inner, providerId, false);
const res = await wrapped("https://api.example.com/v1/chat");
assert.equal(res.status, 200);
assert.equal(calls.length, 1);
// No log entry should be written when disabled
assert.equal(debugLogRead(providerId).length, 0);
});
test("createDebugLoggingFetch: captures request/response when enabled", async () => {
const providerId = "test-provider-captures";
debugLogClear(providerId);
const inner: typeof fetch = async () =>
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "content-type": "application/json" },
});
const wrapped = createDebugLoggingFetch(inner, providerId, true);
const res = await wrapped("https://api.example.com/v1/chat", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: "gpt-4o" }),
});
assert.equal(res.status, 200);
const entries = debugLogRead(providerId);
assert.equal(entries.length, 1);
assert.equal(entries[0].method, "POST");
assert.equal(entries[0].resStatus, 200);
assert.equal(entries[0].url, "https://api.example.com/v1/chat");
assert.deepEqual(entries[0].reqBody, { model: "gpt-4o" });
});
test("createDebugLoggingFetch: records error without crashing the wrapped fetch", async () => {
const providerId = "test-provider-error";
debugLogClear(providerId);
const inner: typeof fetch = async () => {
throw new Error("network down");
};
const wrapped = createDebugLoggingFetch(inner, providerId, true);
await assert.rejects(wrapped("https://api.example.com/v1/chat"), /network down/);
const entries = debugLogRead(providerId);
assert.equal(entries.length, 1);
assert.equal(entries[0].resStatus, null);
assert.equal(entries[0].error, "network down");
});
// ── Regression tests for the 3 HIGH-priority bot review fixes ───────────────
test("createDebugLoggingFetch: URL instance input is captured (not 'undefined')", async () => {
const providerId = "test-provider-url-input";
debugLogClear(providerId);
const inner: typeof fetch = async () =>
new Response("ok", { status: 200 });
const wrapped = createDebugLoggingFetch(inner, providerId, true);
await wrapped(new URL("https://api.example.com/v1/chat"));
const entries = debugLogRead(providerId);
assert.equal(entries.length, 1);
assert.equal(entries[0].url, "https://api.example.com/v1/chat");
assert.notEqual(entries[0].url, undefined);
});
test("createDebugLoggingFetch: Request object input captures URL and headers", async () => {
const providerId = "test-provider-request-input";
debugLogClear(providerId);
const inner: typeof fetch = async () =>
new Response("ok", { status: 200 });
const wrapped = createDebugLoggingFetch(inner, providerId, true);
const req = new Request("https://api.example.com/v1/chat", {
method: "POST",
headers: { "x-test": "yes" },
});
await wrapped(req);
const entries = debugLogRead(providerId);
assert.equal(entries.length, 1);
assert.equal(entries[0].url, "https://api.example.com/v1/chat");
assert.equal(entries[0].reqHeaders["x-test"], "yes");
});
test("createDebugLoggingFetch: SSE response is NOT buffered (resBody is the stream marker)", async () => {
const providerId = "test-provider-sse";
debugLogClear(providerId);
const inner: typeof fetch = async () =>
new Response("data: hello\n\n", {
status: 200,
headers: { "content-type": "text/event-stream" },
});
const wrapped = createDebugLoggingFetch(inner, providerId, true);
const res = await wrapped("https://api.example.com/v1/stream");
// The response body must remain readable downstream
const txt = await res.text();
assert.equal(txt, "data: hello\n\n");
const entries = debugLogRead(providerId);
assert.equal(entries.length, 1);
assert.equal(entries[0].resBody, "[stream]", "SSE responses must not be buffered");
});

View File

@@ -1,410 +0,0 @@
/**
* T-06 Gemini tool-schema sanitisation contract tests.
*
* Three layers under test:
* 1. `sanitizeGeminiToolSchemas` — pure function; key stripping + clone
* semantics on chat-completion + Responses-API shapes.
* 2. `shouldSanitizeForGemini` — model-string detection (liberal).
* 3. `createGeminiSanitizingFetch` — wrapper composition; URL gating,
* body-shape polymorphism, streaming-body bypass, fail-open behaviour,
* composition with the T-04 Bearer interceptor.
*
* Strategy: same posture as fetch-interceptor.test.ts — install a
* closure-based fetch recorder; assert on the `(input, init)` observed by
* the inner fetch after the sanitising wrapper has had its say.
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
__resetGeminiStreamingWarning,
createGeminiSanitizingFetch,
createOmniRouteFetchInterceptor,
sanitizeGeminiToolSchemas,
shouldSanitizeForGemini,
} from "../src/index.js";
// ────────────────────────────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────────────────────────────
type FetchCall = { input: Parameters<typeof fetch>[0]; init?: RequestInit };
function recorder(response: Response = new Response("ok")): {
fn: typeof fetch;
calls: FetchCall[];
} {
const calls: FetchCall[] = [];
const fn = (async (input: any, init?: any) => {
calls.push({ input, init });
return response;
}) as typeof fetch;
return { fn, calls };
}
function bodyAsRecord(init: RequestInit | undefined): Record<string, unknown> {
const b = init?.body;
if (typeof b !== "string") {
throw new Error(`expected string body, got ${typeof b}`);
}
return JSON.parse(b) as Record<string, unknown>;
}
// Sample tool payloads — small enough to inline, big enough to cover
// chat-completion + Responses-API + nested properties.
function chatCompletionsWithDollarSchema(): Record<string, unknown> {
return {
model: "gemini-2.5-pro",
tools: [
{
type: "function",
function: {
name: "search",
parameters: {
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
additionalProperties: false,
properties: {
q: { type: "string" },
},
required: ["q"],
},
},
},
],
};
}
function responsesApiWithRef(): Record<string, unknown> {
return {
model: "gemini-2.5-flash",
tools: [
{
type: "function",
name: "lookup",
input_schema: {
type: "object",
$ref: "#/definitions/Lookup",
properties: {
id: { type: "string", ref: "Id" },
},
},
},
],
};
}
function nestedPropertiesPayload(): Record<string, unknown> {
return {
model: "gemini-pro",
tools: [
{
type: "function",
function: {
name: "deep",
parameters: {
type: "object",
properties: {
outer: {
type: "object",
$schema: "http://json-schema.org/draft-07/schema#",
properties: {
inner: {
type: "object",
additionalProperties: true,
$ref: "#/inner",
properties: {
leaf: { type: "string" },
},
},
},
},
},
},
},
},
],
};
}
// ────────────────────────────────────────────────────────────────────────────
// sanitizeGeminiToolSchemas — pure function
// ────────────────────────────────────────────────────────────────────────────
test("sanitizeGeminiToolSchemas: strips $schema from top-level", () => {
const input = {
model: "gemini-2.5-pro",
$schema: "http://json-schema.org/draft-07/schema#",
tools: [],
};
const out = sanitizeGeminiToolSchemas(input) as Record<string, unknown>;
assert.equal(out.$schema, undefined);
assert.equal(out.model, "gemini-2.5-pro");
});
test("sanitizeGeminiToolSchemas: strips $ref + additionalProperties from tools[].function.parameters", () => {
const input = chatCompletionsWithDollarSchema();
const out = sanitizeGeminiToolSchemas(input) as Record<string, unknown>;
const params = (out.tools as Array<{ function: { parameters: Record<string, unknown> } }>)[0]!
.function.parameters;
assert.equal(params.$schema, undefined);
assert.equal(params.additionalProperties, undefined);
// Untouched keys survive.
assert.equal(params.type, "object");
assert.deepEqual(params.required, ["q"]);
});
test("sanitizeGeminiToolSchemas: strips nested $schema from properties.x.properties.y", () => {
const input = nestedPropertiesPayload();
const out = sanitizeGeminiToolSchemas(input) as Record<string, unknown>;
const params = (out.tools as Array<{ function: { parameters: Record<string, unknown> } }>)[0]!
.function.parameters;
const outer = (params.properties as Record<string, Record<string, unknown>>).outer!;
const inner = (outer.properties as Record<string, Record<string, unknown>>).inner!;
assert.equal(outer.$schema, undefined);
assert.equal(inner.$ref, undefined);
assert.equal(inner.additionalProperties, undefined);
// Leaf still intact.
assert.deepEqual(inner.properties, { leaf: { type: "string" } });
});
test("sanitizeGeminiToolSchemas: handles Responses-API tools[].input_schema shape", () => {
const input = responsesApiWithRef();
const out = sanitizeGeminiToolSchemas(input) as Record<string, unknown>;
const inputSchema = (out.tools as Array<{ input_schema: Record<string, unknown> }>)[0]!
.input_schema;
assert.equal(inputSchema.$ref, undefined);
// Nested `ref` (lowercase) also stripped.
const props = inputSchema.properties as Record<string, Record<string, unknown>>;
assert.equal(props.id!.ref, undefined);
assert.equal(props.id!.type, "string");
});
test("sanitizeGeminiToolSchemas: leaves payload without tools untouched", () => {
const input = { model: "gemini-2.5-pro", messages: [{ role: "user", content: "hi" }] };
const out = sanitizeGeminiToolSchemas(input) as Record<string, unknown>;
assert.deepEqual(out, input);
});
test("sanitizeGeminiToolSchemas: does not mutate input (returned object is distinct)", () => {
const input = chatCompletionsWithDollarSchema();
const beforeJson = JSON.stringify(input);
const out = sanitizeGeminiToolSchemas(input);
// Input bit-identical to its pre-sanitise serialisation.
assert.equal(JSON.stringify(input), beforeJson);
// Output is a different reference.
assert.notEqual(out, input);
});
// ────────────────────────────────────────────────────────────────────────────
// shouldSanitizeForGemini — detection
// ────────────────────────────────────────────────────────────────────────────
test("shouldSanitizeForGemini: gemini-2.5-pro → true", () => {
assert.equal(shouldSanitizeForGemini({ model: "gemini-2.5-pro" }), true);
});
test("shouldSanitizeForGemini: models/gemini-pro → true", () => {
assert.equal(shouldSanitizeForGemini({ model: "models/gemini-pro" }), true);
});
test("shouldSanitizeForGemini: google-vertex/gemini-1.5-flash → true", () => {
assert.equal(shouldSanitizeForGemini({ model: "google-vertex/gemini-1.5-flash" }), true);
});
test("shouldSanitizeForGemini: gemini-cli/gemini-2.5-pro → true (real OmniRoute alias)", () => {
assert.equal(shouldSanitizeForGemini({ model: "gemini-cli/gemini-2.5-pro" }), true);
});
test("shouldSanitizeForGemini: claude-sonnet-4 → false", () => {
assert.equal(shouldSanitizeForGemini({ model: "claude-sonnet-4" }), false);
});
test("shouldSanitizeForGemini: payload.model missing → false", () => {
assert.equal(shouldSanitizeForGemini({ messages: [] }), false);
});
test("shouldSanitizeForGemini: payload is null → false", () => {
assert.equal(shouldSanitizeForGemini(null), false);
});
test("shouldSanitizeForGemini: payload.model is non-string → false", () => {
assert.equal(shouldSanitizeForGemini({ model: 42 }), false);
});
// ────────────────────────────────────────────────────────────────────────────
// createGeminiSanitizingFetch — wrapper
// ────────────────────────────────────────────────────────────────────────────
const URL_CHAT = "https://or.example.com/v1/chat/completions";
const URL_RESPONSES = "https://or.example.com/v1/responses";
const URL_MODELS = "https://or.example.com/v1/models";
test("createGeminiSanitizingFetch: gemini model + chat/completions → tool schemas stripped before forward", async () => {
const rec = recorder();
const wrapped = createGeminiSanitizingFetch(rec.fn);
await wrapped(URL_CHAT, {
method: "POST",
body: JSON.stringify(chatCompletionsWithDollarSchema()),
});
assert.equal(rec.calls.length, 1);
const forwarded = bodyAsRecord(rec.calls[0]!.init);
const params = (
forwarded.tools as Array<{ function: { parameters: Record<string, unknown> } }>
)[0]!.function.parameters;
assert.equal(params.$schema, undefined);
assert.equal(params.additionalProperties, undefined);
});
test("createGeminiSanitizingFetch: non-gemini model + chat/completions → body passed through unchanged", async () => {
const rec = recorder();
const wrapped = createGeminiSanitizingFetch(rec.fn);
const originalBody = JSON.stringify({
model: "claude-sonnet-4",
tools: [
{
type: "function",
function: {
name: "x",
parameters: { $schema: "keep-me", type: "object" },
},
},
],
});
await wrapped(URL_CHAT, { method: "POST", body: originalBody });
// Identity check on body — wrapper must NOT mutate non-Gemini payloads.
assert.equal(rec.calls[0]!.init!.body, originalBody);
});
test("createGeminiSanitizingFetch: gemini model + /v1/models (non-completion endpoint) → body passed through unchanged", async () => {
const rec = recorder();
const wrapped = createGeminiSanitizingFetch(rec.fn);
// GET /v1/models has no body in production; assert that even if a caller
// attached a Gemini-shaped body to a non-completion URL, the wrapper
// doesn't touch it.
const body = JSON.stringify(chatCompletionsWithDollarSchema());
await wrapped(URL_MODELS, { method: "POST", body });
assert.equal(rec.calls[0]!.init!.body, body);
});
test("createGeminiSanitizingFetch: gemini model + /responses endpoint → input_schema stripped", async () => {
const rec = recorder();
const wrapped = createGeminiSanitizingFetch(rec.fn);
await wrapped(URL_RESPONSES, {
method: "POST",
body: JSON.stringify(responsesApiWithRef()),
});
const forwarded = bodyAsRecord(rec.calls[0]!.init);
const schema = (forwarded.tools as Array<{ input_schema: Record<string, unknown> }>)[0]!
.input_schema;
assert.equal(schema.$ref, undefined);
});
test("createGeminiSanitizingFetch: gemini model + Request input with body → tool schemas stripped", async () => {
const rec = recorder();
const wrapped = createGeminiSanitizingFetch(rec.fn);
const req = new Request(URL_CHAT, {
method: "POST",
body: JSON.stringify(chatCompletionsWithDollarSchema()),
headers: { "Content-Type": "application/json" },
});
await wrapped(req);
const forwarded = bodyAsRecord(rec.calls[0]!.init);
const params = (
forwarded.tools as Array<{ function: { parameters: Record<string, unknown> } }>
)[0]!.function.parameters;
assert.equal(params.$schema, undefined);
});
test("createGeminiSanitizingFetch: gemini model + ReadableStream body → skipped + warn emitted once", async () => {
__resetGeminiStreamingWarning();
const rec = recorder();
const wrapped = createGeminiSanitizingFetch(rec.fn);
// Capture console.warn for the duration of this test.
const warnings: string[] = [];
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => {
warnings.push(args.map(String).join(" "));
};
try {
const stream1 = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("{}"));
controller.close();
},
});
const stream2 = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("{}"));
controller.close();
},
});
// Two streaming calls — only one warn expected.
await wrapped(URL_CHAT, { method: "POST", body: stream1 });
await wrapped(URL_CHAT, { method: "POST", body: stream2 });
} finally {
console.warn = originalWarn;
}
// Both calls forwarded to inner fetch with their streams intact.
assert.equal(rec.calls.length, 2);
// ONE warning total — one-shot latch held.
assert.equal(warnings.length, 1);
assert.match(warnings[0]!, /streaming Request body, skipping schema strip/);
});
test("createGeminiSanitizingFetch: invalid JSON body → pass through, no throw", async () => {
const rec = recorder();
const wrapped = createGeminiSanitizingFetch(rec.fn);
// Garbage body must not crash the wrapper.
await wrapped(URL_CHAT, { method: "POST", body: "this is not json{{" });
assert.equal(rec.calls.length, 1);
assert.equal(rec.calls[0]!.init!.body, "this is not json{{");
});
test("createGeminiSanitizingFetch: empty body → pass through unchanged", async () => {
const rec = recorder();
const wrapped = createGeminiSanitizingFetch(rec.fn);
await wrapped(URL_CHAT, { method: "POST" });
assert.equal(rec.calls.length, 1);
});
test("createGeminiSanitizingFetch: composes correctly with createOmniRouteFetchInterceptor (Bearer + sanitization)", async () => {
// Save and replace globalThis.fetch — the Bearer interceptor calls global
// fetch when the URL targets its baseURL.
const originalFetch = globalThis.fetch;
const observed: FetchCall[] = [];
globalThis.fetch = (async (input: any, init?: any) => {
observed.push({ input, init });
return new Response("ok");
}) as typeof fetch;
try {
const composed = createGeminiSanitizingFetch(
createOmniRouteFetchInterceptor({
apiKey: "sk-test",
baseURL: "https://or.example.com/v1",
})
);
await composed(URL_CHAT, {
method: "POST",
body: JSON.stringify(chatCompletionsWithDollarSchema()),
});
assert.equal(observed.length, 1);
// Bearer injected (header concern).
const sentHeaders = new Headers((observed[0]!.init as RequestInit).headers);
assert.equal(sentHeaders.get("Authorization"), "Bearer sk-test");
// Schema sanitised (body concern).
const forwarded = bodyAsRecord(observed[0]!.init);
const params = (
forwarded.tools as Array<{ function: { parameters: Record<string, unknown> } }>
)[0]!.function.parameters;
assert.equal(params.$schema, undefined);
assert.equal(params.additionalProperties, undefined);
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -1,136 +0,0 @@
/**
* T-08 multi-instance smoke.
*
* Validates that two `OmniRoutePlugin(input, opts)` invocations with
* different `providerId` values coexist without sharing mutable state.
* This is the contract that lets opencode.json declare prod + preprod
* side by side:
*
* "plugin": [
* ["@omniroute/opencode-plugin", {"providerId": "omniroute-prod", "baseURL": "https://or.example/v1"}],
* ["@omniroute/opencode-plugin", {"providerId": "omniroute-preprod", "baseURL": "https://or-preprod.example/v1"}]
* ]
*
* Assertions:
* - Each invocation returns its own hooks object (no identity reuse).
* - Each `auth` hook carries its own `provider` matching opts.providerId.
* - Each `auth.methods` array is its own array (not the same reference).
* - Calling the factory twice with IDENTICAL opts still yields two
* independent objects (no instance reuse / no shared closure cache).
* - Mutating one instance's auth hook does NOT bleed into the other.
* - Each instance's loader closure captures its OWN baseURL — no
* last-write-wins module-scope state.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { OmniRoutePlugin } from "../src/index.js";
const fakeInput = {} as Parameters<typeof OmniRoutePlugin>[0];
test("multi-instance: two plugin invocations bind to their own providerId", async () => {
const a = await OmniRoutePlugin(fakeInput, {
providerId: "omniroute-prod",
baseURL: "https://a.example/v1",
});
const b = await OmniRoutePlugin(fakeInput, {
providerId: "omniroute-preprod",
baseURL: "https://b.example/v1",
});
assert.equal(a.auth?.provider, "omniroute-prod");
assert.equal(b.auth?.provider, "omniroute-preprod");
});
test("multi-instance: hook objects + nested arrays are independent references", async () => {
const a = await OmniRoutePlugin(fakeInput, {
providerId: "alpha",
baseURL: "https://a.example/v1",
});
const b = await OmniRoutePlugin(fakeInput, {
providerId: "bravo",
baseURL: "https://b.example/v1",
});
assert.notEqual(a, b, "top-level hooks objects must not be the same reference");
assert.notEqual(a.auth, b.auth, "auth hooks must not be the same reference");
assert.notEqual(
a.auth?.methods,
b.auth?.methods,
"methods arrays must not be the same reference"
);
});
test("multi-instance: identical opts twice still yield independent objects", async () => {
const opts = { providerId: "twin", baseURL: "https://twin.example/v1" };
const first = await OmniRoutePlugin(fakeInput, { ...opts });
const second = await OmniRoutePlugin(fakeInput, { ...opts });
assert.notEqual(first, second);
assert.notEqual(first.auth, second.auth);
assert.notEqual(first.auth?.methods, second.auth?.methods);
// Same provider id is fine — what matters is no shared mutable state.
assert.equal(first.auth?.provider, "twin");
assert.equal(second.auth?.provider, "twin");
});
test("multi-instance: mutating instance A's auth.methods does not affect instance B", async () => {
const a = await OmniRoutePlugin(fakeInput, {
providerId: "iso-a",
baseURL: "https://a.example/v1",
});
const b = await OmniRoutePlugin(fakeInput, {
providerId: "iso-b",
baseURL: "https://b.example/v1",
});
const beforeLen = b.auth?.methods?.length ?? 0;
// Mutate a's methods array — extend it; b's must be untouched.
// We don't know the concrete method shape so push a sentinel cast.
a.auth?.methods?.push({ type: "api", label: "sentinel" } as never);
assert.equal(b.auth?.methods?.length, beforeLen, "instance B leaked from instance A mutation");
});
test("multi-instance: loader closures see their own opts (not last-write-wins)", async () => {
// Each plugin's loader builds its loader payload from the providerId/baseURL
// captured at invocation time. If the factory accidentally shared a closure
// (e.g. a module-scope let that the last invocation overwrites), both
// loaders would emit the same baseURL. Verify they don't.
const a = await OmniRoutePlugin(fakeInput, {
providerId: "omniroute-prod",
baseURL: "https://prod.example/v1",
});
const b = await OmniRoutePlugin(fakeInput, {
providerId: "omniroute-preprod",
baseURL: "https://preprod.example/v1",
});
assert.ok(a.auth?.loader, "instance A must have a loader");
assert.ok(b.auth?.loader, "instance B must have a loader");
const getAuthA = async () => ({ type: "api", key: "sk-prod" }) as never;
const getAuthB = async () => ({ type: "api", key: "sk-preprod" }) as never;
const rA = (await a.auth!.loader!(getAuthA, {} as never)) as Record<string, unknown>;
const rB = (await b.auth!.loader!(getAuthB, {} as never)) as Record<string, unknown>;
assert.equal(rA.apiKey, "sk-prod");
assert.equal(rA.baseURL, "https://prod.example/v1");
assert.equal(rB.apiKey, "sk-preprod");
assert.equal(rB.baseURL, "https://preprod.example/v1");
});
test("multi-instance: invalid opts on one instance does not poison the other", async () => {
// Sequencing: bad opts → good opts. The bad call must throw cleanly; the
// good call must still produce a working hooks object. Confirms no
// half-built module-level state survives a failed parse.
await assert.rejects(
() => OmniRoutePlugin(fakeInput, { providerId: "bad id!" } as never),
/providerId/
);
const ok = await OmniRoutePlugin(fakeInput, {
providerId: "recovered",
baseURL: "https://ok.example/v1",
});
assert.equal(ok.auth?.provider, "recovered");
});

View File

@@ -1,104 +0,0 @@
/**
* T-08 options-schema tests.
*
* Covers `parseOmniRoutePluginOptions(opts)` — the strict Zod gate that
* validates the second-arg `PluginOptions` bag from opencode.json before
* any hook is wired. Anti-pattern checklist mirrored here:
*
* - `null` / `undefined` must collapse to `{}` (defaults apply downstream).
* - Unknown keys must THROW (`.strict()` catches opencode.json typos).
* - Validation runs at parse time, not import time (module loads cleanly).
*/
import test from "node:test";
import assert from "node:assert/strict";
import { parseOmniRoutePluginOptions } from "../src/index.js";
test("parseOmniRoutePluginOptions: undefined → {}", () => {
assert.deepEqual(parseOmniRoutePluginOptions(undefined), {});
});
test("parseOmniRoutePluginOptions: null → {}", () => {
assert.deepEqual(parseOmniRoutePluginOptions(null), {});
});
test("parseOmniRoutePluginOptions: empty object → {}", () => {
assert.deepEqual(parseOmniRoutePluginOptions({}), {});
});
test("parseOmniRoutePluginOptions: valid providerId → returns it", () => {
const r = parseOmniRoutePluginOptions({ providerId: "omniroute-preprod" });
assert.equal(r.providerId, "omniroute-preprod");
});
test("parseOmniRoutePluginOptions: invalid providerId (special chars) → throws", () => {
assert.throws(
() => parseOmniRoutePluginOptions({ providerId: "omniroute prod!" }),
/providerId.*slug/i
);
});
test("parseOmniRoutePluginOptions: empty providerId → throws", () => {
assert.throws(() => parseOmniRoutePluginOptions({ providerId: "" }), /providerId/i);
});
test("parseOmniRoutePluginOptions: valid modelCacheTtl → returns it", () => {
const r = parseOmniRoutePluginOptions({ modelCacheTtl: 60_000 });
assert.equal(r.modelCacheTtl, 60_000);
});
test("parseOmniRoutePluginOptions: negative modelCacheTtl → throws", () => {
assert.throws(() => parseOmniRoutePluginOptions({ modelCacheTtl: -1 }), /modelCacheTtl/i);
});
test("parseOmniRoutePluginOptions: zero modelCacheTtl → throws (positive required)", () => {
assert.throws(() => parseOmniRoutePluginOptions({ modelCacheTtl: 0 }), /modelCacheTtl/i);
});
test("parseOmniRoutePluginOptions: invalid baseURL (not a URL) → throws", () => {
assert.throws(() => parseOmniRoutePluginOptions({ baseURL: "not-a-url" }), /baseURL/i);
});
test("parseOmniRoutePluginOptions: unknown key → throws (strict mode catches typos)", () => {
assert.throws(
() =>
parseOmniRoutePluginOptions({
providerId: "omniroute",
provider_id: "typo-here",
}),
/provider_id|unrecognized/i
);
});
test("parseOmniRoutePluginOptions: all four fields populated correctly → returns them", () => {
const opts = {
providerId: "omniroute-prod",
displayName: "OmniRoute Production",
modelCacheTtl: 120_000,
baseURL: "https://or.example.com/v1",
};
const r = parseOmniRoutePluginOptions(opts);
assert.deepEqual(r, opts);
});
test("parseOmniRoutePluginOptions: error message lists every issue path", () => {
// Two bad fields at once → error string should mention BOTH.
try {
parseOmniRoutePluginOptions({
providerId: "",
baseURL: "garbage",
});
assert.fail("expected throw");
} catch (err) {
const msg = (err as Error).message;
assert.match(msg, /providerId/);
assert.match(msg, /baseURL/);
}
});
test("parseOmniRoutePluginOptions: module import alone does NOT throw", async () => {
// Re-importing the entry must not trigger validation; validation only fires
// on explicit parseOmniRoutePluginOptions / OmniRoutePlugin invocation.
const mod = await import("../src/index.js");
assert.equal(typeof mod.parseOmniRoutePluginOptions, "function");
});

View File

@@ -1,269 +0,0 @@
/**
* T-03 provider-hook contract tests.
*
* Covers `createOmniRouteProviderHook(opts, deps)`:
* - hook.id binds to resolved providerId (single + multi-instance)
* - models() narrows ctx.auth, fetches via injected fetcher, caches per
* (baseURL, apiKey) tuple, refetches after TTL
* - mapRawModelToModelV2 emits a v2 Model shape matching the
* @opencode-ai/sdk/v2 type
*
* Mocking strategy: the fetcher is dependency-injected at hook construction
* (`deps.fetcher`). No global fetch monkey-patch needed. `deps.now` lets us
* fast-forward time deterministically for TTL assertions.
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
createOmniRouteProviderHook,
mapRawModelToModelV2,
type OmniRouteRawModelEntry,
type OmniRouteModelsFetcher,
} from "../src/index.js";
const FIXTURE: OmniRouteRawModelEntry[] = [
{
id: "claude-primary",
object: "model",
owned_by: "combo",
capabilities: { tool_calling: true, reasoning: true, vision: true, thinking: true },
context_length: 200000,
max_output_tokens: 64000,
input_modalities: ["text", "image"],
output_modalities: ["text"],
},
{
id: "claude-low",
object: "model",
owned_by: "combo",
capabilities: { tool_calling: true, reasoning: true, vision: true, thinking: false },
context_length: 200000,
max_output_tokens: 64000,
input_modalities: ["text", "image"],
output_modalities: ["text"],
},
{
id: "gemini-3-flash",
object: "model",
owned_by: "google",
capabilities: { tool_calling: true, reasoning: false, vision: true, thinking: false },
context_length: 1000000,
max_output_tokens: 8192,
input_modalities: ["text", "image"],
output_modalities: ["text"],
},
];
function stubFetcher(payload: OmniRouteRawModelEntry[]): OmniRouteModelsFetcher & {
callCount: () => number;
callsBy: () => Array<[string, string]>;
} {
let calls: Array<[string, string]> = [];
const f: OmniRouteModelsFetcher = async (baseURL, apiKey) => {
calls.push([baseURL, apiKey]);
return payload;
};
return Object.assign(f, {
callCount: () => calls.length,
callsBy: () => calls,
});
}
const apiAuth = (key: string, baseURL?: string): unknown =>
baseURL ? { type: "api", key, baseURL } : { type: "api", key };
test("createOmniRouteProviderHook: default providerId is 'omniroute'", () => {
const hook = createOmniRouteProviderHook(undefined, { combosFetcher: async () => [] });
assert.equal(hook.id, "omniroute");
});
test("createOmniRouteProviderHook: custom providerId binds to hook.id (multi-instance)", () => {
const a = createOmniRouteProviderHook(
{ providerId: "omniroute-preprod" },
{ combosFetcher: async () => [] }
);
const b = createOmniRouteProviderHook(
{ providerId: "omniroute-local" },
{ combosFetcher: async () => [] }
);
assert.equal(a.id, "omniroute-preprod");
assert.equal(b.id, "omniroute-local");
});
test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it", async () => {
const fetcher = stubFetcher(FIXTURE);
const hook = createOmniRouteProviderHook(
{ baseURL: "https://or.example.com/v1" },
{ fetcher, combosFetcher: async () => [] }
);
const out = await hook.models!({} as never, { auth: apiAuth("sk-abc") as never });
assert.equal(fetcher.callCount(), 1);
assert.deepEqual(fetcher.callsBy()[0], ["https://or.example.com/v1", "sk-abc"]);
assert.equal(Object.keys(out).length, 3);
assert.ok(out["claude-primary"]);
});
test("models: returns {} when ctx.auth is null/undefined/wrong-type/empty-key", async () => {
const fetcher = stubFetcher(FIXTURE);
const hook = createOmniRouteProviderHook(
{ baseURL: "https://or.example.com/v1" },
{ fetcher, combosFetcher: async () => [] }
);
assert.deepEqual(await hook.models!({} as never, {} as never), {});
assert.deepEqual(await hook.models!({} as never, { auth: undefined } as never), {});
assert.deepEqual(
await hook.models!({} as never, {
auth: { type: "oauth", refresh: "r", access: "a", expires: 0 } as never,
}),
{}
);
assert.deepEqual(
await hook.models!({} as never, { auth: { type: "api", key: "" } as never }),
{}
);
assert.equal(fetcher.callCount(), 0, "fetcher must not be called on auth rejection");
});
test("models: returns {} when no baseURL resolvable (no opts.baseURL and no auth.baseURL)", async () => {
const fetcher = stubFetcher(FIXTURE);
const hook = createOmniRouteProviderHook({}, { fetcher, combosFetcher: async () => [] });
// valid api auth but neither opts nor auth carries a baseURL
assert.deepEqual(await hook.models!({} as never, { auth: apiAuth("sk-x") as never }), {});
assert.equal(fetcher.callCount(), 0);
});
test("models: baseURL falls back to auth.baseURL when opts.baseURL absent", async () => {
const fetcher = stubFetcher(FIXTURE);
const hook = createOmniRouteProviderHook({}, { fetcher, combosFetcher: async () => [] });
const out = await hook.models!({} as never, {
auth: apiAuth("sk-y", "https://or.creds-attached.example/v1") as never,
});
assert.equal(fetcher.callCount(), 1);
assert.equal(fetcher.callsBy()[0][0], "https://or.creds-attached.example/v1");
assert.equal(Object.keys(out).length, 3);
});
test("models: maps a sample /v1/models entry to ModelV2 (sanity)", async () => {
const fetcher = stubFetcher(FIXTURE);
const hook = createOmniRouteProviderHook(
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" },
{ fetcher, combosFetcher: async () => [] }
);
const out = await hook.models!({} as never, { auth: apiAuth("sk-abc") as never });
const claude = out["claude-primary"];
assert.ok(claude, "claude-primary present");
assert.equal(claude.id, "claude-primary");
assert.equal(claude.name, "claude-primary");
assert.equal(claude.providerID, "omniroute");
assert.equal(claude.api.id, "openai-compatible");
assert.equal(claude.api.url, "https://or.example.com/v1");
assert.equal(claude.api.npm, "@ai-sdk/openai-compatible");
// capabilities: toolcall (one word), reasoning OR thinking, attachment = vision
assert.equal(claude.capabilities.toolcall, true);
assert.equal(claude.capabilities.reasoning, true);
assert.equal(claude.capabilities.attachment, true);
assert.equal(claude.capabilities.temperature, true);
// modalities mapped from arrays
assert.equal(claude.capabilities.input.text, true);
assert.equal(claude.capabilities.input.image, true);
assert.equal(claude.capabilities.input.audio, false);
assert.equal(claude.capabilities.output.text, true);
assert.equal(claude.capabilities.output.image, false);
// cost is zeroed (OmniRoute /v1/models has no pricing)
assert.deepEqual(claude.cost, { input: 0, output: 0, cache: { read: 0, write: 0 } });
// limits
assert.equal(claude.limit.context, 200000);
assert.equal(claude.limit.output, 64000);
assert.equal(claude.status, "active");
});
test("mapRawModelToModelV2: thinking-only model still surfaces reasoning=true", () => {
const m = mapRawModelToModelV2(
{
id: "thinking-only",
capabilities: { thinking: true, reasoning: false },
context_length: 100000,
max_output_tokens: 8192,
},
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" }
);
assert.equal(m.capabilities.reasoning, true);
});
test("mapRawModelToModelV2: missing capabilities defaults to all-false (except temperature)", () => {
const m = mapRawModelToModelV2(
{ id: "minimal" },
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" }
);
assert.equal(m.capabilities.temperature, true);
assert.equal(m.capabilities.reasoning, false);
assert.equal(m.capabilities.attachment, false);
assert.equal(m.capabilities.toolcall, false);
// default modalities = text only
assert.equal(m.capabilities.input.text, true);
assert.equal(m.capabilities.output.text, true);
// missing context / output tokens → 0 fallback (ModelV2.limit.{context,output} required)
assert.equal(m.limit.context, 0);
assert.equal(m.limit.output, 0);
});
test("models: caches result for second call within TTL (fetcher called once)", async () => {
const fetcher = stubFetcher(FIXTURE);
let nowMs = 1_000_000;
const hook = createOmniRouteProviderHook(
{ baseURL: "https://or.example.com/v1", modelCacheTtl: 60_000 },
{ fetcher, now: () => nowMs, combosFetcher: async () => [] }
);
const a = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
nowMs += 30_000; // half the TTL
const b = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
assert.equal(fetcher.callCount(), 1, "second call within TTL must hit the cache");
assert.equal(Object.keys(a).length, 3);
assert.equal(Object.keys(b).length, 3);
});
test("models: refetches after TTL expires", async () => {
const fetcher = stubFetcher(FIXTURE);
let nowMs = 1_000_000;
const hook = createOmniRouteProviderHook(
{ baseURL: "https://or.example.com/v1", modelCacheTtl: 60_000 },
{ fetcher, now: () => nowMs, combosFetcher: async () => [] }
);
await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
nowMs += 60_001; // just past the TTL
await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
assert.equal(fetcher.callCount(), 2, "call past TTL must refetch");
});
test("models: caches per (baseURL, apiKey) tuple (different keys → independent fetches)", async () => {
const fetcher = stubFetcher(FIXTURE);
const hook = createOmniRouteProviderHook(
{ baseURL: "https://or.example.com/v1", modelCacheTtl: 300_000 },
{ fetcher, combosFetcher: async () => [] }
);
await hook.models!({} as never, { auth: apiAuth("sk-A") as never });
await hook.models!({} as never, { auth: apiAuth("sk-B") as never });
await hook.models!({} as never, { auth: apiAuth("sk-A") as never }); // cached
await hook.models!({} as never, { auth: apiAuth("sk-B") as never }); // cached
assert.equal(fetcher.callCount(), 2, "one fetch per distinct apiKey, then cache hits");
});
test("models: caches per (baseURL, apiKey) tuple (different baseURL → independent fetches)", async () => {
const fetcher = stubFetcher(FIXTURE);
const hook = createOmniRouteProviderHook(
{ modelCacheTtl: 300_000 }, // no opts.baseURL → falls back to auth.baseURL
{ fetcher, combosFetcher: async () => [] }
);
await hook.models!({} as never, { auth: apiAuth("sk-same", "https://prod.example/v1") as never });
await hook.models!({} as never, {
auth: apiAuth("sk-same", "https://preprod.example/v1") as never,
});
await hook.models!({} as never, { auth: apiAuth("sk-same", "https://prod.example/v1") as never }); // cached
assert.equal(fetcher.callCount(), 2, "distinct baseURLs share apiKey but not cache");
});

View File

@@ -1,73 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import {
OmniRoutePlugin,
OMNIROUTE_PROVIDER_KEY,
DEFAULT_MODEL_CACHE_TTL_MS,
resolveOmniRoutePluginOptions,
} from "../src/index.js";
test("scaffold: exports public surface", () => {
assert.equal(
typeof OmniRoutePlugin,
"function",
"OmniRoutePlugin must be a function (Plugin factory)"
);
assert.equal(OMNIROUTE_PROVIDER_KEY, "omniroute");
assert.equal(DEFAULT_MODEL_CACHE_TTL_MS, 300_000);
});
test("scaffold: default export is v1 plugin shape { id, server: OmniRoutePlugin }", async () => {
const mod = await import("../src/index.js");
assert.equal(typeof mod.default, "object");
assert.equal(mod.default.id, "@omniroute/opencode-plugin");
assert.equal(mod.default.server, mod.OmniRoutePlugin);
});
test("resolveOmniRoutePluginOptions: defaults", () => {
const r = resolveOmniRoutePluginOptions();
assert.equal(r.providerId, "omniroute");
assert.equal(r.displayName, "OmniRoute");
assert.equal(r.modelCacheTtl, 300_000);
assert.equal(r.baseURL, undefined);
});
test("resolveOmniRoutePluginOptions: custom providerId derives displayName", () => {
const r = resolveOmniRoutePluginOptions({ providerId: "omniroute-preprod" });
assert.equal(r.providerId, "omniroute-preprod");
assert.equal(r.displayName, "OmniRoute (omniroute-preprod)");
});
test("resolveOmniRoutePluginOptions: explicit displayName wins", () => {
const r = resolveOmniRoutePluginOptions({
providerId: "omniroute-x",
displayName: "Custom Label",
});
assert.equal(r.displayName, "Custom Label");
});
test("resolveOmniRoutePluginOptions: invalid TTL falls back to default", () => {
assert.equal(resolveOmniRoutePluginOptions({ modelCacheTtl: 0 }).modelCacheTtl, 300_000);
assert.equal(resolveOmniRoutePluginOptions({ modelCacheTtl: -1 }).modelCacheTtl, 300_000);
});
test("resolveOmniRoutePluginOptions: positive TTL respected", () => {
assert.equal(resolveOmniRoutePluginOptions({ modelCacheTtl: 60_000 }).modelCacheTtl, 60_000);
});
test("OmniRoutePlugin: returns an empty hooks object (scaffold)", async () => {
const fakeCtx = {} as Parameters<typeof OmniRoutePlugin>[0];
const hooks = await OmniRoutePlugin(fakeCtx);
assert.equal(typeof hooks, "object");
assert.notEqual(hooks, null);
});
test("scaffold: CJS default export resolves via require() with v1 shape", () => {
const require_ = createRequire(import.meta.url);
const cjs = require_("../dist/index.cjs");
// after cjsInterop:true, default export is on cjs.default
assert.strictEqual(typeof cjs.default, "object");
assert.strictEqual(cjs.default.id, "@omniroute/opencode-plugin");
assert.strictEqual(typeof cjs.default.server, "function");
});

View File

@@ -1,85 +0,0 @@
/**
* Regression tests for `isUsableCombo` (release/v3.8.2 code review, finding C1).
*
* The combo member refs returned by `/api/combos` do NOT carry a separate
* `providerId` field — OmniRoute's `normalizeComboRecord` folds the provider
* id INTO the full model string (e.g. "cc/claude-opus-4-7"). The previous
* implementation read `step.providerId` (always `undefined`), so the
* `usableOnly` combo filter silently never dropped anything. These tests pin
* the corrected behavior: the verdict is derived from the `step.model` prefix,
* mirroring `isUsableRawModelId`'s subtract-filter semantics.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { isUsableCombo, type OmniRouteRawCombo } from "../src/index.js";
/** Build a `usable` set bundle for the tests. */
function buildUsable(opts: { aliases?: string[]; canonicals?: string[]; known?: string[] }): {
aliases: Set<string>;
canonicals: Set<string>;
knownAliases: Set<string>;
} {
return {
aliases: new Set(opts.aliases ?? []),
canonicals: new Set(opts.canonicals ?? []),
// knownAliases is the union of every prefix the universe is aware of —
// usable or not. Default to including the usable aliases too.
knownAliases: new Set([...(opts.known ?? []), ...(opts.aliases ?? [])]),
};
}
function combo(models: OmniRouteRawCombo["models"]): OmniRouteRawCombo {
return { id: "c1", name: "Test Combo", models };
}
test("isUsableCombo: member with a usable alias prefix → keep", () => {
const usable = buildUsable({ aliases: ["cc"], known: ["cc", "dead"] });
const c = combo([{ kind: "model", model: "cc/claude-opus-4-7" }]);
assert.equal(isUsableCombo(c, usable), true);
});
test("isUsableCombo: all members known-but-NOT-usable → drop (the C1 regression)", () => {
// Before the fix this returned true unconditionally because step.providerId
// was always undefined. Now the known-but-unusable "dead" prefix is dropped.
const usable = buildUsable({ aliases: ["cc"], known: ["cc", "dead"] });
const c = combo([
{ kind: "model", model: "dead/legacy-model" },
{ kind: "model", model: "dead/another" },
]);
assert.equal(isUsableCombo(c, usable), false);
});
test("isUsableCombo: unknown prefix → keep (cannot prove unroutable)", () => {
const usable = buildUsable({ aliases: ["cc"], known: ["cc", "dead"] });
const c = combo([{ kind: "model", model: "agentrouter/mystery" }]);
assert.equal(isUsableCombo(c, usable), true);
});
test("isUsableCombo: mixed non-usable + usable member → keep", () => {
const usable = buildUsable({ aliases: ["cc"], known: ["cc", "dead"] });
const c = combo([
{ kind: "model", model: "dead/legacy" },
{ kind: "model", model: "cc/claude-opus-4-7" },
]);
assert.equal(isUsableCombo(c, usable), true);
});
test("isUsableCombo: zero members → keep", () => {
const usable = buildUsable({ aliases: ["cc"], known: ["cc"] });
assert.equal(isUsableCombo(combo([]), usable), true);
assert.equal(isUsableCombo(combo(undefined), usable), true);
});
test("isUsableCombo: only combo-ref steps (no resolvable model) → keep", () => {
const usable = buildUsable({ aliases: ["cc"], known: ["cc", "dead"] });
const c = combo([{ kind: "combo-ref", comboName: "nested" }]);
assert.equal(isUsableCombo(c, usable), true);
});
test("isUsableCombo: usable canonical prefix → keep", () => {
const usable = buildUsable({ canonicals: ["anthropic"], known: ["anthropic", "dead"] });
const c = combo([{ kind: "model", model: "anthropic/claude-opus-4-7" }]);
assert.equal(isUsableCombo(c, usable), true);
});

View File

@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"isolatedModules": true,
"forceConsistentCasingInFileNames": true,
"noUncheckedIndexedAccess": false,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules", "tests"]
}

View File

@@ -1,20 +0,0 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
dts: true,
clean: true,
sourcemap: false,
splitting: false,
treeshake: false,
target: "node22",
outDir: "dist",
minify: false,
cjsInterop: true,
// Bundle runtime deps so the .tgz / npm install is self-contained.
// `zod` is required at runtime by the options schema and would otherwise
// need a peer install when the plugin is loaded directly from a file path
// in opencode.jsonc.
noExternal: ["zod"],
});

View File

@@ -1,23 +1,5 @@
# @omniroute/opencode-provider
> ## ⚠️ Deprecated — use [`@omniroute/opencode-plugin`](https://www.npmjs.com/package/@omniroute/opencode-plugin) instead
>
> This package writes a **static** `provider.omniroute` block to `opencode.json` from a hardcoded default model list, so it **drifts behind your live OmniRoute catalog** — adding a model in OmniRoute won't show up in OpenCode until you re-run the generator, and OpenCode Desktop/Web only surfaces a subset of the static models.
>
> **`@omniroute/opencode-plugin`** solves this by fetching `GET /v1/models` from your OmniRoute instance at OpenCode startup, so the model list is always live (see [#3419](https://github.com/diegosouzapw/OmniRoute/issues/3419)). It is now the recommended path.
>
> **One-line migration** — replace the static `provider.omniroute` block in `opencode.json` with a single plugin entry:
>
> ```jsonc
> // opencode.json
> {
> "$schema": "https://opencode.ai/config.json",
> "plugin": ["@omniroute/opencode-plugin"]
> }
> ```
>
> This package is **not removed** and still works for static/offline config generation, but it is no longer actively recommended and won't track new models automatically.
Helper for connecting [OpenCode](https://opencode.ai) to a running [OmniRoute](https://github.com/diegosouzapw/OmniRoute) AI gateway.
The package emits a **schema-valid entry** for `opencode.json` (`https://opencode.ai/config.json`) that delegates the actual runtime to [`@ai-sdk/openai-compatible`](https://www.npmjs.com/package/@ai-sdk/openai-compatible). It does not ship any new HTTP client — OmniRoute already exposes an OpenAI-compatible surface, and OpenCode already speaks it through the AI SDK.
@@ -118,7 +100,7 @@ Exported for completeness. Strips trailing `/`, deduplicates a trailing `/v1`, a
- `OMNIROUTE_PROVIDER_KEY``"omniroute"` (the key used under `provider.*`).
- `OMNIROUTE_PROVIDER_NPM``"@ai-sdk/openai-compatible"` (the runtime delegate).
- `OPENCODE_CONFIG_SCHEMA``"https://opencode.ai/config.json"`.
- `OMNIROUTE_DEFAULT_OPENCODE_MODELS` — readonly list of default model ids.
- `OMNIROUTE_DEFAULT_OPENCODE_MODELS` — readonly list of 4 default model ids.
## Custom model catalog
@@ -128,10 +110,10 @@ import { createOmniRouteProvider } from "@omniroute/opencode-provider";
createOmniRouteProvider({
baseURL: "http://localhost:20128",
apiKey: "sk_omniroute",
models: ["auto", "claude-opus-4-8", "gpt-5.5"],
models: ["auto", "claude-opus-4-7", "gpt-5.5"],
modelLabels: {
auto: "Auto-Combo (recommended)",
"claude-opus-4-8": "Claude Opus 4.8",
"claude-opus-4-7": "Claude Opus 4.7",
"gpt-5.5": "GPT-5.5",
},
});

View File

@@ -9,13 +9,13 @@
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
"@types/node": "^22.19.19",
"tsup": "^8.5.1",
"tsx": "^4.22.3",
"typescript": "^5.9.3"
"@types/node": "^20.19.41",
"tsup": "^8.5.0",
"tsx": "^4.20.0",
"typescript": "^5.9.0"
},
"engines": {
"node": ">=22.22.3"
"node": ">=20.20.2"
}
},
"node_modules/@esbuild/aix-ppc64": {
@@ -857,9 +857,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
"version": "20.19.41",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
"integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1464,9 +1464,9 @@
}
},
"node_modules/tsx": {
"version": "4.22.3",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz",
"integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==",
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.0.tgz",
"integrity": "sha512-8ccZMPD69s1AbKXx0C5ddTNZfNjwV04iIKgjZmKfKxMynEtSYcK0Lh7iQFh53fI5Yu4pb9usgAiqyPmEONaALg==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -1,7 +1,7 @@
{
"name": "@omniroute/opencode-provider",
"version": "0.1.0",
"description": "DEPRECATED — use @omniroute/opencode-plugin instead (it fetches the live OmniRoute /v1/models catalog at startup, so models never drift). This static-config generator still works but is no longer the recommended path. OpenCode provider helper for the OmniRoute AI Gateway.",
"description": "OpenCode provider helper for the OmniRoute AI Gateway. Generates a schema-valid provider entry for opencode.json that delegates the runtime to @ai-sdk/openai-compatible.",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
@@ -46,15 +46,15 @@
},
"homepage": "https://github.com/diegosouzapw/OmniRoute/tree/main/%40omniroute/opencode-provider#readme",
"engines": {
"node": ">=22.22.3"
"node": ">=20.20.2"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/node": "^22.19.19",
"tsup": "^8.5.1",
"tsx": "^4.22.3",
"typescript": "^5.9.3"
"@types/node": "^20.19.41",
"tsup": "^8.5.0",
"tsx": "^4.20.0",
"typescript": "^5.9.0"
}
}

View File

@@ -46,7 +46,6 @@ export const OPENCODE_CONFIG_SCHEMA = "https://opencode.ai/config.json" as const
* with Claude Code passthrough models (`cc/` prefix).
*/
export const OMNIROUTE_DEFAULT_OPENCODE_MODELS = [
"cc/claude-opus-4-8",
"cc/claude-opus-4-7",
"cc/claude-sonnet-4-6",
"cc/claude-haiku-4-5-20251001",
@@ -79,21 +78,6 @@ export interface ModelCapabilities {
tool_call?: boolean;
}
/**
* Default per-model context window sizes (tokens) for the curated default catalog.
* Matches the context lengths used by OmniRoute's provider registry.
*/
export const OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS: Record<string, number> = {
"cc/claude-opus-4-8": 1_000_000,
"cc/claude-opus-4-7": 1_000_000,
"cc/claude-sonnet-4-6": 200_000,
"cc/claude-haiku-4-5-20251001": 200_000,
"claude-opus-4-5-thinking": 200_000,
"claude-sonnet-4-5-thinking": 200_000,
"gemini-3.1-pro-high": 1_000_000,
"gemini-3-flash": 1_000_000,
};
/**
* Default per-model capability hints for the curated default catalog.
*
@@ -102,7 +86,6 @@ export const OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS: Record<string, number> = {
* model via `OmniRouteProviderOptions.modelCapabilities`.
*/
export const OMNIROUTE_DEFAULT_MODEL_CAPABILITIES: Record<string, ModelCapabilities> = {
"cc/claude-opus-4-8": { attachment: true, reasoning: true, temperature: true, tool_call: true },
"cc/claude-opus-4-7": { attachment: true, reasoning: true, temperature: true, tool_call: true },
"cc/claude-sonnet-4-6": { attachment: true, reasoning: true, temperature: true, tool_call: true },
"cc/claude-haiku-4-5-20251001": { attachment: true, temperature: true, tool_call: true },
@@ -129,8 +112,8 @@ export interface OmniRouteProviderOptions {
apiKey: string;
/** Override the display name shown in OpenCode. Default: `"OmniRoute"`. */
displayName?: string;
/** Override the model catalog. Accepts model ids (strings) or live model entries from `fetchLiveModels`. When entries carry a `contextLength`, it is used directly — no hardcoded map needed. */
models?: readonly (string | { id: string; contextLength?: number })[];
/** Override the model catalog. Defaults to `OMNIROUTE_DEFAULT_OPENCODE_MODELS`. */
models?: readonly string[];
/** Optional human-readable labels keyed by model id. Overridden by `modelCapabilities[id].label`. */
modelLabels?: Record<string, string>;
/**
@@ -139,12 +122,6 @@ export interface OmniRouteProviderOptions {
* for custom ids the override is used verbatim.
*/
modelCapabilities?: Record<string, ModelCapabilities>;
/**
* Optional per-model context-length overrides (tokens). Takes precedence
* over the static `OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS` map but is
* superseded by `contextLength` on live model entries passed via `models`.
*/
modelContextLengths?: Record<string, string | number>;
/**
* Primary model for OpenCode (top-level `model` key).
* Emitted as `"omniroute/<id>"`. When omitted the key is not written.
@@ -164,19 +141,6 @@ export interface OpenCodeModelEntry {
reasoning?: boolean;
temperature?: boolean;
tool_call?: boolean;
/**
* Context window limit. OpenCode reads this to determine usable context
* length for compaction, overflow detection, and router decisions.
* Maps to `limit.context` in OpenCode's provider config schema.
*/
limit?: {
/** Maximum context length in tokens (e.g. 200000 for Claude, 1000000 for Gemini). */
context: number;
/** Optional per-request max input tokens. */
input?: number;
/** Optional max output tokens. */
output?: number;
};
}
export interface OpenCodeProviderEntry {
@@ -254,12 +218,7 @@ export function createOmniRouteProvider(options: OmniRouteProviderOptions): Open
const models: Record<string, OpenCodeModelEntry> = {};
const seen = new Set<string>();
for (const raw of modelList) {
const id =
typeof raw === "object" && raw !== null && "id" in raw && typeof (raw as any).id === "string"
? (raw as { id: string }).id.trim()
: typeof raw === "string"
? raw.trim()
: "";
const id = typeof raw === "string" ? raw.trim() : "";
if (!id || seen.has(id)) continue;
seen.add(id);
const defaults = OMNIROUTE_DEFAULT_MODEL_CAPABILITIES[id] ?? {};
@@ -276,22 +235,6 @@ export function createOmniRouteProvider(options: OmniRouteProviderOptions): Open
if (typeof merged.reasoning === "boolean") entry.reasoning = merged.reasoning;
if (typeof merged.temperature === "boolean") entry.temperature = merged.temperature;
if (typeof merged.tool_call === "boolean") entry.tool_call = merged.tool_call;
// Context window: live model entry (from API catalog) > modelContextLengths > static defaults
const liveContext =
typeof raw === "object" && raw !== null
? (raw as { contextLength?: number }).contextLength
: undefined;
const rawContextLength =
liveContext ??
options.modelContextLengths?.[id] ??
OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS[id];
const contextLength =
typeof rawContextLength === "string" ? parseInt(rawContextLength, 10) : rawContextLength;
if (typeof contextLength === "number" && !isNaN(contextLength) && contextLength > 0) {
entry.limit = { context: contextLength };
}
models[id] = entry;
}
@@ -502,8 +445,6 @@ async function fetchJSON<T>(url: string, apiKey: string, timeoutMs: number): Pro
export interface OmniRouteLiveModel {
id: string;
name: string;
/** Context window length in tokens (e.g. 200000 for Claude, 1000000 for Gemini). */
contextLength?: number;
}
/**
@@ -527,7 +468,7 @@ export interface OmniRouteLiveModel {
* const config = buildOmniRouteOpenCodeConfig({
* baseURL: "http://localhost:20128",
* apiKey: "sk_omniroute",
* models, // OmniRouteLiveModel[] — contextLength auto-extracted
* models: models.map((m) => m.id),
* modelLabels: Object.fromEntries(models.map((m) => [m.id, m.name])),
* });
* ```
@@ -573,18 +514,7 @@ export async function fetchLiveModels(
? r.display_name.trim()
: id;
// Extract context_length from OmniRoute's /v1/models response.
// OmniRoute returns context_length in snake_case for both synced
// models (with inputTokenLimit) and custom models; the catalog's
// getDefaultContextFallback also injects it from registry defaults.
const contextLength =
typeof r.context_length === "number" && r.context_length > 0
? r.context_length
: typeof r.max_context_window_tokens === "number" && r.max_context_window_tokens > 0
? r.max_context_window_tokens
: undefined;
models.push({ id, name: name || id, ...(contextLength ? { contextLength } : {}) });
models.push({ id, name: name || id });
}
return models;

View File

@@ -2,9 +2,6 @@ import test from "node:test";
import assert from "node:assert/strict";
import { createServer } from "node:http";
import type { Server } from "node:http";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import {
buildOmniRouteOpenCodeConfig,
@@ -18,7 +15,6 @@ import {
mergeIntoExistingConfig,
normalizeBaseURL,
OMNIROUTE_DEFAULT_MODEL_CAPABILITIES,
OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS,
OMNIROUTE_DEFAULT_OPENCODE_MODELS,
OMNIROUTE_MCP_DEFAULT_SCOPES,
OMNIROUTE_PROVIDER_NPM,
@@ -391,7 +387,6 @@ test("createOmniRouteComboConfig includes optional fields when supplied", () =>
test("OMNIROUTE_DEFAULT_OPENCODE_MODELS includes cc/ prefixed models", () => {
const defaults = [...OMNIROUTE_DEFAULT_OPENCODE_MODELS];
assert.ok(defaults.includes("cc/claude-opus-4-8"));
assert.ok(
defaults.some((m) => m.startsWith("cc/")),
"should have cc/ prefixed models"
@@ -399,106 +394,6 @@ test("OMNIROUTE_DEFAULT_OPENCODE_MODELS includes cc/ prefixed models", () => {
assert.ok(defaults.length >= 7, "should have at least 7 models");
});
test("OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS covers every default model id", () => {
for (const id of OMNIROUTE_DEFAULT_OPENCODE_MODELS) {
const ctx = OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS[id];
assert.ok(
typeof ctx === "number" && ctx > 0,
`default context_length for ${id} missing — should be a positive number`
);
// Sanity: context should be at least 8K, at most 2M tokens
assert.ok(ctx >= 8_000, `${id} context_length ${ctx} seems too low`);
assert.ok(ctx <= 2_000_000, `${id} context_length ${ctx} seems too high`);
}
});
test("createOmniRouteProvider emits limit.context on default model entries", () => {
const provider = createOmniRouteProvider({
baseURL: "http://localhost:20128",
apiKey: "sk_omniroute",
});
const entry = provider.models["cc/claude-opus-4-8"];
assert.ok(entry.limit, "model entry should have a limit field");
assert.equal(entry.limit!.context, 1_000_000);
assert.equal(provider.models["cc/claude-opus-4-7"].limit!.context, 1_000_000);
});
test("createOmniRouteProvider omits limit.context for unknown model ids", () => {
const provider = createOmniRouteProvider({
baseURL: "http://localhost:20128",
apiKey: "sk_omniroute",
models: ["completely-unknown-model"],
});
const entry = provider.models["completely-unknown-model"];
assert.equal(entry.limit, undefined);
});
test("createOmniRouteProvider reads contextLength from a live model entry for ids absent from the static map", () => {
// #3298 regression guard: the static OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS
// map only covers the legacy 8 Claude/Gemini ids. Before this change, any
// other model got `undefined` context (see the test above, string form) and
// OpenCode silently fell back to its 128K internal default. A live model
// entry carrying `contextLength` must now surface as `limit.context`.
const provider = createOmniRouteProvider({
baseURL: "http://localhost:20128",
apiKey: "sk_omniroute",
models: [{ id: "completely-unknown-model", contextLength: 262_144 }],
});
const entry = provider.models["completely-unknown-model"];
assert.ok(entry.limit, "a live contextLength should produce a limit field even for ids absent from the static map");
assert.equal(entry.limit!.context, 262_144);
});
test("createOmniRouteProvider: a live model contextLength wins over the static default map", () => {
// `cc/claude-opus-4-8` has a static default (1_000_000). A live entry carrying
// a different contextLength must take precedence (live > modelContextLengths >
// static defaults).
const provider = createOmniRouteProvider({
baseURL: "http://localhost:20128",
apiKey: "sk_omniroute",
models: [{ id: "cc/claude-opus-4-8", contextLength: 524_288 }],
});
assert.equal(provider.models["cc/claude-opus-4-8"].limit!.context, 524_288);
});
test("createOmniRouteProvider serialises limit.context to JSON", () => {
const provider = createOmniRouteProvider({
baseURL: "http://localhost:20128",
apiKey: "sk_omniroute",
});
const round = JSON.parse(JSON.stringify(provider));
for (const id of OMNIROUTE_DEFAULT_OPENCODE_MODELS) {
const expectedContext = OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS[id];
assert.equal(
round.models[id].limit?.context,
expectedContext,
`${id} should serialise limit.context=${expectedContext}`
);
}
});
test("fetchLiveModels extracts context_length from snake_case field", async () => {
const { url, close } = await startMockServer(() => ({
data: [
{ id: "cc/claude-opus-4-7", name: "Claude Opus 4.7", context_length: 200_000 },
{ id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro", context_length: 1_000_000 },
{ id: "no-context", name: "No Context" },
],
}));
try {
const models = await fetchLiveModels(url, "sk_test");
const claude = models.find((m) => m.id === "cc/claude-opus-4-7");
assert.ok(claude, "claude model should be present");
assert.equal(claude!.contextLength, 200_000);
const gemini = models.find((m) => m.id === "gemini-3.1-pro-high");
assert.equal(gemini!.contextLength, 1_000_000);
const noCtx = models.find((m) => m.id === "no-context");
assert.equal(noCtx!.contextLength, undefined);
} finally {
close();
}
});
test("OMNIROUTE_DEFAULT_MODEL_CAPABILITIES covers every default model id", () => {
for (const id of OMNIROUTE_DEFAULT_OPENCODE_MODELS) {
const caps = OMNIROUTE_DEFAULT_MODEL_CAPABILITIES[id];
@@ -513,8 +408,8 @@ test("createOmniRouteProvider emits default capability flags inline with the mod
baseURL: "http://localhost:20128",
apiKey: "sk_omniroute",
});
const entry = provider.models["cc/claude-opus-4-8"];
assert.equal(entry.name, "cc/claude-opus-4-8");
const entry = provider.models["cc/claude-opus-4-7"];
assert.equal(entry.name, "cc/claude-opus-4-7");
assert.equal(entry.attachment, true);
assert.equal(entry.reasoning, true);
assert.equal(entry.temperature, true);
@@ -670,17 +565,3 @@ test("createOmniRouteModesBlock honours numeric overrides limited to OC schema",
assert.equal(block.build.temperature, 0.7);
assert.equal(block.build.top_p, 0.9);
});
// #3419 — soft-deprecation in favour of @omniroute/opencode-plugin. Guard the
// deprecation notice so it can't be silently dropped while the package is kept
// publishing (it still works; it is just no longer the recommended path).
test("package is marked deprecated in favour of @omniroute/opencode-plugin (#3419)", () => {
const here = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
assert.match(pkg.description, /DEPRECATED/);
assert.match(pkg.description, /@omniroute\/opencode-plugin/);
const readme = readFileSync(join(here, "..", "README.md"), "utf8");
assert.match(readme, /Deprecated/i);
assert.match(readme, /@omniroute\/opencode-plugin/);
});

View File

@@ -8,7 +8,7 @@ export default defineConfig({
sourcemap: false,
splitting: false,
treeshake: true,
target: "node22",
target: "node20",
outDir: "dist",
minify: false,
});

196
AGENTS.md
View File

@@ -3,44 +3,10 @@
## Project
Unified AI proxy/router — route any LLM through one endpoint. Multi-provider support
with **229 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks,
with **160+ providers** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks,
Cohere, NVIDIA, Cerebras, Pollinations, Puter, Cloudflare AI, HuggingFace, DeepInfra,
SambaNova, Meta Llama API, Moonshot AI, AI21 Labs, Databricks, Snowflake, and many more)
with **MCP Server** (69 tools), **A2A v0.3 Protocol**, and **Electron desktop app**.
> **Live counts (v3.8.16)**: providers 229 · MCP tools 69 · MCP scopes 13 · A2A skills 6 ·
> open-sse services 111 · routing strategies 15 · auto-combo scoring factors 12 ·
> DB modules 76 · DB migrations 94 · base tables 17 · search providers 12 ·
> i18n locales 42. **Refresh with `npm run check:docs-all`.**
## Doc Accuracy Discipline (read before writing any doc)
> **If `grep -rn "name" src/ open-sse/ bin/` returns nothing, the name does not exist. Do not document it.**
The recurring failure mode in AI-generated docs is _plausible-but-unverified specifics_.
Every claim in a `.md` file under `docs/` should be verifiable against the source.
**Rules (enforced by `npm run check:fabricated-docs`):**
1. **Never state an API name, endpoint, path, CLI command, or env var without grepping for it first.**
```bash
grep -rn "theName" src/ open-sse/ bin/
# 0 hits → do not document
```
2. **Never write a line count, file size, migration count, provider count, or strategy count from memory.**
```bash
wc -l <file> # exact line count
ls <dir>/*.ts | wc -l # file count
```
3. **Every code example should be copy-pasted from real usage or actually run** — not synthesized.
Link to a real call site (`path:line`) instead of inventing a signature.
4. **Prefer citing real source (`file.ts:line`) over paraphrasing behavior** — verifiable and self-correcting.
5. **A shorter doc that is 100% accurate beats a comprehensive one with fabrications.**
Wrong docs cost more than missing docs, because people trust and act on them.
The script `scripts/check/check-fabricated-docs.mjs` extracts every route path, env var, hook
name, function name, and file reference from `docs/**/*.md` and verifies each one against the
codebase. Run it locally before pushing docs; it runs in CI via `npm run check:docs-all`.
with **MCP Server** (37 tools), **A2A v0.3 Protocol**, and **Electron desktop app**.
## Stack
@@ -49,7 +15,7 @@ codebase. Run it locally before pushing docs; it runs in CI via `npm run check:d
- **Database**: better-sqlite3 (SQLite) — `DATA_DIR` configurable, default `~/.omniroute/`
- **Streaming**: SSE via `open-sse` internal workspace package
- **Styling**: Tailwind CSS v4
- **i18n**: next-intl with 42 locales (`src/i18n/messages/`) — refresh with `ls src/i18n/messages/*.json | wc -l`
- **i18n**: next-intl with 40+ languages
- **Desktop**: Electron (cross-platform: Windows, macOS, Linux)
- **Schemas**: Zod v4 for all API / MCP input validation
@@ -57,32 +23,19 @@ codebase. Run it locally before pushing docs; it runs in CI via `npm run check:d
## Build, Lint, and Test Commands
| Command | Description |
| ----------------------------------- | ------------------------------------------------------------------ |
| `npm run dev` | Start Next.js dev server |
| `npm run build` | Production build: `next build` → `.build/next/` + assemble `dist/` |
| `npm run build:release` | Clean rebuild + HEAD sentinel (`dist/BUILD_SHA`) — use for deploy |
| `npm run start` | Run production build |
| `npm run build:cli` | Build CLI package |
| `npm run lint` | ESLint on all source files |
| `npm run typecheck:core` | TypeScript core type checking |
| `npm run typecheck:noimplicit:core` | Strict checking (no implicit any) |
| `npm run check` | Run lint + test |
| `npm run check:cycles` | Check for circular dependencies |
| `npm run electron:dev` | Run Electron app in dev mode |
| `npm run electron:build` | Build Electron app for current OS |
**Build output layout:**
| Directory | Purpose | Gitignored |
| --------- | -------------------------------------------------- | ---------- |
| `src/` | Application source (TypeScript / TSX) | No |
| `.build/` | Build intermediates (`distDir = .build/next`) | Yes |
| `dist/` | Shippable bundle assembled by `assembleStandalone` | Yes |
The pipeline is a single `next build` pass — intermediates land in `.build/next/`, the
assembled bundle in `dist/`. VPS deploys rsync `dist/` into the remote
`/usr/lib/node_modules/omniroute/app/` directory (VPS image path is unchanged).
| Command | Description |
| ----------------------------------- | --------------------------------- |
| `npm run dev` | Start Next.js dev server |
| `npm run build` | Production build (isolated) |
| `npm run start` | Run production build |
| `npm run build:cli` | Build CLI package |
| `npm run lint` | ESLint on all source files |
| `npm run typecheck:core` | TypeScript core type checking |
| `npm run typecheck:noimplicit:core` | Strict checking (no implicit any) |
| `npm run check` | Run lint + test |
| `npm run check:cycles` | Check for circular dependencies |
| `npm run electron:dev` | Run Electron app in dev mode |
| `npm run electron:build` | Build Electron app for current OS |
### Running Tests
@@ -178,7 +131,7 @@ Always run `prettier --write` on changed files.
### Data Layer (`src/lib/db/`)
All persistence uses SQLite through **76 domain-specific modules** in `src/lib/db/`. Top modules:
All persistence uses SQLite through **45+ domain-specific modules** in `src/lib/db/`. Top modules:
- Core: `core.ts`, `migrationRunner.ts`, `encryption.ts`, `stateReset.ts`
- Providers / catalog: `providers.ts`, `models.ts`, `providerLimits.ts`, `compressionAnalytics.ts`
@@ -188,17 +141,17 @@ All persistence uses SQLite through **76 domain-specific modules** in `src/lib/d
- Storage: `backup.ts`, `cleanup.ts`, `jsonMigration.ts`, `healthCheck.ts`, `databaseSettings.ts`
- Extension modules: `evals.ts`, `webhooks.ts`, `reasoningCache.ts`, `readCache.ts`, `tierConfig.ts`, `compressionCombos.ts`, `compressionScheduler.ts`, `batches.ts`, `files.ts`, `syncTokens.ts`, `proxies.ts`, `oneproxy.ts`, `upstreamProxy.ts`, `versionManager.ts`, `cliToolState.ts`, `prompts.ts`, `detailedLogs.ts`, `contextHandoffs.ts`, `compression.ts`, `stats.ts`
Live count: `ls src/lib/db/*.ts | wc -l` (currently 76). Drift detection: `npm run check:docs-counts`.
Schema migrations live in `db/migrations/` (**94 files** as of v3.8.16) and run via `migrationRunner.ts`.
Live count: `ls src/lib/db/*.ts | wc -l` (currently 45).
Schema migrations live in `db/migrations/` (55 files) and run via `migrationRunner.ts`.
`src/lib/localDb.ts` is a **re-export layer only** — never add logic there.
#### DB Internals
- **`core.ts`**: `getDbInstance()` returns a singleton `better-sqlite3` instance with WAL
journaling. `SCHEMA_SQL` defines **17 base tables** (verify with `grep -c "CREATE TABLE" src/lib/db/core.ts` minus 1 for the bookkeeping `_omniroute_migrations` table). Helpers: `rowToCamel`, `encryptConnectionFields`.
journaling. `SCHEMA_SQL` defines 15 base tables. Helpers: `rowToCamel`, `encryptConnectionFields`.
- **`migrationRunner.ts`**: Applies versioned SQL files from `db/migrations/` inside transactions.
Tracks applied migrations in `_omniroute_migrations` table.
- **Migrations**: 94 files (`001_initial_schema.sql` → `094_*.sql`).
- **Migrations**: 55 files (`001_initial_schema.sql``055_command_code_auth_sessions.sql`).
Each migration is idempotent and runs in a transaction. Live count: `ls src/lib/db/migrations/*.sql | wc -l`.
- **Domain modules** import `getDbInstance()` from `core.ts` for all CRUD operations.
Each module owns a specific table/set of tables (e.g., `providers.ts``provider_connections`,
@@ -214,19 +167,19 @@ Route → CORS preflight → Body validation (Zod) → Optional auth (extractApi
→ API key policy enforcement (enforceApiKeyPolicy) → Handler delegation (open-sse)
```
| Route | Handler | Notes |
| ------------------------------- | ------------------------- | ------------------------------------------------------------- |
| `chat/completions/route.ts` | `handleChat()` | + prompt injection guard (clones request) |
| `responses/route.ts` | `handleChat()` (unified) | Responses API format |
| `embeddings/route.ts` | `handleEmbedding()` | Model listing + creation |
| `images/generations/route.ts` | `handleImageGeneration()` | Model listing + creation |
| `audio/transcriptions/route.ts` | audio handler | Multipart form data |
| `audio/speech/route.ts` | TTS handler | Binary audio response |
| `videos/generations/route.ts` | video handler | ComfyUI/SD WebUI |
| `music/generations/route.ts` | music handler | ComfyUI workflows |
| `moderations/route.ts` | moderation handler | Content safety |
| `rerank/route.ts` | rerank handler | Document relevance |
| `search/route.ts` | search handler | Web search (12 providers per `open-sse/handlers/search.ts:6`) |
| Route | Handler | Notes |
| ------------------------------- | ------------------------- | ----------------------------------------- |
| `chat/completions/route.ts` | `handleChat()` | + prompt injection guard (clones request) |
| `responses/route.ts` | `handleChat()` (unified) | Responses API format |
| `embeddings/route.ts` | `handleEmbedding()` | Model listing + creation |
| `images/generations/route.ts` | `handleImageGeneration()` | Model listing + creation |
| `audio/transcriptions/route.ts` | audio handler | Multipart form data |
| `audio/speech/route.ts` | TTS handler | Binary audio response |
| `videos/generations/route.ts` | video handler | ComfyUI/SD WebUI |
| `music/generations/route.ts` | music handler | ComfyUI workflows |
| `moderations/route.ts` | moderation handler | Content safety |
| `rerank/route.ts` | rerank handler | Document relevance |
| `search/route.ts` | search handler | Web search (5 providers) |
**No global Next.js middleware file** — interception is route-specific. Auth is optional
(controlled by `REQUIRE_API_KEY` env). Prompt injection guard is unique to chat completions.
@@ -336,8 +289,7 @@ Includes request/response translators with helpers for image handling.
### Services (`open-sse/services/`)
111 service modules in `open-sse/services/` (top-level only; 171 including sub-dirs like `autoCombo/` and `compression/`). Refresh: `ls open-sse/services/*.ts | wc -l`. Key modules:
`combo.ts` (routing engine), `usage.ts`, `tokenRefresh.ts`,
36+ service modules including: `combo.ts` (routing engine), `usage.ts`, `tokenRefresh.ts`,
`rateLimitManager.ts`, `accountFallback.ts`, `sessionManager.ts`, `wildcardRouter.ts`,
`autoCombo/`, `intentClassifier.ts`, `taskAwareRouter.ts`, `thinkingBudget.ts`,
`contextManager.ts`, `modelDeprecation.ts`, `modelFamilyFallback.ts`,
@@ -378,8 +330,8 @@ Modular prompt compression that runs proactively before the existing reactive co
and iterates through targets in order until one succeeds or all fail.
- **`resolveComboTargets()`**: Expands a combo configuration into an ordered array of
`ResolvedComboTarget[]`, each specifying provider + model + account + credentials.
- **Strategies** (15): priority, weighted, fill-first, round-robin, P2C, random, least-used, reset-aware (v3.8),
reset-window, cost-optimized, strict-random, auto, lkgp, context-optimized, context-relay. Source: `ROUTING_STRATEGY_VALUES` in `src/shared/constants/routingStrategies.ts`.
- **Strategies** (14): priority, weighted, fill-first, round-robin, P2C, random, least-used, reset-aware (v3.8),
cost-optimized, strict-random, auto, lkgp, context-optimized, context-relay.
- Each target calls **`handleSingleModel()`** which wraps `handleChatCore()` with
per-target error handling and circuit breaker checks.
@@ -391,7 +343,7 @@ Policy engine modules: `policyEngine.ts`, `comboResolver.ts`, `costRules.ts`,
### MCP Server (`open-sse/mcp-server/`)
69 tools across 10 tool files (advancedTools: 5, agentSkillTools: 3, compressionTools: 5, gamificationTools: 8, memoryTools: 3, notionTools: 6, obsidianTools: 22, pluginTools: 8, poolTools: 5, skillTools: 4), 3 transports (stdio / SSE / Streamable HTTP). Scoped auth (13 scopes — see `OMNIROUTE_MCP_SCOPES` in `open-sse/mcp-server/README.md:51`), Zod schemas. See [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md).
37 tools (30 base + 3 memory + 4 skills), 3 transports (stdio / SSE / Streamable HTTP). Scoped auth (~13 scopes), Zod schemas. See [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md).
**Core tools** (20): get_health, list_combos, get_combo_metrics, switch_combo, check_quota,
route_request, cost_report, list_models_catalog, web_search, simulate_route, set_budget_guard,
@@ -417,7 +369,7 @@ handler: async (args) => {...} }`. Zod validates inputs before the handler fires
`createMcpServer()` wires all tool sets; `startMcpStdio()` launches the stdio transport.
- **Transports**: stdio (CLI `omniroute --mcp`), SSE (`/api/mcp/sse`), Streamable HTTP
(`/api/mcp/stream`). All share the same tool/scope engine.
- **Scopes** (13): Control which tool categories an API key can access. Enforcement happens
- **Scopes** (10): Control which tool categories an API key can access. Enforcement happens
before handler dispatch.
- **Audit**: Every tool invocation is logged to SQLite (`mcp_audit` table) with tool name,
args, success/failure, API key attribution, and timestamp.
@@ -426,7 +378,7 @@ handler: async (args) => {...} }`. Zod validates inputs before the handler fires
JSON-RPC 2.0, SSE streaming, Task Manager with TTL cleanup.
Agent Card at `/.well-known/agent.json`.
Skills (6): `smartRouting.ts`, `quotaManagement.ts`, `providerDiscovery.ts`, `costAnalysis.ts`, `healthReport.ts`, `listCapabilities.ts`.
Skills (5): `smartRouting.ts`, `quotaManagement.ts`, `providerDiscovery.ts`, `costAnalysis.ts`, `healthReport.ts`.
#### A2A Internals
@@ -527,49 +479,31 @@ Cloudflare Quick/Named, ngrok, Tailscale Funnel. See [`docs/ops/TUNNELS_GUIDE.md
For any non-trivial change, read the matching deep-dive first:
| Area | Doc |
| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| Repo navigation | [`docs/architecture/REPOSITORY_MAP.md`](docs/architecture/REPOSITORY_MAP.md) |
| Architecture | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
| Engineering reference | [`docs/architecture/CODEBASE_DOCUMENTATION.md`](docs/architecture/CODEBASE_DOCUMENTATION.md) |
| Auto-Combo (12-factor, 15 strategies) | [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md) |
| Resilience (3 layers) | [`docs/architecture/RESILIENCE_GUIDE.md`](docs/architecture/RESILIENCE_GUIDE.md) |
| Skills | [`docs/frameworks/SKILLS.md`](docs/frameworks/SKILLS.md) |
| Memory | [`docs/frameworks/MEMORY.md`](docs/frameworks/MEMORY.md) |
| Cloud agents | [`docs/frameworks/CLOUD_AGENT.md`](docs/frameworks/CLOUD_AGENT.md) |
| Guardrails | [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md) |
| Evals | [`docs/frameworks/EVALS.md`](docs/frameworks/EVALS.md) |
| Compliance | [`docs/security/COMPLIANCE.md`](docs/security/COMPLIANCE.md) |
| Webhooks | [`docs/frameworks/WEBHOOKS.md`](docs/frameworks/WEBHOOKS.md) |
| Authz | [`docs/architecture/AUTHZ_GUIDE.md`](docs/architecture/AUTHZ_GUIDE.md) |
| Stealth | [`docs/security/STEALTH_GUIDE.md`](docs/security/STEALTH_GUIDE.md) |
| Reasoning replay | [`docs/routing/REASONING_REPLAY.md`](docs/routing/REASONING_REPLAY.md) |
| Agent protocols (A2A / ACP / Cloud) | [`docs/frameworks/AGENT_PROTOCOLS_GUIDE.md`](docs/frameworks/AGENT_PROTOCOLS_GUIDE.md) |
| MCP server | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md) |
| A2A server | [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md) |
| API reference | [`docs/reference/API_REFERENCE.md`](docs/reference/API_REFERENCE.md) + [`docs/reference/openapi.yaml`](docs/reference/openapi.yaml) |
| Provider catalog (auto-generated) | [`docs/reference/PROVIDER_REFERENCE.md`](docs/reference/PROVIDER_REFERENCE.md) |
| Tunnels | [`docs/ops/TUNNELS_GUIDE.md`](docs/ops/TUNNELS_GUIDE.md) |
| Electron desktop | [`docs/guides/ELECTRON_GUIDE.md`](docs/guides/ELECTRON_GUIDE.md) |
| Release flow | [`docs/ops/RELEASE_CHECKLIST.md`](docs/ops/RELEASE_CHECKLIST.md) |
---
## Fork / Upstream Workflow
This repository is a fork of `diegosouzapw/OmniRoute`. Keep fork-only operational
changes (for example GHCR image publishing, personal deployment workflows, or local
automation) out of upstream contribution PRs.
When preparing a PR for upstream, always start the work branch from `upstream/main`,
not from this fork's `main`:
```bash
git fetch upstream
git switch -c <branch-name> upstream/main
```
Only cherry-pick or reapply the changes intended for the upstream PR.
| Area | Doc |
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| Repo navigation | [`docs/architecture/REPOSITORY_MAP.md`](docs/architecture/REPOSITORY_MAP.md) |
| Architecture | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
| Engineering reference | [`docs/architecture/CODEBASE_DOCUMENTATION.md`](docs/architecture/CODEBASE_DOCUMENTATION.md) |
| Auto-Combo (9-factor, 14 strategies) | [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md) |
| Resilience (3 layers) | [`docs/architecture/RESILIENCE_GUIDE.md`](docs/architecture/RESILIENCE_GUIDE.md) |
| Skills | [`docs/frameworks/SKILLS.md`](docs/frameworks/SKILLS.md) |
| Memory | [`docs/frameworks/MEMORY.md`](docs/frameworks/MEMORY.md) |
| Cloud agents | [`docs/frameworks/CLOUD_AGENT.md`](docs/frameworks/CLOUD_AGENT.md) |
| Guardrails | [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md) |
| Evals | [`docs/frameworks/EVALS.md`](docs/frameworks/EVALS.md) |
| Compliance | [`docs/security/COMPLIANCE.md`](docs/security/COMPLIANCE.md) |
| Webhooks | [`docs/frameworks/WEBHOOKS.md`](docs/frameworks/WEBHOOKS.md) |
| Authz | [`docs/architecture/AUTHZ_GUIDE.md`](docs/architecture/AUTHZ_GUIDE.md) |
| Stealth | [`docs/security/STEALTH_GUIDE.md`](docs/security/STEALTH_GUIDE.md) |
| Reasoning replay | [`docs/routing/REASONING_REPLAY.md`](docs/routing/REASONING_REPLAY.md) |
| Agent protocols (A2A / ACP / Cloud) | [`docs/frameworks/AGENT_PROTOCOLS_GUIDE.md`](docs/frameworks/AGENT_PROTOCOLS_GUIDE.md) |
| MCP server | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md) |
| A2A server | [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md) |
| API reference | [`docs/reference/API_REFERENCE.md`](docs/reference/API_REFERENCE.md) + [`docs/reference/openapi.yaml`](docs/reference/openapi.yaml) |
| Provider catalog (auto-generated) | [`docs/reference/PROVIDER_REFERENCE.md`](docs/reference/PROVIDER_REFERENCE.md) |
| Tunnels | [`docs/ops/TUNNELS_GUIDE.md`](docs/ops/TUNNELS_GUIDE.md) |
| Electron desktop | [`docs/guides/ELECTRON_GUIDE.md`](docs/guides/ELECTRON_GUIDE.md) |
| Release flow | [`docs/ops/RELEASE_CHECKLIST.md`](docs/ops/RELEASE_CHECKLIST.md) |
---

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@ npm run build # Production build (Next.js 16 standalone)
npm run lint # ESLint (0 errors expected; warnings are pre-existing)
npm run typecheck:core # TypeScript check (should be clean)
npm run typecheck:noimplicit:core # Strict check (no implicit any)
npm run test:coverage # Unit tests + coverage gate (60/60/60/60 — statements/lines/functions/branches)
npm run test:coverage # Unit tests + coverage gate (75/75/75/70 — statements/lines/functions/branches)
npm run check # lint + test combined
npm run check:cycles # Detect circular dependencies
```
@@ -47,7 +47,7 @@ For full test matrix, see `CONTRIBUTING.md` → "Running Tests". For deep archit
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
| Database | `src/lib/db/` | SQLite domain modules (45+ files, 55 migrations) |
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
| MCP Server | `open-sse/mcp-server/` | 43 tools (30 base + 3 memory + 4 skills + 6 notion), 3 transports, ~13 scopes |
| MCP Server | `open-sse/mcp-server/` | 37 tools (30 base + 3 memory + 4 skills), 3 transports, ~13 scopes |
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
| Skills | `src/lib/skills/` | Extensible skill framework |
| Memory | `src/lib/memory/` | Persistent conversational memory |
@@ -303,17 +303,6 @@ connection continue serving other models.
4. Add OAuth/credentials handling if needed (`src/lib/oauth/providers/`)
5. Tests + document in `docs/frameworks/CLOUD_AGENT.md`
### Adding a New Embedded Service
1. Create installer in `src/lib/services/installers/{name}.ts` modeled on `ninerouter.ts` (use `runNpm` from `installers/utils.ts` — no shell interpolation, hard rule #13).
2. Register the service in `src/lib/services/bootstrap.ts` (add to `SERVICES[]` array and extend `buildSpawnArgsFactory()`).
3. Add a DB seed row for the new service in `src/lib/db/migrations/` (`version_manager` table, `status='not_installed'`, `auto_start=0`).
4. Create 7 API endpoints under `src/app/api/services/{name}/` (`_lib.ts`, `install`, `start`, `stop`, `restart`, `update`, `status`, `auto-start`). All delegate errors through `createErrorResponse()`. The shared `logs` endpoint is already wired via `[name]/logs/route.ts`.
5. Verify `/api/services/` is in `LOCAL_ONLY_API_PREFIXES` in `src/server/authz/routeGuard.ts`; add a test asserting `isLocalOnlyPath()` returns `true` for the new prefix if you add one (hard rule #17).
6. Add a UI tab in `src/app/(dashboard)/dashboard/providers/services/tabs/` reusing `ServiceStatusCard`, `ServiceLifecycleButtons`, `ServiceLogsPanel`.
7. Document in `docs/frameworks/EMBEDDED-SERVICES.md` (update §1 service table + §4 API reference) and `docs/reference/openapi.yaml`.
8. Write tests: unit (`tests/unit/services/`), integration (`tests/integration/services/`, gated by `RUN_SERVICES_INT=1`), and update `docs/ops/RELEASE_CHECKLIST.md` smoke section.
### Adding a New Guardrail / Eval / Skill / Webhook event
- Guardrail: `src/lib/guardrails/` → docs: `docs/security/GUARDRAILS.md`
@@ -352,7 +341,6 @@ For any non-trivial change, read the matching deep-dive first:
| API reference + OpenAPI | `docs/reference/API_REFERENCE.md` + `docs/reference/openapi.yaml` |
| Provider catalog (auto-generated) | `docs/reference/PROVIDER_REFERENCE.md` |
| Release flow | `docs/ops/RELEASE_CHECKLIST.md` |
| Embedded services | `docs/frameworks/EMBEDDED-SERVICES.md` |
---
@@ -366,23 +354,14 @@ For any non-trivial change, read the matching deep-dive first:
| E2E (Playwright) | `npm run test:e2e` |
| Protocol E2E (MCP+A2A) | `npm run test:protocols:e2e` |
| Ecosystem | `npm run test:ecosystem` |
| Coverage gate | `npm run test:coverage` (60/60/60/60 — statements/lines/functions/branches) |
| Coverage gate | `npm run test:coverage` (75/75/75/70 — statements/lines/functions/branches) |
| Coverage report | `npm run coverage:report` |
**PR rule**: If you change production code in `src/`, `open-sse/`, `electron/`, or `bin/`, you must include or update tests in the same PR.
**Test layer preference**: unit first → integration (multi-module or DB state) → e2e (UI/workflow only). Encode bug reproductions as automated tests before or alongside the fix.
**Both test runners must pass**: `npm run test:unit` (Node native — most tests) AND `npm run test:vitest` (MCP server, autoCombo, cache) cover **non-overlapping files**. Both must be green before merging. A PR where only one suite passes may silently ship broken MCP tools or routing regressions.
**Bug fix / issue triage protocol (Hard Rule #18)**: Every fix for a reported issue must be validated by one of the following — no exceptions:
1. **TDD (preferred)** — write a failing test reproducing the bug → fix it → confirm the test passes. The test becomes the permanent regression guard. Touch only the files the test proves need changing; nothing more.
2. **Real-environment test (when TDD is not possible)** — deploy to the production VPS (`root@192.168.0.15`) and run a documented live test. Record the exact command + result in the PR description. Applies to: OAuth upstream flows, Cloudflare/WS upstream behavior, UI-only regressions, hardware-dependent behavior.
3. "It worked locally without a test" does not count. A fix without a test or a VPS validation record is not a fix — it is a guess.
Why this matters: fixing bug A while opening bug B is worse than not fixing at all. The TDD/VPS gate enforces surgical scope — you touch only what the failing test proves is broken. Examples where this paid off: #3090 (claude-web 403), #3113 (WS HTTP fallback), #3052 (heap-guard auto-calibration).
**Copilot coverage policy**: When a PR changes production code and coverage is below 60% (statements/lines/functions/branches), do not just report — add or update tests, rerun the coverage gate, then ask for confirmation. Include commands run, changed test files, and final coverage result in the PR report.
**Copilot coverage policy**: When a PR changes production code and coverage is below 75% (statements/lines/functions) or 70% (branches), do not just report — add or update tests, rerun the coverage gate, then ask for confirmation. Include commands run, changed test files, and final coverage result in the PR report.
---
@@ -428,26 +407,11 @@ git push -u origin feat/your-feature
6. Never silently swallow errors in SSE streams
7. Always validate inputs with Zod schemas
8. Always include tests when changing production code
9. Coverage must stay ≥60% (statements, lines, functions, branches).
9. Coverage must stay ≥75% (statements, lines, functions) / ≥70% (branches). Current measured: ~82%.
10. Never bypass Husky hooks (`--no-verify`, `--no-gpg-sign`) without explicit operator approval.
11. Never embed public upstream OAuth client_id/secret or Firebase Web keys as string literals — always go through `resolvePublicCred()` (`open-sse/utils/publicCreds.ts`). See `docs/security/PUBLIC_CREDS.md`.
12. Never return raw `err.stack` / `err.message` in HTTP / SSE / executor responses — always route through `buildErrorBody()` or `sanitizeErrorMessage()` (`open-sse/utils/error.ts`). See `docs/security/ERROR_SANITIZATION.md`.
13. Never string-interpolate external paths or runtime values into shell scripts passed to `exec()`/`spawn()` — pass via the `env` option instead. Reference: `src/mitm/cert/install.ts::updateNssDatabases`.
14. Never dismiss a CodeQL / Secret-Scanning alert without (a) first checking the pattern docs above to see if the helper applies, and (b) recording the technical justification in the dismissal comment. Precedent: `js/stack-trace-exposure` raised on callsites that already route through `sanitizeErrorMessage()` is a known CodeQL limitation (custom sanitizers not recognized) — dismiss as `false positive` referencing `docs/security/ERROR_SANITIZATION.md`.
15. Never expose routes that spawn child processes (`/api/mcp/`, `/api/cli-tools/runtime/`) without `isLocalOnlyPath()` classification in `src/server/authz/routeGuard.ts`. Loopback enforcement happens unconditionally before any auth check — leaked JWT via tunnel cannot trigger process spawning. See `docs/security/ROUTE_GUARD_TIERS.md`.
16. Never include `Co-Authored-By` trailers that credit an AI assistant, LLM, or automation account (e.g. names containing "Claude", "GPT", "Copilot", "Bot"; emails at `anthropic.com` / `openai.com` / bot-owned `noreply.github.com` addresses). Such trailers route attribution to the bot account on GitHub, hiding the real author (`diegosouzapw`) in PR history. Human collaborators — including upstream PR authors and issue reporters being ported into OmniRoute — MAY and SHOULD be credited with standard `Co-authored-by: Name <email>` trailers; the upstream-port workflows (`/port-upstream-features`, `/port-upstream-issues`) depend on this.
17. Never expose routes under `/api/services/` or `/dashboard/providers/services/*/embed/` without `isLocalOnlyPath()` classification in `src/server/authz/routeGuard.ts`. These routes can spawn child processes (`npm install`, `node`). Loopback enforcement happens unconditionally before any auth check — a leaked JWT via tunnel cannot trigger process spawning. See `docs/security/ROUTE_GUARD_TIERS.md`.
18. Every bug fix must be validated before shipping: a failing-then-passing unit/integration test (TDD) OR a documented live test on the production VPS (192.168.0.15). A fix without either is not merged. See Testing → "Bug fix / issue triage protocol" for the full decision tree.
---
## PII & Stream Sanitization Learnings
### 1. Regex Security (ReDoS)
All regex patterns matching variable-length strings (e.g. IPv6 address, credit cards) must use strictly bounded, non-overlapping sequences (e.g., limit occurrences with bounded ranges `{1,7}`) to prevent catastrophic backtracking when processing untrusted inputs.
### 2. SSE Snapshot Handling
When parsing streaming LLM responses (e.g. Responses API), check if a chunk represents a final snapshot (`done` or `completed` events). Snapshot text must be sanitized directly as a standalone string (bypassing rolling delta buffers) to prevent text duplication at the end of the stream.
### 3. Database Handles in Tests
Ensure that any unit tests that trigger database migrations or establish SQLite connections call `resetDbInstance()` and properly clean up/close all DB handles in a `test.after(...)` hook. Failure to release database connection handles will cause Node's native test runner to hang indefinitely.
16. Never include `Co-Authored-By` trailers in commit messages. Commits must appear solely under the repository owner's Git identity (`diegosouzapw`). The `Co-Authored-By: Claude …` line causes GitHub to attribute commits to the `claude` Anthropic account, hiding the real author in the PR history.

View File

@@ -8,7 +8,7 @@ Thank you for your interest in contributing! This guide covers everything you ne
### Prerequisites
- **Node.js** `>=22.22.3 <23`, or `>=24.0.0 <27` (recommended: 24 LTS)
- **Node.js** `>=20.20.2 <21`, `>=22.22.2 <23`, or `>=24.0.0 <25` (recommended: 24 LTS)
- **npm** 10+
- **Git**
@@ -59,40 +59,13 @@ These settings are stored in the database and persist across restarts, overridin
npm run dev
# Production build
npm run build # next build → .build/next/ then assembleStandalone → dist/
npm run build
npm run start
# Release build (clean rebuild + HEAD sentinel — required for deploy)
npm run build:release # rm -rf .build dist && build + writes dist/BUILD_SHA
# Common port configuration
PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev
```
### Build Output Layout
| Directory | Contents | Tracked |
| ---------- | ------------------------------------------- | ------- |
| `src/` | Application source (TypeScript / TSX) | Yes |
| `.build/` | Intermediates — `next build` output (gitignored, `distDir = .build/next`) | No |
| `dist/` | Shippable bundle — assembled by `assembleStandalone` (gitignored) | No |
The build pipeline is a single pass:
```
npm run build
└─ next build → .build/next/standalone (Next.js output)
└─ assembleStandalone() (copies standalone + static + public + native assets)
└─ output: dist/ (server.js, .next/static/, public/, node_modules/)
```
`npm run build:release` additionally cleans both directories first and writes
`dist/BUILD_SHA` (= `git rev-parse --short HEAD`) as a deploy integrity sentinel.
> **VPS deploy note:** the remote image directory `/usr/lib/node_modules/omniroute/app/`
> is unchanged. The deploy skills rsync the contents of `dist/` into it.
> Only the in-repo build output path moved (`app/` → `dist/`).
Default URLs:
- **Dashboard**: `http://localhost:20128/dashboard`
@@ -259,25 +232,18 @@ tests/
├── translator/ # Translator-specific tests
└── load/ # Load tests
docs/
├── adr/ # Architecture Decision Records
├── architecture/ # System architecture & resilience
├── comparison/ # OmniRoute vs alternatives
├── compression/ # Compression guides & rules
├── dev/ # Development guides
├── diagrams/ # Architecture diagrams
├── frameworks/ # MCP, A2A, OpenCode, Memory, Skills
├── guides/ # User guide, Docker, setup, troubleshooting
├── i18n/ # Internationalized README translations
├── marketing/ # Marketing materials
── ops/ # Deployment, proxy, coverage, releases
├── providers/ # Provider-specific docs
├── reference/ # API reference, env vars, CLI tools, free tiers
├── releases/ # Release notes
├── routing/ # Auto-combo engine, reasoning replay
├── screenshots/ # Dashboard screenshots
├── security/ # Guardrails, compliance, stealth, tokens
└── specs/ # Design specs
docs/ # Documentation
├── ARCHITECTURE.md # System architecture
├── API_REFERENCE.md # All endpoints
├── USER_GUIDE.md # Provider setup, CLI integration
├── TROUBLESHOOTING.md # Common issues
├── MCP-SERVER.md # MCP server (25 tools)
├── A2A-SERVER.md # A2A agent protocol
├── AUTO-COMBO.md # Auto-combo engine
├── CLI-TOOLS.md # CLI tools integration
├── COVERAGE_PLAN.md # Test coverage improvement plan
├── openapi.yaml # OpenAPI specification
── adr/ # Architecture Decision Records
```
---
@@ -341,10 +307,6 @@ Write unit tests in `tests/unit/` covering at minimum:
Releases are managed via the `/generate-release` workflow. When a new GitHub Release is created, the package is **automatically published to npm** via GitHub Actions.
For VPS deploys, use `npm run build:release` (not `npm run build`) — it performs a clean
rebuild, assembles the bundle into `dist/`, and writes the `dist/BUILD_SHA` sentinel.
Then use the `/deploy-vps-*-cc` skills which rsync `dist/` to the remote `app/` directory.
---
## Getting Help

View File

@@ -1,52 +1,26 @@
# ── Common base with runtime deps ──────────────────────────────────────────
FROM node:24-trixie-slim AS base
FROM node:26.1.0-trixie-slim AS builder
WORKDIR /app
RUN --mount=type=cache,target=/var/cache/apt,sharing=shared \
--mount=type=cache,target=/var/lib/apt/lists,sharing=shared \
apt-get update \
RUN apt-get update \
&& apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# ── Builder ────────────────────────────────────────────────────────────────
FROM base AS builder
# Build tools for native module compilation
# apt-get update needed here because base's rm -rf clears the shared cache
RUN --mount=type=cache,target=/var/cache/apt,sharing=shared \
--mount=type=cache,target=/var/lib/apt/lists,sharing=shared \
apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package*.json ./
COPY scripts/build/postinstall.mjs ./scripts/build/postinstall.mjs
COPY scripts/build/postinstallSupport.mjs ./scripts/build/postinstallSupport.mjs
COPY scripts/build/native-binary-compat.mjs ./scripts/build/native-binary-compat.mjs
ENV NPM_CONFIG_LEGACY_PEER_DEPS=true
# --ignore-scripts blocks broad dependency install/postinstall hooks, closing
# the supply-chain attack surface where a transitive dep can run arbitrary code
# at install time. better-sqlite3 still needs a native binding for the target
# platform, so rebuild and smoke-test only that known runtime dependency below.
#
# We REQUIRE a committed package-lock.json so resolved dependency versions
# are reproducible.
RUN test -f package-lock.json \
|| (echo "package-lock.json is required for reproducible Docker builds" >&2 && exit 1)
RUN --mount=type=cache,target=/root/.npm \
npm ci --no-audit --no-fund --legacy-peer-deps --ignore-scripts \
&& npm rebuild better-sqlite3 \
&& node -e "require('better-sqlite3')(':memory:').close()"
# Use Turbopack for significant build speedup
ENV OMNIROUTE_USE_TURBOPACK=1
RUN if [ -f package-lock.json ]; then \
npm ci --no-audit --no-fund --legacy-peer-deps; \
else \
npm install --no-audit --no-fund --legacy-peer-deps; \
fi
COPY . ./
RUN --mount=type=cache,target=/app/.build/next/cache \
mkdir -p /app/data && npm run build
RUN mkdir -p /app/data && npm run build -- --webpack
# ── Runner base ────────────────────────────────────────────────────────────
FROM base AS runner-base
FROM node:26.1.0-trixie-slim AS runner-base
WORKDIR /app
LABEL org.opencontainers.image.title="omniroute" \
org.opencontainers.image.description="Unified AI proxy — route any LLM through one endpoint" \
@@ -57,113 +31,54 @@ LABEL org.opencontainers.image.title="omniroute" \
ENV NODE_ENV=production
ENV PORT=20128
ENV HOSTNAME=0.0.0.0
ENV OMNIROUTE_MEMORY_MB=1024
ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_MEMORY_MB}"
ENV NODE_OPTIONS="--max-old-space-size=256"
# Data directory inside Docker — must match the volume mount in docker-compose.yml
ENV DATA_DIR=/app/data
RUN apt-get update \
&& apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /app/data
# `npm run build` (build-next-isolated → assembleStandalone) bundles ALL runtime
# files into .build/next/standalone/ — .next, node_modules, migrations, scripts,
# docs, and the previously hand-COPY'd modules below (@swc/helpers, pino-*, split2,
# migrations). assembleStandalone copies them straight from the builder's
# node_modules, so they are present regardless of NFT/Turbopack trace behaviour.
# The old per-module overrides were therefore pure duplication and were removed
# (build-output-isolation cleanup). See scripts/build/assembleStandalone.mjs
# (EXTRA_MODULE_ENTRIES) for the single source of truth.
COPY --from=builder /app/.build/next/standalone ./
# better-sqlite3 is the one exception still copied explicitly: assembleStandalone
# only syncs its native build/ dir; the JS wrapper (lib/, package.json) is left to
# Next.js tracing. bootstrap-env requires SQLite BEFORE the standalone server
# starts, so guarantee the complete package independent of trace behaviour.
COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
# migrations land at <standalone>/migrations via assembleStandalone; point the runtime at them.
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/.next/standalone ./
# Explicitly copy @swc/helpers — not always traced by standalone output but needed at runtime
COPY --from=builder /app/node_modules/@swc/helpers ./node_modules/@swc/helpers
# Explicitly copy pino transport dependencies — pino spawns a worker that requires
# pino-abstract-transport at runtime; Next.js standalone trace does not capture it (#449)
COPY --from=builder /app/node_modules/pino-abstract-transport ./node_modules/pino-abstract-transport
COPY --from=builder /app/node_modules/pino-pretty ./node_modules/pino-pretty
COPY --from=builder /app/node_modules/split2 ./node_modules/split2
# Migration SQL files are read via fs.readFileSync at runtime and are NOT
# traced by Next.js standalone output — copy them explicitly.
COPY --from=builder /app/src/lib/db/migrations ./migrations
ENV OMNIROUTE_MIGRATIONS_DIR=/app/migrations
# MITM server.cjs is spawned at runtime via child_process — not traced by nft
COPY --from=builder /app/src/mitm/server.cjs ./src/mitm/server.cjs
# Documentation files and OpenAPI spec are read from disk at runtime.
# Next.js standalone tracing does not include them.
COPY --from=builder /app/docs ./docs
# Docker healthcheck script — not traced by Next.js standalone output, so copy
# it explicitly. The HEALTHCHECK CMD references it as `node healthcheck.mjs`.
COPY --from=builder /app/scripts/dev/run-standalone.mjs ./dev/run-standalone.mjs
COPY --from=builder /app/scripts/build/runtime-env.mjs ./build/runtime-env.mjs
COPY --from=builder /app/scripts/build/bootstrap-env.mjs ./build/bootstrap-env.mjs
COPY --from=builder /app/scripts/dev/healthcheck.mjs ./healthcheck.mjs
# Hand /app over to the baked-in `node` non-root user (UID/GID 1000) so the
# runtime process never holds root privileges. The chown happens after all
# COPYs so it covers files originally owned by root in the builder stage.
RUN chown -R node:node /app
EXPOSE 20128
# Drop to non-root before ENTRYPOINT/CMD so every derived stage (runner-cli,
# runner-web) also runs as a non-root user unless they explicitly switch back.
USER node
# Warns if the mounted data volume has wrong ownership
COPY --chmod=755 scripts/check-permissions.sh /tmp/check-permissions.sh
ENTRYPOINT ["/tmp/check-permissions.sh"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD ["node", "healthcheck.mjs"]
CMD ["node", "dev/run-standalone.mjs"]
# ── Runner Web (web-cookie providers: Gemini Web, Claude Turnstile) ───────────
#
# Two image flavors:
# runner-base → omniroute:VERSION Lean base (~500 MB). No browsers.
# runner-web → omniroute:VERSION-web +Chromium/Playwright (~800 MB).
#
# Use runner-web when you need web-cookie providers (gemini-web, claude-web,
# claude-turnstile). For all other providers runner-base is sufficient.
#
# Build:
# docker build --target runner-web -t omniroute:web .
# Compose:
# build:
# context: .
# target: runner-web
FROM runner-base AS runner-web
USER root
# Copy playwright and playwright-core from the builder stage.
# The slim runtime image does not have playwright in node_modules, so npx falls
# back to a registry download — unreliable on CI runners (exits 127 on failure).
# Copying from the builder avoids any network access at image-build time and also
# ensures the same playwright version is available at runtime for web-session providers.
COPY --from=builder /app/node_modules/playwright-core ./node_modules/playwright-core
COPY --from=builder /app/node_modules/playwright ./node_modules/playwright
# Install Playwright browser binaries + OS dependencies under root, then hand
# ownership of the browsers cache to the node user.
# PLAYWRIGHT_BROWSERS_PATH overrides the default ~/.cache/ms-playwright so the
# browsers land under /home/node which persists across image layers and is
# accessible to the non-root runtime user.
ENV PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
apt-get update \
&& node node_modules/playwright/cli.js install chromium --with-deps \
&& chown -R node:node /home/node/.cache \
&& rm -rf /var/lib/apt/lists/*
USER node
FROM runner-base AS runner-cli
# Drop back to root briefly so we can install system + global npm packages,
# then return to the `node` non-root user before the CMD inherited from
# runner-base runs.
USER root
# Install system dependencies required by openclaw (git+ssh references).
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
apt-get update \
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates docker.io docker-compose \
&& rm -rf /var/lib/apt/lists/* \
&& git config --system url."https://github.com/".insteadOf "ssh://git@github.com/"
# Install CLI tools globally. Separate layer from apt for better cache reuse.
RUN --mount=type=cache,target=/root/.npm \
npm install -g --no-audit --no-fund @openai/codex @anthropic-ai/claude-code droid openclaw@latest
USER node
RUN npm install -g --no-audit --no-fund @openai/codex @anthropic-ai/claude-code droid openclaw@latest

View File

@@ -1,209 +0,0 @@
# Fase 6A — Auditoria Crítica das Fases 06: o que deixamos passar
> **Para workers agênticos:** SUB-SKILL OBRIGATÓRIA: `superpowers:subagent-driven-development` (recomendado) ou `superpowers:executing-plans`, tarefa-a-tarefa. Tarefas P0/P1 maiores devem ser **expandidas em sub-plano bite-sized próprio** (`writing-plans`) no momento da execução. Hard Rule #18 (TDD/VPS) em tudo. Auditar subagentes (trust-but-verify) após cada task.
> # ⏳ PORTÃO DE ATIVAÇÃO — NÃO INICIAR ANTES DE **2026-06-16**
> Mesma janela da Fase 7 (decisão do owner 2026-06-09: 1 semana de uso em produção das Fases 06 antes de evoluir). **Ordem na ativação: Fase 6A ANTES da Fase 7** — primeiro consertamos/endurecemos o que já existe, depois adicionamos ferramentas novas.
> **Exceção possível (decisão do owner):** as tasks **6A.1 e 6A.2 são bugs pré-existentes descobertos pela auditoria** (testes que nunca rodam + suíte vitest fora do CI), não "gates novos" — podem ser antecipados como fix avulso se o owner preferir não esperar a janela.
**Goal:** Fechar os furos que a auditoria crítica pós-implementação (2026-06-09, análise inline dos 18 gates + motor + CI + baselines) encontrou nas Fases 06 — antes de adicionar qualquer ferramenta nova na Fase 7.
**Architecture:** Zero ferramenta nova (tudo homegrown, padrão `check-*.mjs` + motor existente). Três frentes: (1) **bugs sistêmicos de runner** descobertos (testes órfãos, vitest fora do CI); (2) **endurecimento do padrão de catraca** (stale-allowlist enforcement + require-tighten, validados pela prática da Notion); (3) **expansão de escopo** dos gates existentes (diretórios/superfícies que ficaram de fora).
**Tech Stack:** Node ≥20 ESM, ESLint 9 flat, c8, jscpd@4 (a pinar), GitHub Actions, Node native test runner, vitest. Nada novo em `dependency-allowlist.json` exceto a promoção do jscpd a devDependency (Task 6A.12).
---
## Origem: o que a auditoria encontrou (resumo dos achados)
Método: releitura inline de todos os `scripts/check/*.{mjs,ts}` criados nas Fases 06, `scripts/quality/*`, baselines, `ci.yml`, `package.json`, hooks Husky e docs — sem subagentes — mais validação por pesquisa (sistema de ratcheting da Notion; práticas de suppression-hygiene de linters).
| # | Achado | Gravidade | Task |
|---|--------|-----------|------|
| A1 | **≈135 arquivos `*.test.ts` em subdiretórios de `tests/unit/` não são coletados por NENHUM runner** — `test:unit`, `test:coverage` e os shards do CI usam o glob não-recursivo `tests/unit/*.test.ts`; o vitest só inclui `autoCombo/**` (+ `.tsx`). Inclui `authz/routeGuard.test.ts` (Hard Rules #15/#17), 50 testes de `compression/`, 12 de `services/`, 10 de `gamification/`, 6 de `guardrails/`, 5 de `security/`. **Amostra rodada na auditoria: 2 asserts de `routeGuard.test.ts` FALHAM hoje** ("management policy allows /api/services/ (e /api/copilot/chat) from localhost with valid CLI token") — o arquivo apodreceu sem ninguém ver, provavelmente desde o redesign do peer-stamp (2026-05-31) | **P0 — falso verde sistêmico** | 6A.1 |
| A2 | **Nenhum workflow roda `test:vitest`** (`grep -rln vitest .github/workflows/` = vazio). O CLAUDE.md afirma "Both test runners must pass… before merging", mas a suíte vitest (MCP server 43 tools, autoCombo, cache, componentes) está 100% fora da esteira | **P0** | 6A.2 |
| B1 | **Nenhum gate falha quando uma entrada de allowlist deixa de ser necessária.** Os 18 gates congelam ~90 violações em `KNOWN_*`; quando alguém corrige a violação (ex.: criar `/api/gamification/level` da issue #3484, remover `krutrim` da #3483), a entrada vira um furo aberto — a regressão pode VOLTAR sem revisão. Só `check-error-helper` tem detecção parcial (WARN de arquivo inexistente, que ninguém lê). Prática validada: linters maduros "yell when an exclusion exists that doesn't break the rule" | **P0 — corrói a catraca com o tempo** | 6A.3 |
| B2 | **Melhoria não-capturada vira folga permanente no motor**: sem `--update` manual, uma métrica que melhorou pode regredir de volta até o baseline antigo sem ninguém ver. A Notion auto-decrementa budgets no pre-commit; nosso motor não exige aperto | P1 | 6A.5 |
| B3 | EPS único (0.01) para todas as métricas; o plano da Fase 4 pedia epsilon maior para `coverage.branches` (não-determinismo do v8) e não foi implementado | P1 | 6A.5 |
| B4 | Métrica coletada sem entrada no baseline é ignorada em silêncio (coletor novo esquecido do baseline = falso conforto) | P2 | 6A.5 |
| C1 | `check-fetch-targets` só varre `src/app/(dashboard)`**20+ arquivos com `fetch("/api/…")` fora do escopo**: `src/shared/components/` (Sidebar, CommandPalette, modais…), `src/app/connect/`, `src/app/status/`, `src/lib/evals/` | P1 | 6A.7 |
| C2 | `check-fetch-targets` ignora 100% dos template literals (`` fetch(`/api/x/${id}`) ``) — nem o prefixo estático é validado | P1 | 6A.7 |
| C3 | `check-fetch-targets` não valida o método HTTP (fetch `POST` → rota só com `GET` = 405 em runtime, gate verde) | P2 | 6A.7 |
| C4 | `check-deps` cobre só `package.json` raiz + `electron/` — **`@omniroute/opencode-plugin` (dep `zod` + 5 devDeps, pacote PUBLICADO no npm), `@omniroute/opencode-provider` e `open-sse/` ficam fora** | P1 | 6A.8 |
| C5 | `check-public-creds` escaneia só 2 arquivos hardcoded — credencial literal em arquivo NOVO (executor, oauth provider) passa batida | P1 | 6A.8 |
| C6 | `check-error-helper` cobre `open-sse/executors` + `handlers` — a Hard Rule #12 também fala de **MCP handlers** (`open-sse/mcp-server/`) e rotas HTTP (`src/app/api/`), fora do escopo | P1 | 6A.8 |
| C7 | `check-file-size` e `check-complexity` varrem `src` + `open-sse` — `electron/` e `bin/` fora (god-file pode nascer lá) | P2 | 6A.11 |
| C8 | `check-known-symbols` cobre executors/strategies/translators — **faltam as 3 superfícies de despacho por-string restantes: MCP tools (43), A2A skills (5, `A2A_SKILL_HANDLERS`), cloud agents (3, registry)** | P1 | 6A.9 |
| C10 | `check-route-guard-membership` depende da lista manual `SPAWN_CAPABLE_ROUTE_ROOTS` (3 raízes) — rota nova que spawna processo FORA dessas raízes é invisível ao gate | P1 | 6A.8 |
| C11 | `check-openapi-coverage` (THRESHOLD=36%) e `check-ui-keys-coverage` (65%) são pisos fixos manuais — não ratcheteiam; rotas/strings novas sem doc/i18n passam enquanto o % não cai do piso | P2 | 6A.11 |
| C12 | `check-test-masking`: (a) `--diff-filter=M` não vê teste **DELETADO** (o masking mais brutal); (b) não vê `.skip`/`.todo`/`.only` adicionados (mantêm os asserts no texto, mas nunca rodam); (c) tautologia só cobre `assert.ok(true)` | P1 | 6A.10 |
| D1 | **`ci.yml` roda gates apenas em `pull_request → main`** — todo o ciclo de PRs feature→`release/vX` passa SEM gate; as violações acumulam por semanas e estouram juntas no merge release→main (observado no gate da v3.8.18: 4 fixes de typecheck + ReDoS de última hora) | P1 — decisão do owner | 6A.6 |
| D2 | `.husky/pre-push` está 100% comentado, mas o CLAUDE.md afirma "pre-push: npm run test:unit" (drift doc↔real) | P2 | 6A.12 |
| D3 | **CLAUDE.md não menciona nenhum dos 18 gates** — um agente futuro não sabe que existem, qual a política de allowlist ("corrija, não congele"), nem como apertar baselines. Hard Rule #9 ainda diz "≥60%" (a catraca real é 80/80/82/73) | **P0 — anti-alucinação para os próprios agentes** | 6A.4 |
| D4 | `check-duplication` roda `npx --yes jscpd@4` — pacote **não pinado por lockfile**, baixado do registry a cada run do CI (supply-chain + flakiness + latência); contraria o espírito do próprio `check-deps` | P2 | 6A.12 |
| D5 | A skill `/quality-scan` roda ~20 comandos um a um — falta um runner agregador paralelo | P2 | 6A.12 |
| D6 | Baselines de coverage com folga de ~2,5pt (80/80/82/73 vs real ~82,6/82,6/84,2/75,2) — a nota "_aperte após o 1º run verde_" existe no JSON mas não é tarefa de ninguém | P1 | 6A.5 |
| E4 | Sem guarda contra artefato trackeado por engano — `node_modules` symlink já foi commitado 2× neste repo (`git add -A` em worktree) | P2 | 6A.12 |
**Decisões conscientes de NÃO fazer (avaliadas e descartadas, com motivo):**
- **Gate de idempotência de migrations via regex** — SQLite não tem `ADD COLUMN IF NOT EXISTS`; idempotência vive em try/catch do runner; regex seria frágil (FP/FN). O `check-migration-numbering` + revisão humana bastam.
- **Endurecer `check-docs-counts-sync` para fail** — contagens em prosa são heurísticas; soft-fail é o design correto. A cobertura de MCP tools entra pela 6A.9 (símbolos, não prosa).
- **Sentido inverso do provider-consistency (providers.ts → REGISTRY)** — muitos providers canônicos legitimamente não têm entrada no REGISTRY (web/OAuth-only); a allowlist nasceria com dezenas de entradas e baixa razão sinal/ruído. Reavaliar quando o refactor #3501 tocar o split de providers.
- **Complexidade por-função/por-arquivo (formato any-budget)** — upgrade real, mas o count global + `max-lines-per-function` já bloqueiam o grosso; o formato per-file entra junto com `sonarjs/cognitive-complexity` na Fase 7 Task 5 para não pagar duas migrações de baseline.
---
# Tasks
## P0 — bugs sistêmicos + documentação
### Task 6A.1 — `check-test-discovery` + religamento triado dos ~135 testes órfãos ⭐
**O achado nº1 da auditoria.** Testes que não rodam são o falso verde definitivo — todo o investimento anti test-masking da Fase 4 protege asserts de testes que **nem executam**.
**Files:**
- Create: `scripts/check/check-test-discovery.mjs` + `tests/unit/check-test-discovery.test.ts`
- Modify: `package.json` (globs de `test:unit`, `test:coverage`), `.github/workflows/ci.yml` (globs dos shards 8×/node24/node26), `vitest.mcp.config.ts` ou `vitest.config.ts` (se algum subdir for re-homed para vitest)
- Create: `test-discovery-baseline.json` (órfãos ainda-não-religados, catraca `down` até zerar)
**Approach (expandir em sub-plano na execução):**
1. **Gate primeiro (TDD):** `check-test-discovery.mjs` enumera todo `**/*.{test,spec}.{ts,tsx}` do repo (fora de `node_modules`/`.next`) e verifica que cada arquivo é coletado por ≥1 runner: (a) globs do node test runner extraídos de `package.json`/`ci.yml`; (b) `include` dos dois `vitest.*config.ts`; (c) projetos Playwright. Órfão fora do baseline → exit 1. O baseline congela os órfãos atuais (catraca: não pode SUBIR; religamentos a fazem cair até `{}`).
2. **Inventário verde/vermelho:** rodar cada subdir órfão isoladamente (`node --import tsx --test tests/unit/<dir>/*.test.ts`), registrar passa/falha. *Não* ligar tudo de uma vez — a amostra já provou que há vermelhos (`authz/routeGuard.test.ts`: 2 asserts).
3. **Religar os verdes:** trocar o glob principal para recursivo — `"tests/unit/**/*.test.ts"` ENTRE ASPAS (expandido pelo test runner do Node, não pelo shell; **Step 0: validar o suporte a glob do runner na menor versão de Node suportada pelo repo** — fallback: listar os subdirs explicitamente) — em `test:unit`, `test:coverage` e nos 3 lugares do `ci.yml` (shards 8×, node24, node26). Remover religados do baseline.
4. **Triar os vermelhos (Hard Rule #18 em cada um):** teste desatualizado → atualizar para o comportamento real (e provar que o comportamento real é o desejado); bug real revelado → fix TDD; teste de feature morta → deletar com justificativa no commit. Os 2 asserts do `routeGuard.test.ts` ("allows … with valid CLI token") são o primeiro caso: provável drift do peer-stamp de 2026-05-31 — MAS, por ser superfície de segurança (#15/#17), confirmar com cuidado que é o teste que está errado, não o guard.
5. **Recalibrar cobertura:** religar ≈135 arquivos muda o denominador/numerador da cobertura — re-medir e apertar `quality-baseline.json` via `--update` no mesmo PR (resolve também o D6).
**Acceptance:** `check-test-discovery` no CI (job lint); zero órfãos fora do baseline; baseline decrescente documentado; suíte verde com os religados; cobertura recalibrada.
### Task 6A.2 — vitest no CI
**Files:** `.github/workflows/ci.yml` (job novo `test-vitest`, paralelo aos shards; NÃO tocar nos triggers — apenas adicionar job).
**Approach:** job com `npm ci` + `npm run test:vitest` (+ `test:vitest:ui` se o tempo couber; senão segundo step). Rodar localmente primeiro para garantir verde (a suíte passa hoje fora da esteira — confirmar). Se houver vermelho pré-existente, triagem antes do wire (mesmo protocolo da 6A.1 passo 4).
**Acceptance:** PR→main roda as DUAS suítes; o claim do CLAUDE.md ("both must be green") vira verdade mecânica.
### Task 6A.3 — Stale-allowlist enforcement em todos os gates (suppression hygiene)
**O endurecimento sistêmico nº1.** Padrão validado (ESLint `--report-unused-disable-directives`; Notion): exclusão que não exclui nada vivo é dívida fantasma e furo de regressão.
**Files:** todos os gates com allowlist + seus testes:
`check-fetch-targets` (KNOWN_MISSING, 7) · `check-provider-consistency` (KNOWN_REGISTRY_ONLY, 1) · `check-openapi-routes` (KNOWN_STALE_SPEC, 1) · `check-public-creds` (KNOWN_LITERAL_CREDS, 5) · `check-db-rules` (KNOWN_UNEXPORTED 25 + KNOWN_RAW_SQL 15) · `check-docs-symbols` (KNOWN_STALE_DOC_REFS, 30) · `check-migration-numbering` (KNOWN_GAPS/DUPLICATES) · `check-error-helper` (KNOWN_MISSING_ERROR_HELPER, 7 — promover o WARN existente a FAIL e cobrir também "arquivo existe mas não viola mais") · `check-deps` (dependency-allowlist: entrada sem dep correspondente em manifest algum = stale) · `check-file-size` (entrada `frozen` cujo arquivo foi deletado/renomeado) · `check-route-guard-membership` (KNOWN_UNCLASSIFIED — vazio hoje; implementar o check para quando deixar de ser).
**Approach (mecânica única, TDD por gate):** após a detecção normal, re-avaliar cada entrada da allowlist: *"se esta entrada não existisse, o gate flagaria algo?"* — para allowlists de path/valor isso é `violationsDetectadas.has(entry)`; para arquivos, existência + violação presente. Entrada que não suprime nada → **exit 1** com mensagem `entrada obsoleta — a violação foi corrigida; REMOVA a entrada para travar a correção`. Extrair helper comum `reportStaleEntries(allowlist, liveViolations, gateName)` em `scripts/check/lib/allowlist.mjs` para não duplicar 11×.
**Acceptance:** corrigir qualquer violação congelada (ex.: as issues #3483#3501) passa a EXIGIR a remoção da entrada no mesmo PR; teste sintético prova fail-on-stale em cada gate.
### Task 6A.4 — Documentar os gates no CLAUDE.md (+ corrigir drifts de doc)
**Files:** `CLAUDE.md`, `AGENTS.md` (se houver seção espelho), `docs/architecture/` (página `QUALITY_GATES.md` referenciada pela tabela de docs).
**Approach:** (1) seção nova "Quality Gates & Ratchets" no CLAUDE.md: tabela dos gates (nome → o que trava → allowlist/baseline), a política **"corrija a causa; allowlist só com justificativa + issue"**, como apertar (`npm run quality:ratchet -- --update`, `check:<gate> -- --update`), e o que fazer quando um gate falha num PR. (2) Corrigir: Hard Rule #9 (60% → "catraca de cobertura: nunca abaixo do baseline congelado em `quality-baseline.json`; piso absoluto 60"), claim do pre-push (refletir o real pós-6A.12), claim "both runners" (verdade pós-6A.2). (3) Página `docs/architecture/QUALITY_GATES.md` com o detalhe operacional (o CLAUDE.md fica curto, linka). Rodar `check:docs-all` após editar (o próprio docs-sync valida).
**Acceptance:** agente novo lendo o CLAUDE.md descobre os gates e a política sem ler scripts; `check:docs-all` verde.
## P1 — endurecimento do motor + escopos
### Task 6A.5 — Motor v2: `--require-tighten`, eps por métrica, métricas órfãs
**Files:** `scripts/quality/check-quality-ratchet.mjs`, `quality-baseline.json` (schema), `tests/unit/quality-ratchet.test.ts`, `.github/workflows/ci.yml` (flag no job quality-gate).
**Approach (TDD):**
1. Schema por métrica ganha campos opcionais: `eps` (default 0.01) e `tightenSlack` (default: igual a `eps`).
2. Novo modo `--require-tighten` (ligado no CI): se `atual` melhor que `baseline` além de `tightenSlack`, **exit 1** com `melhorou de X para Y — rode 'npm run quality:ratchet -- --update' e commite o baseline apertado neste PR`. Métricas determinísticas (`eslintWarnings`) usam slack 0; cobertura usa slack 1.5 (flutuação v8). É o "auto-decrement" da Notion adaptado a CI sem bot de commit.
3. Warning para métricas presentes em `quality-metrics.json` sem entrada no baseline (coletor órfão).
4. Calibrar os 4 `coverage.*` para o real medido (fecha D6 — coordenar com a 6A.1 passo 5, que muda a base).
**Acceptance:** melhoria sem aperto de baseline falha no CI; flutuação de coverage dentro do slack não falha; testes cobrem os 3 comportamentos novos.
### Task 6A.6 — `quality.yml`: gates rápidos em PRs → `release/**` ⚠️ DECISÃO DO OWNER
**Contexto sensível:** o owner já reverteu mudança de trigger no `ci.yml` ("não mexe na CI"). Esta task **não toca o `ci.yml`** — cria um workflow NOVO e enxuto. Ainda assim, **passo 0 = confirmação explícita do owner**.
**Files:** Create: `.github/workflows/quality.yml`.
**Approach:** `on: pull_request: branches: ["release/**"]`; um job único (~12 min) só com os gates determinísticos filesystem-only: provider-consistency, fetch-targets, openapi-routes, docs-symbols, deps, file-size, error-helper, migration-numbering, public-creds, db-rules, known-symbols, route-guard-membership, test-discovery (pós-6A.1) + `check:any-budget:t11`. SEM lint/test/build (continuam só no PR→main). Ganho: a violação aparece no PR que a introduz, não semanas depois no gate do release (padrão observado: 4 fixes de última hora no release da v3.8.18).
**Acceptance:** PR de teste contra a release branch com uma rota inventada falha em <2 min; PRs limpos não ganham mais que ~2 min de CI.
### Task 6A.7 — `check-fetch-targets` v2: escopo completo + prefixo de template + método HTTP
**Files:** `scripts/check/check-fetch-targets.mjs`, `tests/unit/check-fetch-targets.test.ts`.
**Approach (TDD):**
1. **Escopo:** varrer todo `src/**/*.{ts,tsx}` client-side (excluindo `src/app/api/**`, testes, `src/lib/db`), não só `(dashboard)` — congela os misses pré-existentes que aparecerem em `KNOWN_MISSING` (com triagem/issue por cluster, igual Fase 2).
2. **Template literals:** extrair o prefixo estático de `` fetch(`/api/x/y/${id}…`) `` e validar por **prefix-match** contra as rotas reais (existe alguma rota cujo path começa com `/api/x/y/`?). Pega diretório inteiro alucinado; não tenta resolver o sufixo dinâmico.
3. **Método HTTP (heurístico, mesma chamada):** quando o 2º argumento literal contém `method: "POST"` (etc.), verificar que o `route.ts` resolvido exporta a função correspondente (`grep` por `export (async )?function POST` / `export const POST`). Sem method literal → assume GET-ok (rota existe basta). Casos dinâmicos → skip silencioso.
**Acceptance:** fixture com fetch em `src/shared/components` + template com prefixo falso + `method: "DELETE"` para rota só-GET — 3 detecções; repo real verde com os novos congelados documentados.
### Task 6A.8 — Escopo dos gates de segurança: error-helper, public-creds, route-guard, deps
**Files:** `check-error-helper.mjs`, `check-public-creds.mjs`, `check-route-guard-membership.ts`, `check-deps.mjs` + testes.
**Approach (TDD, um sub-commit por gate):**
1. **error-helper** (+Rule #12 completa): incluir `open-sse/mcp-server/**` e `src/app/api/**/route.ts` no SCAN_DIRS; rodar; congelar os achados novos em KNOWN com comentário-justificativa cada (e issue por cluster).
2. **public-creds**: além dos 2 arquivos âncora, varrer `open-sse/**` e `src/lib/oauth/**` com a mesma `CRED_KEY_RE` (linha a linha, barato); congelar achados. Limitação documentada: `const CLIENT_ID = "…"` (variável solta) continua fora — o gitleaks da Fase 7 cobre essa classe.
3. **route-guard**: novo sub-check — todo `route.ts` (qualquer raiz) cujo fonte OU imports de 1º nível relativos contenham `child_process`/`spawn(`/`execFile(`/`worker_threads` deve ser classificado local-only por `isLocalOnlyPath()`. Mata a dependência da lista manual de 3 raízes.
4. **deps**: `MANIFESTS` → descoberta automática de todo `package.json` do repo (fora `node_modules`/`.next`): hoje raiz, `electron/`, `open-sse/`, `@omniroute/opencode-plugin/` (dep `zod` entra na allowlist), `@omniroute/opencode-provider/`. Workspace novo amanhã entra sozinho.
**Acceptance:** fixtures sintéticas por gate; repo real verde com achados congelados + issues; dep nova em QUALQUER manifest do repo dispara o gate.
### Task 6A.9 — `check-known-symbols` v2: MCP tools, A2A skills, cloud agents
**Files:** `scripts/check/check-known-symbols.ts`, `tests/unit/check-known-symbols.test.ts`.
**Approach (TDD, mesmo padrão das 3 superfícies existentes — Step 0 de verificação dos exports reais antes de codar):**
1. **MCP tools:** enumerar os tools registrados (via `createMcpServer()` ou parse determinístico do tool-set em `open-sse/mcp-server/tools/`) e congelar o snapshot de nomes (catraca: tool sumir = fail; tool novo = report). Cruzar com os scopes (~13) — tool sem scope atribuído = fail.
2. **A2A skills:** chaves de `A2A_SKILL_HANDLERS` (`src/lib/a2a/taskExecution.ts`) ↔ skills expostas no Agent Card (`src/app/.well-known/agent.json/route.ts`) — divergência = fail.
3. **Cloud agents:** entradas do `src/lib/cloudAgent/registry.ts` ↔ classes em `agents/` — incompleto/órfão = fail.
**Acceptance:** remover um tool/skill/agent do registro quebra o gate; adicionar reporta (e `check-docs-counts-sync` continua cuidando da prosa).
## P2 — refinamentos
### Task 6A.10 — `check-test-masking` v2: deleções, skips, tautologias
**Files:** `scripts/check/check-test-masking.mjs`, `tests/unit/check-test-masking.test.ts`.
**Approach (TDD):** (a) `--diff-filter=M` → `MDR` (com `-M` para rename-detection): arquivo de teste **deletado** = flag automático ("N asserts removidos — arquivo deletado"); renamed = comparar contra o path antigo. (b) Contar `\.(skip|todo|only)\s*\(` + `\{\s*skip:\s*true` base vs HEAD — **aumento líquido de skips = flag** (skip novo esconde asserts sem removê-los); `.only` novo = flag sempre (filtra o resto da suíte). (c) Tautologias extras: `expect(true).toBe(true)`, `assert.equal(1, 1)`, `expect(x).toBeDefined()` como ÚNICO assert do teste.
**Acceptance:** fixtures para os 3 bypasses (delete, skip, only) — todos flagados; suíte real verde.
### Task 6A.11 — Pisos manuais → catraca do motor + escopo electron/bin
**Files:** `scripts/quality/collect-metrics.mjs`, `quality-baseline.json`, `check-openapi-coverage.mjs`, `scripts/i18n/check-ui-keys-coverage.mjs` (só leitura do valor), `check-file-size.mjs`, `eslint.complexity.config.mjs` + baselines.
**Approach:** (1) coletor emite `openapiCoverage.pct` e `i18nUiCoverage.pct` → baseline `{direction: up}` com o valor real atual (36→real, 65→real); os THRESHOLDs fixos viram redundância de segurança (mantidos). Rotas/strings novas sem doc/i18n agora REGRIDEM o % e falham. (2) `check-file-size` e `check-complexity`: adicionar `electron/` e `bin/` ao scan (congelar os >cap que existirem).
**Acceptance:** rota nova não-documentada derruba `openapiCoverage.pct` → gate falha; god-file novo em `electron/` falha.
### Task 6A.12 — Higiene operacional (4 itens pequenos)
**Files:** `package.json`, `dependency-allowlist.json`, `.husky/pre-push`, Create: `scripts/check/check-tracked-artifacts.mjs`, `scripts/quality/run-all-gates.mjs`; Modify: `.agents/skills/quality-scan/SKILL.md`.
**Approach:**
1. **jscpd pinado:** `jscpd@^4` como devDependency (entra no lockfile + allowlist); `check-duplication` chama o binário local em vez de `npx --yes jscpd@4` (remove download de registry no CI + supply-chain risk + flakiness).
2. **pre-push barato:** reativar com APENAS os gates determinísticos rápidos (<10s: fetch-targets, openapi-routes, db-rules, public-creds, migration-numbering, file-size, deps, error-helper) — NÃO `test:unit` (lento; CI cobre). Atualizar o claim do CLAUDE.md (coordenar com 6A.4).
3. **check-tracked-artifacts:** falhar se `git ls-files` contém `node_modules/`, `.next/`, `coverage/`, `quality-metrics.json` ou symlink para fora do repo (o incidente do symlink trackeado já aconteceu 2×). Wire no lint job + pre-commit (é instantâneo).
4. **Runner agregador:** `scripts/quality/run-all-gates.mjs` roda os gates em paralelo (pool ~4), agrega `{gate, exitCode, lastLine, durationMs}` e imprime a tabela consolidada; `npm run quality:scan`. A skill `/quality-scan` passa a chamá-lo (atualizar SKILL.md).
**Acceptance:** `npm run quality:scan` < 3 min com tabela única; pre-push roda <10s; `git add node_modules && commit` falha no pre-commit.
---
## Ordem de execução recomendada (na ativação, 2026-06-16+)
1. **6A.1 + 6A.2** (bugs de runner — destravam números reais de cobertura para o resto)
2. **6A.3 + 6A.4** (stale-enforcement + docs — endurecem o que já roda)
3. **6A.5** (motor v2) → **6A.6** (quality.yml, após OK do owner)
4. **6A.7 → 6A.9** (escopos)
5. **6A.10 → 6A.12** (refinamentos)
6. Só então **Fase 7** (ferramentas novas sobre uma fundação consertada)
## Self-Review
- **Cobertura dos achados:** todos os achados A*/B*/C*/D*/E* da tabela têm task (coluna Task); os descartados estão em "Decisões conscientes de NÃO fazer" com motivo. ✓
- **Zero dependência nova** exceto a promoção do jscpd (que já roda hoje via npx, não-pinado — a task REDUZ risco). ✓
- **Sem flag-day:** toda expansão de escopo congela os achados pré-existentes (allowlist + issue), igual Fases 06; o stale-enforcement só exige remoção quando a correção JÁ aconteceu. ✓
- **Consistência com o motor:** novas métricas (`openapiCoverage.pct`, `i18nUiCoverage.pct`) usam o formato `{value, direction}`; `eps`/`tightenSlack` são opcionais e retrocompatíveis. ✓
- **Não-duplicação com a Fase 7:** cognitive-complexity per-file, gitleaks (creds não-públicas), knip, osv — tudo continua na Fase 7; a 6A só conserta/endurece o existente. ✓

View File

@@ -1,182 +0,0 @@
# Fase 7 — Quality Gates: Segurança, Dead-Code, Mutação & Ferramental Community
> **Para workers agênticos:** SUB-SKILL OBRIGATÓRIA: `superpowers:subagent-driven-development` (recomendado) ou `superpowers:executing-plans`, tarefa-a-tarefa. Cada tarefa aqui é um subsistema independente → **expandir em sub-plano bite-sized próprio** no momento da execução. Hard Rule #18 (TDD/VPS) em tudo.
> # ⏳ PORTÃO DE ATIVAÇÃO — NÃO INICIAR ANTES DE **2026-06-16**
> **Este plano está GUARDADO, não ativo.** Decisão do owner (2026-06-09): finalizar 100% as Fases 06 (PR #3471), **usar em produção por 1 semana** para validar na prática, e só então evoluir. **Data cravada de início da Fase 7: 2026-06-16.** Não ativar antes — o objetivo da semana é coletar sinal real (falsos-positivos dos gates, custo de CI, atrito) antes de adicionar mais.
> **Pré-condições para ativar:** (1) PR #3471 (Fases 06) mergeada e rodada ≥1 semana; (2) re-home do PR para `release/v3.8.18` resolvido; (3) as issues #3483#3501 com decisões aplicadas ou conscientemente adiadas; (4) **Fase 6A executada (ou conscientemente re-priorizada)** — a auditoria crítica de 2026-06-09 ([`PLANO-QUALITY-GATES-FASE6A.md`](./PLANO-QUALITY-GATES-FASE6A.md)) encontrou bugs sistêmicos de runner (≈135 testes órfãos, vitest fora do CI) e furos de escopo nos gates existentes que devem ser consertados ANTES de adicionar ferramentas novas por cima.
**Goal:** Maximizar a cobertura de quality gates do OmniRoute adicionando catracas de **segurança** (Sonar/osv/CodeQL → zero na timeline), **dead-code**, **complexidade cognitiva**, **type-coverage**, **mutação**, **bundle-size**, **a11y** e completando o anti-slopsquatting — usando **somente ferramentas Community/OSS** (projeto é open-source, zero SaaS pago, dados na box).
**Architecture:** Reusa o motor existente — toda métrica numérica entra como `{value, direction}` em `quality-baseline.json` (catraca só-regressão) ou vira um `scripts/check/check-*.mjs` dedicado (padrão `check-t11-any-budget.mjs`). Gates pesados vão no job paralelo `quality-gate`; gates rápidos no `lint`; mutação/visual em job nightly separado. Tudo só-regressão (sem flag-day).
**Tech Stack (tudo OSS/Community):** SonarQube **Community Build** (self-hosted) · osv-scanner (Google) · CodeQL (GitHub, grátis p/ público) · knip · eslint-plugin-sonarjs · type-coverage · lockfile-lint · dpdm · Stryker (`@stryker-mutator/*`) · size-limit · `@axe-core/playwright` · semcheck · agent-lsp (MCP) · Qlty CLI (OSS, opcional) · **gitleaks** (secret scanning, MIT; avaliar o sucessor drop-in Betterleaks, 2026-03) · **actionlint + zizmor** (lint + auditoria de segurança dos workflows) · **license-compliance** (allowlist SPDX de licenças). ESLint 9 flat · c8 · Node native test runner · GitHub Actions.
---
## Princípio (igual às Fases 06)
Toda catraca é **só-regressão**: congela o baseline atual, bloqueia QUALQUER piora, decai a zero/melhor com o tempo via `--update`. Nenhum gate exige limpeza imediata (flag-day). Cada ferramenta nova que vira dependência **deve ser adicionada a `dependency-allowlist.json`** (o gate `check-deps` da Fase 2 vai exigir — é o ponto de revisão humana).
## Mapa de arquivos (criar/modificar)
| Arquivo | Responsabilidade |
|---|---|
| `quality-baseline.json` (modificar) | + `vulnCount`, `codeqlAlerts`, `sonarIssues`, `cognitiveComplexity`, `typeCoveragePct`, `deadExports` |
| `scripts/quality/collect-metrics.mjs` (modificar) | + coletores: osv-scanner, CodeQL count, Sonar API, knip, type-coverage, sonarjs |
| `scripts/check/check-vuln-ratchet.mjs` (criar) | osv-scanner → vulnCount (catraca) |
| `scripts/check/check-dead-code.mjs` (criar) | knip → exports/files/deps mortos (catraca) |
| `scripts/check/check-cognitive-complexity.mjs` + `eslint.sonarjs.config.mjs` (criar) | sonarjs/cognitive-complexity em config isolado (não polui o count principal) |
| `scripts/check/check-type-coverage.mjs` (criar) | type-coverage % (catraca up) |
| `scripts/check/check-lockfile.mjs` (criar) | lockfile-lint (host/https/integrity) |
| `scripts/check/check-pr-evidence.mjs` (criar) | exige output de comando no corpo do PR (Rule #18 mecânica) |
| `scripts/check/check-bundle-size.mjs` + `.size-limit.json` (criar) | size-limit → orçamento de bundle |
| `tests/e2e/a11y.spec.ts` (criar) | `@axe-core/playwright` nas páginas-chave |
| `stryker.conf.json` (criar) | mutação nos ~8 módulos críticos (nightly) |
| `sonar-project.properties` (modificar) | remover `coverage`/`cpd` exclusions; ativar new-code gate |
| `.github/workflows/ci.yml` (modificar) | wirar novos gates (lint / quality-gate / nightly) + `qualitygate.wait` no Sonar |
| `semcheck.yaml` (criar) | semcheck: docs↔código (camada fuzzy LLM, opcional) |
| `.mcp.json` / config de agentes (modificar) | registrar agent-lsp (LSP-in-the-loop) |
| `.gitleaks.toml` + `scripts/check/check-secrets.mjs` (criar) | gitleaks → catraca de findings de secret (Task 18) |
| `.github/workflows/quality.yml` ou job lint (modificar) | actionlint + zizmor sobre `.github/workflows/**` (Task 19) |
| `scripts/check/check-licenses.mjs` + `.license-allowlist.json` (criar) | allowlist SPDX das licenças das deps (Task 20) |
| `dependency-allowlist.json` (modificar) | + osv-scanner, knip, sonarjs, type-coverage, lockfile-lint, stryker, size-limit, axe-core, dpdm, license-compliance |
---
## Tarefas (cada uma = 1 sub-plano bite-sized na execução)
### Task 1 — Ativar SonarQube Community + "Clean as You Code" (gate de segurança nativo)
- **Tool:** SonarQube Community Build (self-hosted, grátis).
- **Files:** `sonar-project.properties`, `.github/workflows/ci.yml` (job `sonarqube`).
- **Approach:** setar secrets `SONAR_TOKEN`/`SONAR_HOST_URL`; **remover** `sonar.coverage.exclusions=**/*` e `sonar.cpd.exclusions=**/*` (hoje neutralizam o Sonar); ativar o quality gate **new-code / "Clean as You Code"** (código novo não pode adicionar issue/bug/vuln/hotspot; legado grandfathered) + adicionar `-Dsonar.qualitygate.wait=true` para **bloquear** o PR (hoje o job é inerte: secrets-gated, sem wait).
- **Acceptance:** PR que introduz um code-smell/bug/vuln em código novo falha o gate; legado não bloqueia. Documentar suppressions legítimas (já há h1h6 no properties).
### Task 2 — Catraca de vulnerabilidades (osv-scanner)
- **Tool:** osv-scanner (Google/OSV, OSS) — `--format json`, on-box.
- **Files:** `scripts/check/check-vuln-ratchet.mjs`, `quality-baseline.json` (+`vulnCount`), `collect-metrics.mjs`, `ci.yml` (job `quality-gate`), `dependency-allowlist.json`.
- **Approach:** rodar `osv-scanner --format json` sobre os lockfiles → contar vulns → métrica `vulnCount {direction: down}`. Catraca: não pode subir, decai a zero. Mantém o `npm audit` escalonado da Fase 0 como bloqueio de crítico imediato; osv é o ratchet de timeline.
- **Acceptance:** nova dep com vuln conhecida sobe o count → falha; remediar/remover baixa → `--update`.
### Task 3 — Catraca de alertas CodeQL
- **Tool:** GitHub CodeQL (já roda; grátis p/ repo público).
- **Files:** `scripts/check/check-codeql-ratchet.mjs`, `quality-baseline.json` (+`codeqlAlerts`), `ci.yml`.
- **Approach:** puxar a contagem de alertas abertos via `gh api /repos/{owner}/{repo}/code-scanning/alerts?state=open` → métrica `codeqlAlerts {down}`. (Respeitar Hard Rule #14 — dismiss só com justificativa; alertas dismissed não contam.)
- **Acceptance:** novo alerta CodeQL sobe o count → sinaliza; resolver baixa.
### Task 4 — Dead-code / unused-exports / unused-deps (knip)
- **Tool:** knip (OSS, v6+) — `--reporter json`.
- **Files:** `scripts/check/check-dead-code.mjs`, `quality-baseline.json` (+`deadExports`/`unusedDeps`), `knip.json` (config), `collect-metrics.mjs`, `ci.yml`, `dependency-allowlist.json`.
- **Approach:** `knip --reporter json` sobre os workspaces `src/`+`open-sse/` → contar unused files/exports/deps → catraca `down`. Config knip ciente do monorepo + Next 16.
- **Acceptance:** novo export/dep morto sobe → falha; remoção baixa.
### Task 5 — Complexidade cognitiva (eslint-plugin-sonarjs, config isolado)
- **Tool:** eslint-plugin-sonarjs (OSS) — `sonarjs/cognitive-complexity`.
- **Files:** `eslint.sonarjs.config.mjs` (config standalone, NÃO o principal — não polui o `eslintWarnings=3482`), `scripts/check/check-cognitive-complexity.mjs`, `quality-baseline.json` (+`cognitiveComplexity`), `ci.yml` (job `quality-gate`), `dependency-allowlist.json`.
- **Approach:** mesmo molde do `check-complexity` (Fase 6) mas com `sonarjs/cognitive-complexity` num config isolado; contar violações → catraca `down`. (Complementa a complexidade ciclomática core já existente.)
- **Acceptance:** função acima do limite cognitivo sobe o count → falha.
### Task 6 — Type-coverage ratchet
- **Tool:** type-coverage (OSS).
- **Files:** `scripts/check/check-type-coverage.mjs`, `quality-baseline.json` (+`typeCoveragePct {up}`), `dependency-allowlist.json`.
- **Approach:** `type-coverage --detail --json` → % de símbolos tipados → catraca `up`. Complementa o `check:any-budget` (count de `any` por arquivo) com a visão %-global.
- **Acceptance:** queda do % tipado → falha.
### Task 7 — Lockfile policy (lockfile-lint)
- **Tool:** lockfile-lint (OSS, v5).
- **Files:** `scripts/check/check-lockfile.mjs`, `ci.yml` (lint), `dependency-allowlist.json`.
- **Approach:** `lockfile-lint --path package-lock.json --type npm --validate-https --validate-integrity --allowed-hosts npm` → gate pass/fail (não é ratchet; é política anti-poisoning). Complementa o `check-deps` (Fase 2).
- **Acceptance:** lockfile com host não-https/sem integrity → falha.
### Task 8 — Completar anti-slopsquatting (registry-existence + age-cooldown)
- **Tool:** npm registry API (`npm view <pkg> time.created`).
- **Files:** `scripts/check/check-deps.mjs` (estender), test.
- **Approach:** além do allowlist-diff atual, para uma dep NOVA: verificar que existe no registry E que foi publicada há ≥72h (age-cooldown contra "registra o nome alucinado em horas"). Base: CSA 2026 (19,7% de nomes alucinados; 43% reaparecem).
- **Acceptance:** dep nova inexistente no registry ou publicada há <72h → falha (a menos que allowlistada com justificativa).
### Task 9 — Pisos de cobertura por módulo crítico (peça adiada da Fase 4)
- **Files:** `scripts/quality/collect-metrics.mjs` (estender), `quality-baseline.json`.
- **Approach:** emitir `coverage.<modulo>.lines` (lido do `coverage-summary.json` por-arquivo) para ~8 módulos de alto risco: `open-sse/handlers/chatCore.ts`, `open-sse/services/combo.ts`, `open-sse/services/accountFallback.ts`, `src/sse/services/auth.ts`, `src/server/authz/routeGuard.ts`, `open-sse/utils/error.ts`, `open-sse/utils/publicCreds.ts`, `src/shared/utils/circuitBreaker.ts`. Cada um vira métrica `up`. **Calibrar a partir do coverage mergeado real do 1º run verde na main.**
- **Acceptance:** queda de cobertura num módulo crítico → falha, mesmo que o global não caia.
### Task 10 — Evidence-in-PR-body (peça adiada da Fase 5)
- **Files:** `scripts/check/check-pr-evidence.mjs`, `ci.yml` (job `pr-test-policy`).
- **Approach:** se o corpo do PR afirma "tests pass"/"added endpoint X"/"fixed Y" sem um bloco de **output de comando** anexado (typecheck/test/grep), falha (torna a Rule #18 mecânica — "evidence before assertions"). Heurístico, no contexto de PR.
- **Acceptance:** PR alegando sucesso sem output anexado → falha.
### Task 11 — Mutation testing nos módulos críticos (Stryker, nightly)
- **Tool:** Stryker (`@stryker-mutator/core` + runner; OSS).
- **Files:** `stryker.conf.json`, `ci.yml` (job NIGHTLY separado — não no PR), `dependency-allowlist.json`.
- **Approach:** escopar Stryker aos ~8 módulos críticos da Task 9 (não repo-wide — é caro + c8 já OOM-prone). Mutantes sobreviventes = **testes tautológicos** (passam sem provar nada) → complementa o `check-test-masking` da Fase 4. Rodar nightly/weekly, não por-PR.
- **Acceptance:** mutation score por módulo crítico vira métrica (catraca `up`, nightly).
### Task 12 — Bundle-size / perf budget (size-limit)
- **Tool:** size-limit (OSS).
- **Files:** `.size-limit.json`, `scripts/check/check-bundle-size.mjs`, `ci.yml`, `dependency-allowlist.json`.
- **Approach:** definir orçamento por bundle Next 16; size-limit emite tamanhos → catraca `down` (bundle não pode inchar).
- **Acceptance:** PR que estoura o orçamento de bundle → falha.
### Task 13 — a11y gate (axe-core + Playwright)
- **Tool:** `@axe-core/playwright` (OSS; Playwright já existe).
- **Files:** `tests/e2e/a11y.spec.ts`, `ci.yml` (job `test-e2e`), `dependency-allowlist.json`.
- **Approach:** rodar axe nas páginas-chave do dashboard; congelar violações atuais (catraca `down`). Atende o item a11y/visual do plano t15.
- **Acceptance:** nova violação a11y → falha; correção baixa.
### Task 14 — semcheck (camada fuzzy docs↔código, opcional/LLM)
- **Tool:** semcheck (OSS, MIT) — `fail-on-issues`.
- **Files:** `semcheck.yaml`, `ci.yml` (advisory).
- **Approach:** regras ligando `docs/**` ao módulo de código que documentam; pega docs que descrevem o que o código NÃO faz (camada fuzzy sobre o determinístico `check-docs-symbols` da Fase 6). É LLM → rodar advisory/non-blocking ou em label, por custo.
- **Acceptance:** doc que descreve comportamento inexistente → flag (advisory).
### Task 15 — agent-lsp (LSP-in-the-loop para os agentes)
- **Tool:** agent-lsp (MCP server, OSS).
- **Files:** config MCP dos agentes (`.mcp.json`/equivalente).
- **Approach:** expor `tsserver`/agent-lsp aos agentes para `blast_radius`/diagnostics/`preview_edit` ANTES de escrever — vira "símbolo inventado" de catch-de-review para impossibilidade-no-edit. Pareia com `typecheck:core` como gate pré-PR (compile-before-claim).
- **Acceptance:** agentes resolvem símbolo/import via LSP; menos alucinação de símbolo na origem.
### Task 16 — dpdm circular-deps JSON cross-check (opcional)
- **Tool:** dpdm (OSS, v4) — `--circular --output`.
- **Approach:** cross-check JSON de ciclos complementando o `check-cycles.mjs` existente (AST-TS mais preciso). Catraca de contagem de ciclos. Baixa prioridade (já temos check-cycles).
### Task 17 — Avaliar Qlty CLI como consolidador (opcional, spike)
- **Tool:** Qlty CLI (OSS, grátis).
- **Approach:** spike: avaliar se Qlty (Baseline analysis + 70 analyzers) substitui N scripts caseiros sem perder o controle/determinismo. Decisão build-vs-buy. Não obrigatório.
### Task 18 — Secret scanning local (gitleaks) *adicionada pela auditoria 6A (2026-06-09)*
- **Tool:** gitleaks (OSS, MIT — binário Go, on-box). Nota 2026: o criador original (Zach Rice) lançou o **Betterleaks** (2026-03) como drop-in replacement (flags/config compatíveis) — avaliar os dois no Step 0 e escolher 1.
- **Files:** `.gitleaks.toml`, `scripts/check/check-secrets.mjs`, `quality-baseline.json` (+`secretFindings {down}`), `ci.yml` (job quality-gate), pre-commit (modo `--staged`, é rápido).
- **Approach:** complementa o `check-public-creds` da Fase 6 (que cobre apenas credenciais PÚBLICAS por chave de objeto em 2 arquivos): gitleaks pega a classe geral — `const API_KEY = "sk-…"`, tokens em config/teste/docs, secrets em histórico. Rodar `gitleaks dir --report-format json` → contar findings → catraca `down`. Findings legítimos (creds públicas já congeladas no check-public-creds, fixtures de teste) vão para `.gitleaks.toml` `[allowlist]` com comentário — sujeitos ao stale-enforcement da 6A.3 (conceitual: revisar allowlist a cada release).
- **Acceptance:** secret real plantado em fixture é detectado; baseline congela os findings atuais; novo finding falha o gate.
### Task 19 — Lint + auditoria de segurança dos workflows (actionlint + zizmor) *adicionada pela auditoria 6A*
- **Tools:** actionlint (OSS — correção/sintaxe/shellcheck dos YAML) + zizmor (OSS, zizmorcore — 24+ audits de segurança: unpinned actions, script injection, `pull_request_target` perigoso, cache poisoning). Complementares por design; o repo tem 10 workflows sem NENHUMA validação hoje.
- **Motivação 2026:** o incidente trivy-action/LiteLLM (2026-03) explorou exatamente uma misconfiguração de `pull_request_target` que o zizmor detecta estaticamente. Os release-workflows do OmniRoute (npm/Docker/Electron publish) são alvo de alto valor.
- **Files:** `ci.yml` ou `quality.yml` (steps actionlint + zizmor), `zizmor.yml` (config/ignores justificados), `quality-baseline.json` (+`zizmorFindings {down}` — começar advisory, congelar baseline, depois bloquear).
- **Approach:** actionlint = pass/fail imediato (sintaxe não tem "legado aceitável"); zizmor = catraca `down` no padrão do motor (os findings atuais — provavelmente actions não-pinadas por SHA — são dívida congelada que decai).
- **Acceptance:** workflow novo com `pull_request_target` + checkout de código do PR falha; action não-pinada NOVA sobe o count e falha.
### Task 20 — License compliance (allowlist SPDX) *adicionada pela auditoria 6A*
- **Tool:** license-compliance ou @onebeyond/license-checker (ambos OSS, npm). Projeto é **MIT** — deps com copyleft forte (GPL/AGPL) em produção são risco de compliance para os usuários do proxy.
- **Files:** `scripts/check/check-licenses.mjs`, `.license-allowlist.json` (SPDX permitidas: MIT, Apache-2.0, BSD-2/3, ISC, 0BSD, …), `ci.yml` (lint job), `dependency-allowlist.json`.
- **Approach:** rodar sobre as `dependencies` de produção (devDependencies = relatório advisory); licença fora da allowlist → fail com o caminho da dep. Exceções pontuais (dual-license, LGPL avaliada) entram na allowlist por **pacote** com justificativa — pareia com o `check-deps` da Fase 2 (lá controla O QUE entra; aqui, SOB QUAL licença).
- **Acceptance:** dep GPL-3.0 sintética em fixture falha; árvore atual passa com a allowlist calibrada.
---
## Wiring & CI (resumo)
- **lint job:** check-lockfile, check-cognitive-complexity (rápido?), check-type-coverage, **check-licenses (Task 20)**, **actionlint (Task 19)**.
- **quality-gate job (paralelo):** check-vuln-ratchet, check-dead-code, check-codeql-ratchet, check-cognitive-complexity (se lento), check-bundle-size, **check-secrets (Task 18)**, **zizmor (Task 19)**.
- **pr-test-policy job:** check-pr-evidence.
- **sonarqube job:** Clean-as-You-Code + `qualitygate.wait`.
- **NIGHTLY job (novo):** Stryker (mutação), semcheck (advisory), a11y full.
- Todas as métricas numéricas → `quality-baseline.json` (motor da Fase 1, com `eps`/`tightenSlack` da 6A.5). Toda dep nova → `dependency-allowlist.json`. Toda allowlist nova nasce com o stale-enforcement da 6A.3.
## Self-Review
- **Cobertura do spec:** 7 gates sugeridos = Task 1-3 (segurança), 4 (knip), 9 (coverage por módulo), 10 (evidence), 11 (mutação), 12 (bundle), 13 (a11y). "Todas as ferramentas discutidas" = Tasks 1-8, 11-17 (Sonar/osv/CodeQL/knip/sonarjs/type-coverage/lockfile/dpdm/stryker/size-limit/axe/semcheck/agent-lsp/Qlty). Auditoria 6A (2026-06-09) acrescentou Tasks 18-20 (gitleaks, actionlint+zizmor, license compliance). ✓
- **Community/OSS only:** confirmado — Sonar Community Build, todos os demais OSS (gitleaks MIT, zizmor/actionlint OSS, license-compliance npm), zero SaaS pago. ✓
- **Sem flag-day:** toda catraca é só-regressão, calibrada do estado atual (zizmor/gitleaks começam advisory→baseline→bloqueio). ✓
- **Consistência:** todas as métricas usam o formato `{value, direction}` do motor da Fase 1; deps novas passam pelo `check-deps`+`dependency-allowlist.json`. ✓
- **Não-sobreposição com a 6A:** a 6A conserta/endurece o EXISTENTE (runners, stale-allowlists, escopos); a Fase 7 só adiciona ferramenta nova. gitleaks (Task 18) complementa — não substitui — o check-public-creds expandido pela 6A.8. ✓
## Handoff (na ativação, 2026-06-16+)
**Ordem: Fase 6A primeiro** ([`PLANO-QUALITY-GATES-FASE6A.md`](./PLANO-QUALITY-GATES-FASE6A.md)) — consertar os runners (testes órfãos + vitest no CI) e endurecer as catracas existentes muda os baselines (cobertura recalibrada) sobre os quais várias tasks daqui (1, 9, 11) calibram. Depois: começar pela **Task 1-3 (catraca de segurança)**, **Task 4 (knip)** e **Task 19 (zizmor — protege os release-workflows)** — maior retorno. Cada Task vira um sub-plano `writing-plans` bite-sized próprio. Recomendado: Subagent-Driven, 1 subagente por Task, com auditoria (trust-but-verify) e o ratchet `eslintWarnings`/`check-deps` validando que cada adição não regride o que já temos.

View File

@@ -1,688 +0,0 @@
# Plano de Implementação — Quality Gates & Catraca Anti-Alucinação
> **Para workers agênticos:** SUB-SKILL OBRIGATÓRIA: use `superpowers:subagent-driven-development` (recomendado) ou `superpowers:executing-plans` para executar este plano tarefa-a-tarefa. Os passos usam checkbox (`- [ ]`) para rastreio. **Cada fix de bug obedece à Hard Rule #18** (teste falha→passa OU validação ao vivo no VPS). Não burle Husky (`--no-verify`) sem aprovação. Veja o diagnóstico completo em [`RELATORIO-QUALITY-GATES.md`](./RELATORIO-QUALITY-GATES.md).
**Goal:** Generalizar a catraca de qualidade do OmniRoute (hoje só para `any`) para todas as métricas relevantes — cobertura, duplicação, tamanho de arquivo, complexidade — e adicionar gates determinísticos anti-alucinação, tudo no padrão `check-*.mjs` já existente, sem SaaS novo.
**Architecture:** Camadas incrementais. (0) reativa/reconcilia o que já existe; (1) constrói o **motor de catraca** (`quality-baseline.json` commitado + coletor + comparador genérico que clona o `check-t11-any-budget.mjs`); (2) adiciona gates determinísticos que matam os ímãs de alucinação (provider-consistency, fetch-targets, openapi-routes); (3) catraca de duplicação+tamanho; (4) catraca de cobertura + anti test-masking; (5) skill `/babysit` + evidência. Toda catraca é **só-regressão** (baseline congelado) — nunca um piso absoluto que exija limpeza flag-day.
**Tech Stack:** Node ≥20 ESM (`.mjs`/`.ts` via `tsx`), ESLint 9 flat config, c8, jscpd v5, eslint-plugin-sonarjs v4, GitHub Actions, Node native test runner (`node --import tsx --test`), `gh` CLI + GraphQL.
**Escopo / sub-planos (scope-check):** Fases 0, 1 e 2 estão totalmente bite-sized aqui. Fases 3, 4 e 5 são subsistemas independentes — cada uma deve ser **expandida no próprio sub-plano** (via `writing-plans`) no momento da execução, a partir das specs/critérios de aceitação definidos aqui. Cada fase entrega software funcional e testável por si só.
---
## Mapa de arquivos (o que será criado/modificado)
| Arquivo | Responsabilidade |
|---------|------------------|
| `quality-baseline.json` (criar, **commitar**) | Baseline congelado: por métrica `{value, direction}` (`down`=menor-é-melhor, `up`=maior-é-melhor) |
| `scripts/quality/collect-metrics.mjs` (criar) | Roda os coletores → emite `quality-metrics.json` |
| `scripts/quality/check-quality-ratchet.mjs` (criar) | Comparador genérico: falha em qualquer regressão; com `--update` ratcheta o baseline |
| `scripts/check/check-fetch-targets.mjs` (criar) | Todo `fetch("/api/...")` do dashboard resolve para um `route.ts` real |
| `scripts/check/check-provider-consistency.ts` (criar) | ids de provider batem entre `providers.ts``providerRegistry.ts``validation.ts` |
| `scripts/check/check-openapi-routes.mjs` (criar) | Toda `path` do `openapi.yaml``route.ts` real (bidirecional) |
| `scripts/check/check-deps.mjs` (criar) | Anti-slopsquatting: allowlist + existência no registry + age-cooldown |
| `.github/workflows/ci.yml` (modificar) | Novo job `quality-gate`; reconciliar gate de cobertura; escalonar audit; plugar scripts órfãos |
| `.husky/pre-commit` (modificar) | Reativar a parte barata |
| `package.json` (modificar) | Novos scripts `check:*` / `quality:*` |
| `eslint.config.mjs` (modificar, Fase 3) | `max-lines`, `max-lines-per-function`, `complexity`, `sonarjs/cognitive-complexity` (warn) |
| `.claude/skills/babysit/SKILL.md` (criar, Fase 5) | Skill `/babysit` |
| `tests/unit/quality-ratchet.test.ts` etc. (criar) | Testes TDD de cada gate |
---
# FASE 0 — Reativar & Reconciliar (quick wins, sem tooling novo)
### Task 0.1: Reconciliar o gate de cobertura do CI (40 → baseline real)
**Contexto:** `ci.yml:377` gata em `40/40/40/40`; local gata `60`; comentário renderiza `60`; baseline real ≈ 7982%. O 40 torna o gate quase banguela.
**Files:**
- Modify: `.github/workflows/ci.yml:376-377`
- [ ] **Step 1: Confirmar o baseline real de cobertura**
Run:
```bash
npm run test:coverage 2>&1 | tail -20
node -e "const c=require('./coverage/coverage-summary.json').total; console.log(c.statements.pct,c.lines.pct,c.functions.pct,c.branches.pct)"
```
Expected: 4 números (ex.: `79.8 79.8 82.2 75.2`). Anote-os.
- [ ] **Step 2: Subir o gate do CI para `baseline_real - 2` (headroom anti-flake)**
Em `.github/workflows/ci.yml`, troque a linha `--statements 40 --lines 40 --functions 40 --branches 40` pelos valores `(real-2)` de cada métrica (ex.: `--statements 77 --lines 77 --functions 80 --branches 73`). Mantenha como **piso**; a catraca da Fase 4 cuidará do "não-cair".
- [ ] **Step 3: Alinhar o script local e o display do comentário** para os mesmos números (procure `60` em `package.json` `test:coverage` e em `scripts/check/test-report-summary.mjs`).
- [ ] **Step 4: Verificar que o CI não quebra** — abrir um PR de teste (ou rodar `act`/push numa branch) e confirmar que o job `test-coverage` fica verde com o novo piso.
- [ ] **Step 5: Commit**
```bash
git add .github/workflows/ci.yml package.json scripts/check/test-report-summary.mjs
git commit -m "fix(ci): reconcile coverage gate to real baseline (40->~78) across CI/local/report"
```
### Task 0.2: Escalonar `npm audit` (critical=bloqueia / high=avisa)
**Files:** Modify: `package.json:112`
- [ ] **Step 1: Trocar o script `audit:deps`** de `npm audit --audit-level=moderate && npm run audit:electron` para:
```json
"audit:deps": "npm audit --audit-level=critical && (npm audit --audit-level=high || echo '::warning::high-severity advisories present (non-blocking)') && npm run audit:electron",
```
- [ ] **Step 2: Rodar e observar o comportamento**
```bash
npm run audit:deps; echo "exit=$?"
```
Expected: `exit=0` se não houver critical; mensagem de warning se houver high.
- [ ] **Step 3: Commit**
```bash
git add package.json && git commit -m "chore(ci): tier npm audit (critical blocks, high warns)"
```
### Task 0.3: Plugar os 3 scripts órfãos no CI
**Contexto:** `check:cli-i18n`, `check:openapi-coverage`, `check:openapi-security-tiers` existem, dão `exit 1`, mas não rodam em lugar nenhum (Hard Rules #15/#17).
**Files:** Modify: `.github/workflows/ci.yml` (job `docs-sync-strict` ou `lint`)
- [ ] **Step 1: Rodar os 3 localmente para confirmar verde no estado atual**
```bash
npm run check:cli-i18n && npm run check:openapi-coverage && npm run check:openapi-security-tiers; echo "exit=$?"
```
Expected: `exit=0` (se algum falhar, corrija a deriva antes de plugar).
- [ ] **Step 2: Adicionar os 3 como steps** no job `docs-sync-strict` do `ci.yml`, após `check:docs-all`.
- [ ] **Step 3: Commit**
```bash
git add .github/workflows/ci.yml && git commit -m "ci: wire orphaned gates (cli-i18n, openapi-coverage, openapi-security-tiers)"
```
### Task 0.4: Reativar a parte barata do pre-commit do Husky
**Files:** Modify: `.husky/pre-commit`
- [ ] **Step 1: Descomentar SÓ as 3 linhas baratas e determinísticas:**
```sh
npx lint-staged
node scripts/check/check-docs-sync.mjs
npm run check:any-budget:t11
```
(Deixe i18n/openapi comentados por enquanto — eles são mais lentos; rodam no CI.)
- [ ] **Step 2: Testar o hook** com um commit trivial e medir o tempo:
```bash
time git commit --allow-empty -m "chore: test pre-commit hook"
git reset --soft HEAD~1
```
Expected: hook roda lint-staged + 2 checks em poucos segundos.
- [ ] **Step 3: Commit**
```bash
git add .husky/pre-commit && git commit -m "chore(husky): re-enable cheap pre-commit gates (lint-staged, docs-sync, any-budget)"
```
---
# FASE 1 — Motor de Catraca (o coração) ⭐
> Generaliza o `check-t11-any-budget.mjs` (catraca de `any` por arquivo) para um motor de catraca genérico, multi-métrica, que lê um baseline commitado e falha em qualquer regressão. Começa com 2 métricas (warnings de ESLint + cobertura) e é estendido nas fases seguintes.
### Task 1.1: Comparador genérico de catraca (TDD)
**Files:**
- Create: `scripts/quality/check-quality-ratchet.mjs`
- Test: `tests/unit/quality-ratchet.test.ts`
- [ ] **Step 1: Escrever o teste que falha**
```ts
// tests/unit/quality-ratchet.test.ts
import { test } from "node:test";
import assert from "node:assert";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const SCRIPT = path.resolve("scripts/quality/check-quality-ratchet.mjs");
function run(baseline, metrics, extraArgs = []) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ratchet-"));
const bPath = path.join(dir, "baseline.json");
const mPath = path.join(dir, "metrics.json");
fs.writeFileSync(bPath, JSON.stringify(baseline));
fs.writeFileSync(mPath, JSON.stringify(metrics));
try {
const out = execFileSync("node", [SCRIPT, "--baseline", bPath, "--metrics", mPath, ...extraArgs], { encoding: "utf8" });
return { code: 0, out, dir, bPath };
} catch (e) {
return { code: e.status, out: (e.stdout || "") + (e.stderr || ""), dir, bPath };
}
}
test("passes when metrics equal baseline", () => {
const b = { metrics: { eslintWarnings: { value: 100, direction: "down" }, "coverage.lines": { value: 80, direction: "up" } } };
assert.equal(run(b, { eslintWarnings: 100, "coverage.lines": 80 }).code, 0);
});
test("fails when a 'down' metric regresses (more warnings)", () => {
const b = { metrics: { eslintWarnings: { value: 100, direction: "down" } } };
const r = run(b, { eslintWarnings: 101 });
assert.equal(r.code, 1);
assert.match(r.out, /eslintWarnings/);
});
test("fails when an 'up' metric regresses (coverage drops)", () => {
const b = { metrics: { "coverage.lines": { value: 80, direction: "up" } } };
assert.equal(run(b, { "coverage.lines": 79 }).code, 1);
});
test("passes on improvement; --update ratchets the baseline", () => {
const b = { metrics: { eslintWarnings: { value: 100, direction: "down" } } };
const r = run(b, { eslintWarnings: 90 }, ["--update"]);
assert.equal(r.code, 0);
const updated = JSON.parse(fs.readFileSync(r.bPath, "utf8"));
assert.equal(updated.metrics.eslintWarnings.value, 90);
});
test("fails (code 2) when a baseline metric is missing from collected metrics", () => {
const b = { metrics: { eslintWarnings: { value: 100, direction: "down" } } };
assert.equal(run(b, {}).code, 1);
});
```
- [ ] **Step 2: Rodar o teste e ver falhar**
```bash
node --import tsx --test tests/unit/quality-ratchet.test.ts
```
Expected: FAIL (script não existe ainda).
- [ ] **Step 3: Implementar o comparador**
```js
#!/usr/bin/env node
// scripts/quality/check-quality-ratchet.mjs
// Catraca genérica multi-métrica. Clona o espírito de check-t11-any-budget.mjs:
// um baseline congelado por métrica; falha em qualquer regressão; só anda num sentido.
import fs from "node:fs";
import path from "node:path";
const cwd = process.cwd();
function getArg(name, fallback) {
const i = process.argv.indexOf(name);
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
}
const BASELINE = path.resolve(getArg("--baseline", path.join(cwd, "quality-baseline.json")));
const METRICS = path.resolve(getArg("--metrics", path.join(cwd, "quality-metrics.json")));
const SUMMARY = getArg("--summary", null);
const UPDATE = process.argv.includes("--update");
const EPS = 0.01;
function load(p) {
if (!fs.existsSync(p)) { console.error(`[quality-ratchet] arquivo ausente: ${p}`); process.exit(2); }
return JSON.parse(fs.readFileSync(p, "utf8"));
}
const baseline = load(BASELINE);
const metrics = load(METRICS);
const failures = [];
const improvements = [];
const rows = [];
for (const [key, spec] of Object.entries(baseline.metrics)) {
const current = metrics[key];
const base = spec.value;
const dir = spec.direction; // "down" = menor-é-melhor | "up" = maior-é-melhor
if (current === undefined) { failures.push(`métrica "${key}" ausente em ${path.basename(METRICS)}`); rows.push([key, base, "—", "MISSING"]); continue; }
let status = "ok";
if (dir === "down") {
if (current > base + EPS) { failures.push(`${key}: ${current} > baseline ${base} (não pode aumentar)`); status = "REGRESSÃO"; }
else if (current < base - EPS) { improvements.push([key, current]); status = "↑ melhorou"; }
} else {
if (current < base - EPS) { failures.push(`${key}: ${current} < baseline ${base} (não pode cair)`); status = "REGRESSÃO"; }
else if (current > base + EPS) { improvements.push([key, current]); status = "↑ melhorou"; }
}
rows.push([key, base, current, status]);
}
if (SUMMARY) {
const md = ["# Quality Ratchet", "", "| Métrica | Baseline | Atual | Status |", "|---|---|---|---|",
...rows.map(([k, b, c, s]) => `| ${k} | ${b} | ${c} | ${s} |`), "",
failures.length ? `**${failures.length} regressão(ões) — gate BLOQUEADO.**` : "**Sem regressões — gate OK.**"].join("\n");
fs.mkdirSync(path.dirname(SUMMARY), { recursive: true });
fs.writeFileSync(SUMMARY, md + "\n");
}
if (UPDATE && failures.length === 0 && improvements.length) {
for (const [key, val] of improvements) baseline.metrics[key].value = val;
fs.writeFileSync(BASELINE, JSON.stringify(baseline, null, 2) + "\n");
console.log(`[quality-ratchet] baseline ratcheado: ${improvements.length} métrica(s) melhoraram`);
}
if (failures.length) { console.error("[quality-ratchet] FALHOU:\n" + failures.map((f) => " ✗ " + f).join("\n")); process.exit(1); }
console.log(`[quality-ratchet] OK (${rows.length} métricas, ${improvements.length} melhoraram)`);
```
- [ ] **Step 4: Rodar o teste e ver passar**
```bash
node --import tsx --test tests/unit/quality-ratchet.test.ts
```
Expected: PASS (5/5).
- [ ] **Step 5: Commit**
```bash
git add scripts/quality/check-quality-ratchet.mjs tests/unit/quality-ratchet.test.ts
git commit -m "feat(quality): generic ratchet comparator (multi-metric, regression-only)"
```
### Task 1.2: Coletor de métricas (ESLint warnings + cobertura)
**Files:** Create: `scripts/quality/collect-metrics.mjs`
- [ ] **Step 1: Implementar o coletor**
```js
#!/usr/bin/env node
// scripts/quality/collect-metrics.mjs — emite quality-metrics.json
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
const cwd = process.cwd();
const out = {};
// 1) ESLint: contagem de warnings (errors devem ser 0; o lint já gata isso)
function eslintCounts() {
let stdout;
try {
stdout = execFileSync("npx", ["eslint", ".", "--format", "json"], { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
} catch (e) { stdout = e.stdout?.toString() || "[]"; } // eslint sai !=0 quando há errors
const results = JSON.parse(stdout);
out.eslintWarnings = results.reduce((n, r) => n + (r.warningCount || 0), 0);
out.eslintErrors = results.reduce((n, r) => n + (r.errorCount || 0), 0);
}
// 2) Cobertura: lê coverage/coverage-summary.json se existir
function coverage() {
const p = path.join(cwd, "coverage", "coverage-summary.json");
if (!fs.existsSync(p)) return;
const t = JSON.parse(fs.readFileSync(p, "utf8")).total;
out["coverage.statements"] = t.statements.pct;
out["coverage.lines"] = t.lines.pct;
out["coverage.functions"] = t.functions.pct;
out["coverage.branches"] = t.branches.pct;
}
eslintCounts();
coverage();
fs.writeFileSync(path.join(cwd, "quality-metrics.json"), JSON.stringify(out, null, 2) + "\n");
console.log("[collect-metrics]", JSON.stringify(out));
```
- [ ] **Step 2: Rodar e inspecionar a saída**
```bash
node scripts/quality/collect-metrics.mjs && cat quality-metrics.json
```
Expected: JSON com `eslintWarnings`, `eslintErrors` e (se houver coverage) os 4 `coverage.*`. **Anote `eslintWarnings`.**
- [ ] **Step 3: Commit**
```bash
git add scripts/quality/collect-metrics.mjs && echo "quality-metrics.json" >> .gitignore
git add .gitignore && git commit -m "feat(quality): metrics collector (eslint warnings + coverage)"
```
### Task 1.3: Congelar o baseline inicial
**Files:** Create: `quality-baseline.json` (**commitado**)
- [ ] **Step 1: Gerar o baseline a partir das métricas reais** (use os números anotados):
```json
{
"_comment": "Catraca: 'down' nao pode aumentar, 'up' nao pode cair. Atualize via 'npm run quality:ratchet -- --update' (so em melhora).",
"metrics": {
"eslintWarnings": { "value": <N_REAL>, "direction": "down" },
"coverage.statements": { "value": <S>, "direction": "up" },
"coverage.lines": { "value": <L>, "direction": "up" },
"coverage.functions": { "value": <F>, "direction": "up" },
"coverage.branches": { "value": <B>, "direction": "up" }
}
}
```
- [ ] **Step 2: Validar a catraca contra si mesma**
```bash
node scripts/quality/collect-metrics.mjs
node scripts/quality/check-quality-ratchet.mjs; echo "exit=$?"
```
Expected: `[quality-ratchet] OK` e `exit=0`.
- [ ] **Step 3: Provar que pega regressão** (teste manual): edite `quality-metrics.json` somando 1 a `eslintWarnings`, rode o comparador, confirme `exit=1`, depois descarte a edição.
- [ ] **Step 4: Adicionar scripts npm**
```json
"quality:collect": "node scripts/quality/collect-metrics.mjs",
"quality:ratchet": "node scripts/quality/check-quality-ratchet.mjs",
"quality:gate": "npm run quality:collect && npm run quality:ratchet"
```
- [ ] **Step 5: Commit**
```bash
git add quality-baseline.json package.json
git commit -m "feat(quality): freeze initial quality baseline (eslint warnings + coverage)"
```
### Task 1.4: Wire no CI (job + artefato + comentário no PR)
**Files:** Modify: `.github/workflows/ci.yml`
- [ ] **Step 1: Adicionar job `quality-gate`** (depois de `test-coverage`, para reusar `coverage/coverage-summary.json`):
```yaml
quality-gate:
name: Quality Ratchet
runs-on: ubuntu-latest
needs: test-coverage
if: ${{ always() && needs.test-coverage.result == 'success' }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '24', cache: 'npm' }
- run: npm ci
- uses: actions/download-artifact@v4
with: { name: coverage-report, path: coverage/ }
- run: npm run quality:collect
- run: node scripts/quality/check-quality-ratchet.mjs --summary .artifacts/quality-ratchet.md
- if: always()
run: cat .artifacts/quality-ratchet.md >> "$GITHUB_STEP_SUMMARY"
- if: always()
uses: actions/upload-artifact@v4
with: { name: quality-ratchet, path: .artifacts/quality-ratchet.md }
```
- [ ] **Step 2: Adicionar comentário no PR** clonando o job `coverage-pr-comment` (marcador `<!-- omniroute-quality-ratchet -->`, lê `.artifacts/quality-ratchet.md`). Reuse o mesmo `github-script` de upsert de comentário.
- [ ] **Step 3: Validar num PR de teste** — confirmar que o job aparece, o step summary mostra a tabela, e o comentário é postado.
- [ ] **Step 4: Commit**
```bash
git add .github/workflows/ci.yml
git commit -m "ci(quality): add quality-ratchet job with PR comment + artifact"
```
---
# FASE 2 — Gates Determinísticos Anti-Alucinação
> Cada gate ataca um ímã de alucinação específico (ver §2.4 do relatório). Todos no padrão `check-*.mjs`. **`check-fetch-targets` vem com código completo** (parsing determinístico de arquivos, sem risco de inventar nomes de export). **`check-provider-consistency` e `check-openapi-routes` começam com um Step 0 de verificação dos nomes reais** — propositalmente, porque fabricar a forma do import/spec seria o exato anti-padrão que este plano combate.
### Task 2.1: `check-fetch-targets.mjs` — toda rota chamada pelo dashboard existe (TDD)
**Ataca:** ímã nº2 (300 paths `fetch("/api/...")` sem ligação com as rotas).
**Files:**
- Create: `scripts/check/check-fetch-targets.mjs`
- Test: `tests/unit/check-fetch-targets.test.ts`
- [ ] **Step 1: Escrever o teste que falha**
```ts
// tests/unit/check-fetch-targets.test.ts
import { test } from "node:test";
import assert from "node:assert";
import { resolveApiPathToRoute } from "../../scripts/check/check-fetch-targets.mjs";
test("matches a static route file", () => {
const files = new Set(["src/app/api/usage/route.ts"]);
assert.equal(resolveApiPathToRoute("/api/usage", files), true);
});
test("matches a dynamic [param] segment", () => {
const files = new Set(["src/app/api/providers/[id]/models/route.ts"]);
assert.equal(resolveApiPathToRoute("/api/providers/abc-123/models", files), true);
});
test("rejects a hallucinated route", () => {
const files = new Set(["src/app/api/usage/route.ts"]);
assert.equal(resolveApiPathToRoute("/api/providers/refresh", files), false);
});
```
- [ ] **Step 2: Rodar e ver falhar**
```bash
node --import tsx --test tests/unit/check-fetch-targets.test.ts
```
Expected: FAIL (módulo não existe).
- [ ] **Step 3: Implementar o gate**
```js
#!/usr/bin/env node
// scripts/check/check-fetch-targets.mjs
// Todo fetch("/api/...") em src/app/(dashboard) deve resolver para um route.ts real.
import fs from "node:fs";
import path from "node:path";
const cwd = process.cwd();
const DASH = path.join(cwd, "src/app/(dashboard)");
const API = path.join(cwd, "src/app/api");
// allowlist de paths dinâmicos/externos que o checker não consegue resolver estaticamente
const IGNORE = [/^\/api\/v1\//, /\$\{/, /` \+/]; // /v1 é a superfície OpenAI-compat; templates
function walk(dir, acc = []) {
if (!fs.existsSync(dir)) return acc;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p, acc);
else if (/\.(ts|tsx)$/.test(e.name)) acc.push(p);
}
return acc;
}
function collectRouteFiles() {
return new Set(walk(API).filter((p) => /route\.tsx?$/.test(p)).map((p) => path.relative(cwd, p).replace(/\\/g, "/")));
}
export function resolveApiPathToRoute(apiPath, routeFiles) {
// apiPath ex.: /api/providers/abc/models → src/app/api/providers/[id]/models/route.ts
const segs = apiPath.replace(/^\//, "").replace(/[?#].*$/, "").split("/"); // ["api","providers","abc","models"]
for (const rf of routeFiles) {
const rsegs = rf.replace(/^src\/app\//, "").replace(/\/route\.tsx?$/, "").split("/"); // ["api","providers","[id]","models"]
if (rsegs.length !== segs.length) continue;
const ok = rsegs.every((rs, i) => rs === segs[i] || /^\[.*\]$/.test(rs));
if (ok) return true;
}
return false;
}
function extractFetchPaths(file) {
const src = fs.readFileSync(file, "utf8");
const re = /(?:fetch|fetchJson|apiFetch)\(\s*["'`](\/api\/[^"'`?]+)/g;
const out = [];
let m;
while ((m = re.exec(src))) out.push(m[1]);
return out;
}
function main() {
const routeFiles = collectRouteFiles();
const misses = [];
for (const f of walk(DASH)) {
for (const apiPath of extractFetchPaths(f)) {
if (IGNORE.some((rx) => rx.test(apiPath))) continue;
if (!resolveApiPathToRoute(apiPath, routeFiles)) misses.push(`${path.relative(cwd, f)}${apiPath}`);
}
}
if (misses.length) {
console.error(`[check-fetch-targets] ${misses.length} fetch(es) para rota inexistente:\n` + misses.map((m) => " ✗ " + m).join("\n"));
process.exit(1);
}
console.log(`[check-fetch-targets] OK (${routeFiles.size} rotas conhecidas)`);
}
if (import.meta.url === `file://${process.argv[1]}`) main();
```
- [ ] **Step 4: Rodar o teste unitário e ver passar**
```bash
node --import tsx --test tests/unit/check-fetch-targets.test.ts
```
Expected: PASS (3/3).
- [ ] **Step 5: Rodar o gate no repo real** (modo descoberta — pode encontrar misses legítimos)
```bash
node scripts/check/check-fetch-targets.mjs; echo "exit=$?"
```
Se encontrar misses reais: triagem — ou são rotas faltantes (bug), ou paths dinâmicos a adicionar ao `IGNORE`. **Não** mascare; documente cada exceção no `IGNORE` com comentário.
- [ ] **Step 6: Adicionar script + commit**
```json
"check:fetch-targets": "node scripts/check/check-fetch-targets.mjs"
```
```bash
git add scripts/check/check-fetch-targets.mjs tests/unit/check-fetch-targets.test.ts package.json
git commit -m "feat(check): gate that every dashboard fetch() targets a real API route"
```
### Task 2.2: `check-provider-consistency.ts` — ids batem entre os 3 arquivos
**Ataca:** ímã nº1 (split de provider; 229 vs 155 vs N já divergem).
**Files:** Create: `scripts/check/check-provider-consistency.ts` (rodado via `tsx`); Test: `tests/unit/check-provider-consistency.test.ts`
- [ ] **Step 0 (VERIFICAÇÃO — obrigatório antes de codar):** descobrir os nomes reais de export, para **não inventar**:
```bash
grep -nE "export (const|function) " src/shared/constants/providers.ts | grep -iE "provider|section" | head
grep -nE "getOrCreateAiProviders|_PROVIDER_SECTIONS|AI_PROVIDERS" src/shared/constants/providers.ts | head
grep -nE "baseUrl|^\s*['\"][a-z0-9-]+['\"]\s*:" open-sse/config/providerRegistry.ts | head
grep -nE "case ['\"]|=== ['\"]|SPECIALTY_VALIDATORS" src/lib/providers/validation.ts | head
```
Anote: como enumerar os ids canônicos em runtime, a forma das chaves do `providerRegistry.ts`, e onde `validation.ts` lista os providers cobertos.
- [ ] **Step 1: Escrever o teste** com fixtures sintéticas (3 conjuntos de ids), afirmando que o diff detecta um id presente em um e ausente em outro. (Implemente a lógica como função pura `diffProviderSets(canonical, registry, validators)` que retorna `{missingInRegistry, missingInValidators, orphanInRegistry}`.)
- [ ] **Step 2: Rodar e ver falhar.**
- [ ] **Step 3: Implementar** importando os ids reais (descobertos no Step 0) e cruzando-os. Reusar o padrão de `scripts/check/check-docs-counts-sync.mjs` (que **já conta** executors/oauth/strategies vs docs — é o template direto). Para ids que legitimamente vivem só num lugar, manter um `KNOWN_EXCEPTIONS` allowlist com comentário por entrada.
- [ ] **Step 4: Rodar no repo real**, triar as divergências reais (as contagens já divergem — algumas serão bugs de registro pela metade, outras exceções legítimas).
- [ ] **Step 5: Commit** (`feat(check): provider-id consistency gate across providers.ts/registry/validation`).
**Critério de aceitação:** o gate falha quando um id existe em `providers.ts` mas não no `providerRegistry.ts` (ou vice-versa), e quando um model no registry referencia um provider id desconhecido.
### Task 2.3: `check-openapi-routes.mjs` — spec ↔ rotas (bidirecional)
**Ataca:** docs-hallucination (endpoint inventado no `openapi.yaml`).
**Files:** Create: `scripts/check/check-openapi-routes.mjs`; Test correspondente.
- [ ] **Step 0 (VERIFICAÇÃO):** confirmar o caminho e a forma do spec:
```bash
ls docs/reference/openapi.yaml && grep -nE "^\s{2,4}/[a-z]" docs/reference/openapi.yaml | head
```
Confirmar como `check-openapi-coverage.mjs` já parseia o YAML (reusar o parser dele).
- [ ] **Step 15 (TDD):** teste com spec sintética → implementar: toda `path` do spec resolve para um `route.ts` (reusando `resolveApiPathToRoute` da Task 2.1) e toda rota não-interna aparece no spec (com allowlist para rotas LOCAL_ONLY/internas). Commit.
### Task 2.4: `check-deps.mjs` — anti-slopsquatting
**Ataca:** pacotes alucinados (CSA: 19,7% das amostras IA).
**Files:** Create: `scripts/check/check-deps.mjs`; Test.
- [ ] **Step 0 (VERIFICAÇÃO):** decidir política — allowlist = todas as deps atuais do `package.json`/lockfile como baseline; novas deps exigem (a) existir no registry, (b) idade ≥ 72h, (c) entrada explícita.
- [ ] **Step 15 (TDD):** teste com `package.json` sintético adicionando uma dep nova → gate falha se a dep não está no baseline E (não existe no registry OU foi publicada há <72h). Usar `npm view <pkg> time.created` para idade. Reusar o padrão diff-base...HEAD de `check-pr-test-policy.mjs`. Commit.
### Task 2.5 (opcional): lints Rule #11/#12 via `no-restricted-syntax`
- [ ] Estender o `eslint.config.mjs` (bloco `no-restricted-syntax` já usado p/ a regra de busca turca) para sinalizar `NextResponse.json({ error: "<string>" })` cru (exigir `buildErrorBody()`) e literais de `clientIdDefault`/`clientSecretDefault` no `providerRegistry.ts` (exigir `resolvePublicCred()`). Ratchet via contagem (adoção 11% → sobe). TDD com fixtures de ESLint.
**Plugar tudo da Fase 2 no CI:** adicionar `check:fetch-targets`, `check:provider-consistency`, `check:openapi-routes`, `check:deps` ao job `lint` (ou `docs-sync-strict`) do `ci.yml`, e ao pre-commit barato quando rápidos o suficiente.
---
# FASE 3 — Catraca de Duplicação + Tamanho (mata-slop) — *expandir em sub-plano*
**Justificativa:** GitClear mostra duplicação 48× na era IA; é a assinatura nº1 de slop. Nenhum gate hoje (Sonar CPD excluído).
**Specs (criar sub-plano `writing-plans` a partir daqui):**
1. **Adicionar `jscpd` v5** como devDependency. Rodar `jscpd --reporters json src open-sse``quality-metrics.json` ganha `duplication.pct`. **[verificar no install]** o schema JSON do v5 antes de parsear (ver ressalva no relatório §4.2).
2. **Estender `collect-metrics.mjs`** com um coletor de duplicação (lê o JSON do jscpd) e um coletor de **tamanho de arquivo** (conta LOC de cada arquivo em `src`+`open-sse`, emite `fileSize.<path>` para os arquivos já acima de um teto, ex. >700 LOC — congelando os 64 atuais).
3. **Adicionar regras ESLint** em `eslint.config.mjs` como `warn`: `max-lines` (700), `max-lines-per-function` (80), `complexity` (15), `sonarjs/cognitive-complexity` (15). Coletor adiciona `eslintWarnings` por categoria (a contagem já está na catraca da Fase 1; opcionalmente segregar `complexityWarnings`).
4. **Estender `quality-baseline.json`** com `duplication.pct` (`down`) e os `fileSize.*` dos arquivos grandes (`down` — só podem encolher; arquivos novos têm teto absoluto).
5. **Teto absoluto para arquivos novos:** o gate falha se um arquivo **não** presente no baseline nasce acima do teto (ex. 700 LOC) — impede o próximo god-component.
**Critérios de aceitação:** PR que aumenta a duplicação % falha; PR que cresce qualquer um dos 64 arquivos grandes falha; PR que cria arquivo novo >700 LOC falha; refator que encolhe um arquivo grande ratcheta o baseline para baixo (via `--update`).
---
# FASE 4 — Catraca de Cobertura + Anti Test-Masking — *expandir em sub-plano*
**Specs:**
1. **`check-coverage-ratchet`** já é coberto pelo motor da Fase 1 (as 4 métricas `coverage.*` com `direction: "up"`). Confirmar que o job `quality-gate` roda **após** o merge dos 8 shards de cobertura no CI (não localmente, onde a cobertura é parcial). Adicionar **epsilon** maior (ex. 0.5) para `coverage.branches` por causa do não-determinismo do v8.
2. **Pisos por módulo crítico:** estender `collect-metrics.mjs` para emitir `coverage.<modulo>.lines` para uma lista curta de módulos de alto risco (lendo o `coverage-summary.json` por arquivo): `open-sse/handlers/chatCore.ts`, `open-sse/services/combo.ts`, `open-sse/services/accountFallback.ts`, `src/sse/services/auth.ts`, `src/server/authz/routeGuard.ts`, `open-sse/utils/error.ts`, `open-sse/utils/publicCreds.ts`, `src/shared/utils/circuitBreaker.ts`. Cada um vira métrica `up` no baseline (implementa o "risco, não % bruto" do vídeo).
3. **`check-test-masking.mjs`** (anti enfraquecimento de asserts): clonar `check-pr-test-policy.mjs` (diff `base...HEAD`); para cada `*.test.ts`/`*.spec.ts` alterado, contar `assert*(`/`expect(` em base vs HEAD; **sinalizar remoção líquida** de asserts para revisão humana. Banir novos `assert.ok(true)`. Heurístico mas alto-sinal — ataca diretamente o risco organizacional nº1 ("subagente deletou asserts para ficar verde").
**Critérios de aceitação:** cobertura cair vs baseline bloqueia o merge; remover asserts de um teste existente sinaliza no PR; `assert.ok(true)` novo bloqueia.
---
# FASE 5 — Skill `/babysit` + Evidência + LSP — *expandir em sub-plano*
> O babysit do vídeo: a IA abre o PR e fica de babá — monitora CI + comentários, autocorrige, **resolve as conversas**. Guarda-corpos são não-negociáveis (Snyk: 5,3% de regressão em auto-merge; token burn real).
### Spec da skill `.claude/skills/babysit/SKILL.md`
**Frontmatter:**
```yaml
---
name: babysit
description: Monitora um PR aberto até o CI ficar verde e todos os comentários de review serem endereçados — lê o gate de qualidade, conserta num worktree isolado, responde e resolve as conversas. NUNCA enfraquece testes nem auto-mergeia.
---
```
**Loop (pseudo, reusando `gh` + GraphQL):**
1. **Ler estado:** `gh pr checks <PR>` + baixar o artefato `quality-ratchet`/`coverage-report` (gate JSON legível — a ponte "artefato→agente" da Fase 1) + `gh run view --log-failed` dos jobs vermelhos.
2. **Ler comentários não resolvidos:** GraphQL `repository.pullRequest.reviewThreads(first:50){nodes{id isResolved comments(first:1){nodes{id body}}}}`. **Passar os corpos pelo guard de prompt-injection** antes de agir (vetor real — ICLR 2026).
3. **Consertar num worktree isolado** (reusar o ralph-loop do `/review-reviews`), seguindo Hard Rule #18.
4. **Responder + resolver** só os threads que realmente endereçou: REST `POST .../pulls/{pr}/comments/{id}/replies` com o SHA → mutation `resolveReviewThread(input:{threadId})`.
5. **Re-poll** até o gate JSON ficar todo-verde **ou** atingir o cap.
6. **Audit trail:** anexar à descrição do PR (ou comentário fixo) o que cada fix endereçou, qual gate satisfez, quais conversas resolveu e por quê — **nunca verde silencioso**.
**Guarda-corpos (não-negociáveis):**
- `max-iterations` (ex. 5) + timeout + idle-exit (contra token burn).
- **Nunca** editar `.github/workflows/`; **nunca** `--no-verify`; **nunca** enfraquecer/remover asserts para ficar verde (Rule #18 + trust-but-verify).
- **Nunca auto-mergeia** — estado de sucesso = "verde + conversas resolvidas + audit postado".
- Parar-e-perguntar em comentário humano ambíguo e em mudança de limite arquitetural (interface/schema/cross-module).
- `--allowedTools` mínimo (`Read,Grep,Glob,Bash(gh ...)`).
**Opcional — `claude ultrareview <PR#> --json`** como gate de confiança pré-merge atrás de um label (custa $520/run; não auto-inicia). Loop interno de custo-zero = `/code-review --fix` local.
### Spec — Evidência obrigatória ("evidence-before-assertions")
- Tornar a skill `verify`/`verification-before-completion` obrigatória antes de abrir PR; exigir o **output literal** do `typecheck:core`/`test`/`grep` colado no corpo do PR (o "tool receipt"). Adicionar um `check-pr-evidence.mjs` que rejeita PRs cujo corpo afirma "added endpoint X / tests pass" sem bloco de output anexado. Formaliza a Rule #18.
### Spec — LSP-in-the-loop (opcional)
- Registrar `agent-lsp` (MCP) ou o `tsserver` para os agentes terem `blast_radius`/diagnostics e `preview_edit` antes de escrever — vira "símbolo inventado" de catch-de-review para impossibilidade-no-edit. Pareia com `typecheck:core` como gate pré-PR (compile-before-claim).
**Critérios de aceitação:** a skill `/babysit <PR#>` leva um PR de vermelho a verde sem auto-merge, resolve só as conversas que endereçou, respeita o cap de iterações, e deixa rastro auditável; nenhum assert é enfraquecido.
---
## Self-Review (checklist do autor)
- **Cobertura do spec:** Fases 02 mapeiam 1:1 com as recomendações do relatório §5; Fases 35 cobrem duplicação/tamanho, cobertura/masking e babysit/evidência. ✓
- **Sem placeholders nos passos bite-sized:** Fases 0/1/2.1 têm comandos e código reais. As Fases 2.22.4 usam um Step-0 de verificação **deliberado** (não placeholder) para não fabricar nomes de export — coerente com o objetivo anti-alucinação. ✓
- **Consistência de tipos:** `resolveApiPathToRoute` (Task 2.1) é reusada na Task 2.3; o comparador usa o mesmo formato `{value, direction}` em todas as fases; `quality-metrics.json` é a interface única coletor↔comparador. ✓
- **Ordem de dependência:** Fase 1 (motor) precede 3/4 (que só adicionam métricas ao baseline); Fase 0 destrava o resto. ✓
## Handoff de Execução
**Plano salvo em `PLANO-QUALITY-GATES.md`.** Duas opções:
1. **Subagent-Driven (recomendado)** — um subagente fresco por task, review entre tasks. SUB-SKILL: `superpowers:subagent-driven-development`.
2. **Inline** — executar nesta sessão com checkpoints. SUB-SKILL: `superpowers:executing-plans`.
**Recomendação:** começar pela **Fase 0** (quick wins, baixo risco) e **Fase 1** (motor de catraca) numa branch `feat/quality-ratchet`, validar num PR de teste, e só então abrir as fases anti-alucinação. Fases 35 viram sub-planos próprios.

2103
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,219 +0,0 @@
# Relatório — Quality Gates, Catraca & Anti-Alucinação no OmniRoute
> **Data:** 2026-06-09
> **Origem:** Auditoria do projeto (5 subagentes Opus em paralelo mapeando todas as pastas exceto `node_modules`/`_references`/`dist`) + análise da transcrição do vídeo *"Qualidade de código"* (Stupid Button Club, 2026-05-04) + pesquisa web 2026 (4 frentes, últimos ~3 meses).
> **Companheiro:** Veja [`PLANO-QUALITY-GATES.md`](./PLANO-QUALITY-GATES.md) para o plano de implementação bite-sized (TDD).
---
## 0. TL;DR
1. **O OmniRoute já é muito mais maduro** que o projeto "Strawberry" do vídeo: tem CI com 20 jobs, gate de cobertura, ESLint 9 flat, SonarQube, 14 scripts `check-*.mjs` e **uma catraca real já funcionando** (`check-t11-any-budget.mjs` — orçamento de `any` por arquivo que só pode encolher). O vídeo descreve onde queremos chegar; nós já estamos a meio caminho.
2. **Mas faltam exatamente as catracas que o vídeo prega.** Não há baseline congelado de métricas, nem gate de **duplicação**, nem de **tamanho de arquivo**, e o gate de cobertura é um **piso fixo** — não uma catraca "nunca piorar".
3. **Há derivas (drifts) reais que pegamos na auditoria:** o gate de cobertura **no CI é `40/40/40/40`** (ci.yml:377), não os `60/60/60/60` que o CLAUDE.md anuncia (esse é só o script local). O Husky está **100% comentado** (zero gate local). O SonarQube tem `coverage` e `cpd` **excluídos** (`sonar-project.properties:9-10`) — as duas métricas mais úteis contra "slop" estão desligadas. E 3 scripts `check-*` existem mas **não rodam em lugar nenhum**.
4. **Os maiores ímãs de alucinação são estruturais:** o split de provider em 3 arquivos gigantes em 2 workspaces (`providers.ts``providerRegistry.ts``validation.ts`, com contagens que já divergem: 229 ids vs 155 blocos vs N validadores), os 300 paths `fetch("/api/...")` hardcoded sem ligação de compilação com as rotas, e o arquivo de **12.760 linhas** (`providers/[id]/page.tsx`) que nenhum agente consegue segurar em contexto.
5. **2026 confirma a tese do vídeo com dados:** GitClear (211M linhas) mostra duplicação crescendo 48× na era da IA; o paper SlopCodeBench prova que *instrução de prompt sozinha não impede a degradação* — só gates determinísticos seguram. O ecossistema 2026 tem ferramentas maduras para cada métrica (jscpd v5, knip v6, eslint-plugin-sonarjs v4, osv-scanner, Qlty, Sonar "Clean as You Code"/"AI Code Assurance").
6. **A jogada é em camadas:** (a) reativar/reconciliar o que já existe; (b) construir o **motor de catraca** (baseline.json + coletor + comparador, clonando o `any-budget`); (c) adicionar **gates determinísticos anti-alucinação** (provider-consistency, fetch-target, openapi-routes); (d) catraca de **duplicação + tamanho**; (e) catraca de **cobertura** + detecção de test-masking; (f) skill **`/babysit`** com guarda-corpos. Detalhe no plano.
---
## 1. O que o vídeo ensina (insights destilados)
O vídeo é uma fala sem roteiro sobre *qualidade de código no mundo em que a IA escreve ~100% do código*. Pontos centrais:
| # | Insight | Citação/essência |
|---|---------|------------------|
| 1 | **O humano virou o gargalo.** | "Eu acabei virando o gargalo da IA. Fazer o babysit das coisas do request básicas é o gargalo. Não consigo entregar 4 tarefas ao mesmo tempo se preciso ler 10.000 linhas/dia." |
| 2 | **Quality Gate = portão que a IA tem que passar.** | Todo PR passa por um portão; a IA fica em loop se autocorrigindo até ficar verde, em vez de o humano revisar e pedir refação. |
| 3 | **Baseline + Catraca (ratchet).** | "Tu congela o baseline e o repositório só pode melhorar a partir dali ou empatar." A catraca anda num sentido só. |
| 4 | **Regra de ouro.** | "Cada PR pode adicionar código, mas não pode aumentar nenhuma das métricas — nem por uma violação, nem por uma linha, nem por 0,1 ponto percentual." |
| 5 | **Métricas do baseline.** | Violações de ESLint (483 em 120 arquivos), duplicação de código (2,2% via JSCPD), cobertura (%), arquivos acima do limite de tamanho (19 arquivos; o maior com 4.600 linhas). |
| 6 | **Pipeline de CI.** | `npm ci``npm audit` (critical=bloqueia / high=avisa) → `npm run lint``test:coverage`**script node de quality gate** que compara métricas atuais vs `baseline.json` e falha em qualquer regressão → comentário no PR + **upload de artefatos** que o agente lê para se autocorrigir. |
| 7 | **Artefatos legíveis pelo agente.** | "Não adianta cuspir isso no PR. O agente precisa ter acesso ao que está dando errado." |
| 8 | **Babysit skill.** | "Recomendo criar uma skill de babysit": a IA monitora o CI + comentários dos revisores, endereça os comentários e **resolve as conversas** para dar rastreabilidade no GitHub. |
| 9 | **Comentários perto do código (legibilidade p/ agente).** | Mudou de ideia: antes era contra comentários ("o código é a documentação"); agora, no mundo de agentes, comentário explicando *o quê* e *por quê* perto do código vale mais que um MD gigante, porque o harness faz `grep` no arquivo e lê o comentário junto. |
| 10 | **É "só" colar ferramentas.** | "Não é nada excepcional. Eu só estou colando um monte de ferramentas e chamando de quality gate." Pode-se usar SonarQube ou GitHub Code Quality no lugar do script caseiro. |
| 11 | **Por que a IA não faz certo de primeira.** | Modelos top já sabem (foram treinados com os livros), mas são "preguiçosos" porque output imperfeito = mais tokens vendidos. A catraca força o nível. |
**Tradução para o nosso contexto:** o vídeo descreve um sistema que **já temos em embrião** (o `any-budget` é exatamente a catraca da regra de ouro, só que para uma métrica). O salto é (1) generalizar a catraca para todas as métricas, (2) reconciliar os drifts, e (3) fechar os buracos anti-alucinação que são específicos do nosso tamanho.
---
## 2. Onde o OmniRoute está hoje (panorama auditado)
### 2.1 O que já temos (e o vídeo nem sonha)
- **CI robusto** (`.github/workflows/ci.yml`, ~25 KB, 20 jobs): lint + audit + cycles + route-validation + any-budget + docs-sync + typecheck (core e noimplicit) + build + package-artifact + electron-smoke + unit (8 shards) + Node 24/26 compat + coverage (8 shards + merge) + SonarQube + e2e (9 shards) + integration + security.
- **Catraca real já existente:** `scripts/check/check-t11-any-budget.mjs` — array `{file, maxAny}` (a maioria `0`), strip de comentários, anotações de falso-positivo, `exit 1` em regressão. **É o template exato da catraca do vídeo.**
- **14 scripts `check-*.mjs`** (cycles, route-validation, any-budget, docs-sync, docs-counts, env-doc-sync, deprecated-versions, doc-links, cli-i18n, openapi-coverage, openapi-security-tiers, pr-test-policy, node-runtime, test-report-summary) — vários já são *gates de consistência fonte-vs-derivado*, o mesmo padrão que precisamos para anti-alucinação.
- **PR test policy:** `check-pr-test-policy.mjs` já força "mudou código de produção ⇒ mudou teste" (diff base...HEAD).
- **Cobertura sumarizada + comentada no PR:** `test-report-summary.mjs` + `coverage/coverage-summary.json` + job `coverage-pr-comment` (comentário com marcador `<!-- omniroute-coverage-report -->`). **Isto é exatamente o "artefato legível pelo agente" do vídeo** — já construído.
- **Disciplina TDD institucionalizada** (Hard Rule #18: todo fix precisa de teste falha→passa ou validação ao vivo no VPS).
- **SonarQube** configurado (job no CI + `sonar-project.properties`).
- **Skills agênticas de review** já existem: `/review-prs`, `/review-reviews` (bateria de 8 reviewers + ralph-loop), `/code-review`, `/generate-release` (a única com babysit real de CI, mas de workflows de *release*, não do `ci.yml` do PR).
### 2.2 O que está DESLIGADO ou inerte ⚠️ (achados da auditoria)
| Item | Estado | Evidência | Impacto |
|------|--------|-----------|---------|
| **Husky** | 100% comentado (pre-commit **e** pre-push) | `.husky/pre-commit`, `.husky/pre-push` (todas as linhas com `#`) | Zero enforcement local — lint-staged, docs-sync, any-budget, env-doc-sync, openapi checks e o `test:unit` de pre-push dependem 100% do CI. |
| **Gate de cobertura no CI** | **40/40/40/40** (não 60) | `ci.yml:377` `--statements 40 --lines 40 --functions 40 --branches 40` | O comentário do PR renderiza contra 60, o script local gata 60, o RELEASE_CHECKLIST diz 75/70, e o baseline real é ~7982%. **O único número que bloqueia merge é 40** → gate de cobertura quase banguela. |
| **Sonar coverage** | Excluído | `sonar-project.properties:9` `sonar.coverage.exclusions=**/*` | Sonar ignora cobertura de todo arquivo. |
| **Sonar CPD (duplicação)** | Excluído | `sonar-project.properties:10` `sonar.cpd.exclusions=**/*` | Sonar não detecta copy-paste — a assinatura nº1 de slop de IA. |
| **SonarQube job** | Inerte | `ci.yml` roda scan só se `PR && SONAR_TOKEN != '' && SONAR_HOST_URL != ''`; sem `qualitygate.wait` | Em runs sem secret, escreve "skipped"; mesmo quando roda, nunca falha o build. |
| **`npm audit`** | Plano (`moderate`), não escalonado | `package.json:112` `--audit-level=moderate` | O vídeo prega critical=bloqueia / high=avisa. O nosso é um nível único. |
| **3 scripts órfãos** | Sem CI nem husky | `check:cli-i18n`, `check:openapi-coverage`, `check:openapi-security-tiers` | Existem, dão `exit 1`, mas não rodam em lugar nenhum (Hard Rules #15/#17 guardadas só por um deles). |
| **`typecheck:noimplicit:core`** | `continue-on-error: true` | `ci.yml:45-46` | Warn-only "forward-looking". |
### 2.3 Hotspots de tamanho (sem gate hoje)
`64 arquivos > 1000 LOC`, `194 > 500 LOC` (src + open-sse, sem testes). Top:
| LOC | Arquivo | Risco para edição por IA |
|-----|---------|--------------------------|
| **12.760** | `src/app/(dashboard)/dashboard/providers/[id]/page.tsx` | God-component: **192 `useState`**, 21 `useEffect`, 87 `fetch()` inline, 34 tipos inline. Nenhuma IA segura em contexto; qualquer edição arrisca apagar estado não relacionado. |
| 5.977 | `open-sse/handlers/chatCore.ts` | God-handler: 58 funções, invariantes demais, blast radius alto. |
| 4.590 | `open-sse/config/providerRegistry.ts` | Array gigante providers+models+OAuth; mistura `resolvePublicCred()` e literais crus. |
| 4.456 | `open-sse/services/combo.ts` | 14 estratégias num `if/else if` sem enum/exhaustiveness — estratégia desconhecida vira no-op silencioso. |
| 4.349 | `src/app/(dashboard)/dashboard/combos/page.tsx` | 51 `useState`, mesmo padrão god-component. |
| 4.205 | `src/lib/providers/validation.ts` | Mega-função com closures e `SPECIALTY_VALIDATORS` definidos *dentro* da função. |
| 3.776 | `open-sse/handlers/imageGeneration.ts` | Branching multi-provider num handler. |
| 3.076 | `src/shared/constants/providers.ts` | 229 ids em 27 consts agrupados via `Proxy` — sem lista plana. |
| 2.869 | `open-sse/executors/chatgpt-web.ts` | Sessão web reversa; classe só começa na linha 2443. |
| 2.278 | `src/app/api/providers/[id]/models/route.ts` | God-route: importa ~30 módulos provider-específicos e ramifica por provider num GET. |
### 2.4 Ímãs de alucinação (ranqueados, da auditoria)
1. **Split de provider em 3 arquivos / 2 workspaces (nº1).** Não há lista plana de providers — 229 ids escondidos atrás de 27 consts + merge via `Proxy` (`AI_PROVIDERS`). Uma IA não consegue enumerar "quais providers existem" barato → **inventa ids plausíveis** (variantes `*-web`/`*-cli` inexistentes) ou registra no grupo errado. As três contagens (`providers.ts` 229 ids ↔ `providerRegistry.ts` 155 blocos ↔ `validation.ts` N validadores) **já divergem**, então não há cross-check autoritativo. *(Esse é o tema recorrente das nossas memórias de alucinação — ex.: ids inventados, modelos inexistentes.)*
2. **300 paths `fetch("/api/...")` hardcoded** no dashboard (659 call sites), sem client tipado. Refatore uma rota e os call sites apodrecem silenciosamente; uma IA editando a UI inventa rota (`/api/providers/[id]/refresh`) ou assume `res.error.message` numa rota que devolve `{error:"..."}`. Sem ligação de símbolo entre os 659 call sites e os 488 `route.ts`.
3. **Dual chat stack (armadilha documentada).** Seleção/fallback de conta vive em `src/sse/` (não `open-sse/`). Uma IA pedida para "consertar fallback de conta" edita `open-sse/handlers/chatCore.ts` (errado) em vez de `src/sse/services/auth.ts`. Nada no código cruza os dois stacks. *(Já erramos um diagnóstico público por isso.)*
4. **Estratégias de combo inventadas.** 14 nomes reais soterrados num `if/else` de strings (sem enum exportado) → IA inventa nomes plausíveis-mas-falsos (`"latency-optimized"`, `"failover"`) que passam no typecheck como string e viram no-op.
5. **Métodos de executor inventados.** O padrão real é "sobrescreve `execute()` inteiro" (48/50 executors), sem hooks documentados → IA inventa `buildRequest`/`parseChunk`/`mapError` que não existem em `BaseExecutor`.
6. **Helpers de erro inventados** + queda para `err.message` cru (viola Rule #12) quando o helper inventado "falha"; 5 web executors hoje **não importam helper nenhum**.
7. **AGENTS.md de DB defasado:** documenta 21 migrations / 22 módulos quando o real é **94 / 75** — uma IA lendo isso acredita em conjuntos de tabelas/módulos que não existem mais.
8. **Route-guard omitido:** rotas novas spawn-capazes (`/api/services/`, `/api/mcp/`) devem entrar em `LOCAL_ONLY_API_PREFIXES`; a convenção está só no CLAUDE.md, não num teste que a IA veja (parcialmente coberta por 1 script órfão).
---
## 3. Gap analysis — modelo do vídeo vs OmniRoute
| Métrica/peça do vídeo | OmniRoute hoje | Gap |
|-----------------------|----------------|-----|
| `npm ci` determinístico | ✅ em todos os 14 jobs | — |
| `npm audit` critical=bloqueia / high=avisa | ⚠️ `--audit-level=moderate` (nível único) | **Escalonar** em dois invokes |
| `lint` | ✅ bloqueante | — |
| `test` + cobertura | ✅ mas piso **40** no CI (drift) | **Reconciliar** p/ baseline real + catraca |
| **Contagem de ESLint congelada** | ❌ (lint é 0-erros, mas warnings livres) | **Construir** (ratchet de violações) |
| **Duplicação % (JSCPD)** | ❌ (Sonar CPD excluído, sem jscpd) | **Construir** (jscpd + catraca) |
| **Limite de tamanho de arquivo** | ❌ (sem `max-lines`, sem script) | **Construir** (ESLint max-lines + catraca, freeze dos 64) |
| **`baseline.json` congelado** | ❌ (nenhum baseline de métricas no repo) | **Construir** (o coração da catraca) |
| **Script comparador (regra de ouro)** | 🟡 existe **para `any`** (`any-budget`) | **Generalizar** p/ todas as métricas |
| **Sumário markdown + artefatos p/ o agente** | ✅ (coverage summary + PR comment + artifact) | **Reusar** wholesale |
| **Babysit skill (monitora CI + resolve conversas)** | 🟡 `review-prs`/`review-reviews`/`generate-release` parciais; nenhuma resolve threads do PR nem loopa no `ci.yml` | **Construir** `/babysit` |
| **Comentários perto do código p/ legibilidade de agente** | 🟡 `routeGuard.ts` é exemplar; resto irregular | **Padrão cultural** (Karpathy/guidelines) |
---
## 4. O que o mundo faz em 2026 (pesquisa, últimos ~3 meses)
> Todas as fontes abaixo vêm com URL + data nas seções de origem (ver §7). Onde a pesquisa **não conseguiu confirmar** algo de fonte primária, está marcado **[não-verificado]** — honestidade de engenharia.
### 4.1 Catraca / baseline-freeze (a "catraca" do vídeo)
- **betterer** — o tool canônico de ratchet (snapshot de métrica → `.betterer.results`; CI falha se piora, auto-atualiza se melhora). **[caveat]** Baixa velocidade: último commit no `master` em **ago/2025**, releases vazias no GitHub. Viável, mas **não** apostar como peça load-bearing de longo prazo.
- **eslint-formatter-ratchet** — formatter que congela contagem de violações de ESLint. **Ativamente mantido** (commit 2026-03-17). Mais estreito (só ESLint) mas é a trajetória oposta ao betterer.
- **SonarQube "Clean as You Code" (new-code conditions)** — o padrão baseline-freeze mais maduro: o quality gate aplica condições **só ao código novo** (branch de referência), grandfathering do legado. Atual.
- **SonarQube "AI Code Assurance" (2026.1.0)** — gate específico para código gerado por IA (tag o projeto → workflow de assurance + "Sonar way for AI Code" mais estrito). **[não-verificado]** se *bloqueia* o PR (docs canônicas deram 404; descrito como "enforced quality gate" mas mecânica de bloqueio não confirmada em fonte única).
- **Qlty CLI (qlty.sh)** — o produto 2026 mais aderente: CLI Rust **OSS e grátis** (v0.630.0, 2026-05-08) que agrega 70+ analisadores; tem **Baseline analysis** (= a catraca), **Quality Gates** com veredito go/no-go e coverage gates. **[caveat]** `qlty metrics` (a tabela LOC/complexidade) **não tem flag JSON** — só `qlty check --sarif`/`qlty smells --sarif`; o ratchet de tamanho/complexidade por arquivo ainda precisa do JSON do ESLint.
- **Code Climate Quality → Qlty** — a marca clássica de ratchet virou empresa separada (Qlty, nov/2024). Write-ups antigos de "Code Climate" = Qlty hoje.
### 4.2 Ferramentas de métrica por tipo (todas com JSON p/ alimentar a catraca)
| Métrica | Tool 2026 | Comando JSON | Status |
|---------|-----------|--------------|--------|
| Duplicação | **jscpd v5** (reescrita Rust) | `jscpd --reporters json` (ou `sarif`) | Muito ativo (v5.0.4, 2026-06-08). **[caveat]** schema JSON v4→v5 não confirmado — verificar no install. |
| Tamanho/fn-length/ciclomática | **ESLint core** (`max-lines`, `max-lines-per-function`, `complexity`) | `eslint --format json` | Built-in ESLint 9 |
| Complexidade cognitiva | **eslint-plugin-sonarjs** (`sonarjs/cognitive-complexity`) | `eslint --format json` | Mantido (v4.0.3, 2026-04-16; agora no monorepo SonarJS — o repo standalone foi arquivado, mas o pacote está vivo). **[caveat]** README das rules deu 404; presença de S3776 em v4 é alta-confiança mas confirmar no install. |
| Dead code / unused exports / unused deps | **knip** (vence ts-prune **arquivado** + depcheck **arquivado**) | `knip --reporter json` | Muito ativo (v6.16.1, 2026-06-06) |
| Ciclos | **check-cycles.mjs** (já temos) + **dpdm** opcional | `dpdm --circular --output deps.json` | dpdm ativo (v4.2.0, 2026-05-09); madge estagnado |
| Vulnerabilidades | **osv-scanner** (Google/OSV) | `osv-scanner --format json` | Muito ativo (push 2026-06-08) |
| Política de lockfile (gate, não métrica) | **lockfile-lint** | `lockfile-lint --validate-https --validate-integrity` | Mantido (v5.0.0, 2026-01-25) |
> **Realidade do ratchet:** não existe tool único 2026 que emita *todas* as métricas como um JSON limpo. O padrão robusto é **N tools que emitem JSON + um reducer Node** que monta `metrics-summary.json` + o comparador que falha só em regressão (exatamente o que o `any-budget` já faz para uma métrica).
### 4.3 Anti-alucinação (2026)
- **LSP-in-the-loop / `agent-lsp` (MCP)** — servidor MCP que dá ao agente fatos verificáveis do language server (definições, referências, tipos, diagnostics, `blast_radius`) e `preview_edit` antes de escrever. Funciona com Claude Code. **Fit alto:** vira "símbolo inventado" de catch-de-review para *impossibilidade-no-edit*. (v0.13.0, 2026-06-04 — pequeno mas ativo.)
- **Slopsquatting / pacotes alucinados** — **CSA Research Note (2026-04-19):** **19,7%** de 2,23M amostras de código IA continham nomes de pacote alucinados; 205k nomes fabricados únicos; **43%** reaparecem em re-runs (registráveis por atacantes). Defesas: **allowlist** de deps para agentes, **registry existence check** antes de instalar, **age-cooldown** (2472h), lockfile-exact, scripts de install desabilitados. **Fit alto:** novo `check-deps.mjs`.
- **Semcheck** (v1.2.1, fev/2026) — CLI que usa LLM para verificar que a implementação bate com o spec/doc, via `semcheck.yaml` ligando doc↔código; roda em pre-commit e Actions com `fail-on-issues`. Feito para pegar **"docs que descrevem features não implementadas"**. **Fit muito alto** para nossos incidentes recorrentes de docs alucinadas — porém é fuzzy (LLM); pareie com checks determinísticos.
- **OpenAPI drift determinístico** — check que toda `path` do `openapi.yaml` resolve para um `route.ts` real (e vice-versa). Rápido, sem LLM, pega "endpoint inventado". **Fit alto** para `docs/reference/openapi.yaml`.
- **Skill `verify` / `verification-before-completion`** (já no nosso ambiente) — "evidence before assertions": o veredito PASS/FAIL repousa **só** no que o app rodando demonstrou; rejeita "rodei os testes" como prova. **Fit altíssimo:** é a formalização da nossa Hard Rule #18 — exigir o *output literal* do comando colado no PR ("tool receipt").
- **Adversarial review (críticos de sessão fresca)** — agentes Skeptic/Architect/Minimalist leem o diff *contra o spec* ("o autor está comprometido — vai racionalizar"); símbolos/APIs inventados viram violação de spec. Mapeia no nosso `/review-reviews`.
- **SlopCodeBench (arXiv 2603.24755, ~mai/2026)** — sem mitigação, erosão estrutural aumentou em **77%** das trajetórias; o código de agente acumula verbosidade ~7× e erosão ~5× mais rápido que repos humanos. **Mitigações só-de-prompt ("anti-slop", "plan-first") melhoram o início mas NÃO param a degradação iterativa.****justificativa empírica** de que precisamos de gates determinísticos, não instrução.
- **GPT-5.5 System Card (2026-04-23)** — figuras oficiais são modestas (23% mais provável de acerto factual; 3% menos erros num set propenso). O headline de **"queda de 60% em alucinação / 88,7% SWE-bench" é imprensa secundária [não-verificado]**, não a seção de factualidade do system card. Upgrade de modelo ajuda na margem, não substitui gate.
### 4.4 Babysit loops (2026)
- **Claude Code "auto-fix in the cloud"** (Anthropic, lançado 2026-03-27): "observa seus PRs na nuvem, resolvendo falhas de CI e comentários de review automaticamente; empurra fixes quando claro, pergunta quando ambíguo." Não auto-mergeia.
- **Devin Autofix** (2026-02-10): auto-conserta comentários de review + lint/CI; endereça comentários de bots, mas deixa julgamento humano nas conversas humanas.
- **CodeRabbit Autofix** (early access abr/2026): coleta o bloco **"Prompt for AI Agents"** de cada comentário, aplica fix, roda build-verification; **nada mergeia automaticamente**.
- **Greptile `greploop` + skill `check-pr`** (MIT) — "dispara review → conserta comentários → re-review até 5/5 de confiança e zero comentários". **Template quase-exato** para a nossa `/babysit`.
- **Claude `claude ultrareview <PR#> --json`** (subcomando não-interativo, research preview): bloqueia até terminar, `exit 0/1`, payload de bugs verificados parseável. **Não auto-inicia** e custa $520/run → usar atrás de label, não em todo push. (O `/code-review ultra` local com `--fix` é o loop interno de custo-zero.)
- **Resolver threads de review:** não há comando `gh` nativo (cli/cli#12419). Padrão de 2 passos GraphQL: `reviewThreads(first:50){nodes{id isResolved...}}``mutation { resolveReviewThread(input:{threadId}) }`, respondendo antes com o SHA do commit via REST.
- **Guarda-corpos (críticos):**
- **Snyk Agent Fix field test (2026): ~5,3% de regressão** ("1 em 19 fixes auto-mergeados introduz problema novo") → **forte argumento contra auto-merge**.
- **Token burn:** loops ingênuos realimentam a conversa crescente → prompt incha → alucina do próprio histórico. Uber capou gasto em **$1.500/mês/dev/tool** (abr/2026). Mitigação: **max-iterations**, time limit, idle-exit.
- **Test-masking:** o babysit **NÃO** pode enfraquecer/remover asserts para ficar verde (= nossa Rule #18 + memória "trust but verify"). Revisão humana fica nos limites arquiteturais (interface/schema/cross-service).
- **Audit trail:** deixar rastro humano-legível (qual fix endereçou o quê, qual gate satisfez, quais conversas resolveu) — nunca um verde silencioso. Reforça o guard de prompt-injection sobre os *corpos de comentário* que o agente ingere (21% dos reviews do ICLR 2026 eram IA; injeção embutida em código é vetor real).
---
## 5. Recomendações (ranqueadas) → ver o PLANO
> Build-vs-buy: **construir in-repo** os gates determinísticos (zero SaaS, dados não saem da box, reusa o harness `check-*.mjs`). **Avaliar Qlty CLI** depois, se quisermos consolidar N scripts num tool. **Não** depender de CodeRabbit/Greptile/Diamond como *o* gate de "não-piorar-métrica" — são opiniões de LLM, não contadores determinísticos.
**Fase 0 — Reativar & reconciliar (quick wins, sem tooling novo):**
1. Reativar pre-commit barato do Husky (lint-staged + docs-sync + any-budget).
2. Reconciliar o gate de cobertura: subir o CI de 40 → baseline real (com headroom) e alinhar os 4 lugares que divergem.
3. Escalonar `npm audit` (critical=bloqueia / high=avisa).
4. Plugar os 3 scripts órfãos (`cli-i18n`, `openapi-coverage`, `openapi-security-tiers`) no CI.
**Fase 1 — Motor de catraca (o coração):**
5. `quality-baseline.json` commitado + `collect-metrics.mjs` (coletor) + `check-quality-ratchet.mjs` (comparador, clone do `any-budget`) + job de CI + artefato + comentário no PR (clone do `coverage-pr-comment`).
**Fase 2 — Gates determinísticos anti-alucinação:**
6. `check-provider-consistency.mjs` (o ímã nº1), `check-fetch-targets.mjs`, `check-openapi-routes.mjs`, allow-list de estratégias/translators/executors, lint Rule #11/#12, `check-deps.mjs` (slopsquatting).
**Fase 3 — Catraca de duplicação + tamanho (mata-slop):**
7. jscpd + ESLint `max-lines`/`max-lines-per-function`/`complexity` + `sonarjs/cognitive-complexity`, congelando os 64 arquivos grandes (catraca só-pode-encolher).
**Fase 4 — Catraca de cobertura + anti test-masking:**
8. `check-coverage-ratchet.mjs` (cobertura não cai vs baseline) + pisos por módulo crítico + `check-test-masking.mjs` (delta de contagem de asserts em testes alterados).
**Fase 5 — Skill `/babysit` + evidência + LSP:**
9. Skill `/babysit` (gh pr checks + reviewThreads + worktree de fix + resolveReviewThread + loop-até-verde, com guarda-corpos), "evidence-before-assertions" obrigatório no corpo do PR, e (opcional) `agent-lsp` MCP.
---
## 6. Riscos & ressalvas (honestidade de engenharia)
- **Flag-day risk:** ligar qualquer gate num projeto que nunca o teve deixa tudo vermelho. **Toda** catraca aqui é *só-regressão* (baseline congelado), nunca um piso absoluto que exige limpeza imediata — exatamente o ponto do vídeo.
- **Custo de IA:** o babysit pode queimar tokens. Guarda-corpos (max-iterations, sem auto-merge, sem editar `.github/workflows/`) são não-negociáveis.
- **Ressalvas de pesquisa não-verificadas:** betterer baixa-velocidade; Sonar "AI Code Assurance" bloqueio-de-PR não confirmado; "GPT-5.5 60% alucinação" é imprensa, não system card; schema JSON do jscpd v5 e S3776 do sonarjs v4 a confirmar no install; `qlty metrics` sem JSON. Nenhuma decisão do plano depende criticamente de um item não-verificado.
- **Trust-but-verify:** estes números internos (CI=40, husky off, sonar exclusions, 12.760 LOC, any-budget como catraca) foram **conferidos manualmente** contra os arquivos, não só relatados pelos subagentes.
---
## 7. Fontes (consolidadas)
**Catraca / ratchet:** betterer `github.com/phenomnomnominal/betterer` (último master ago/2025); eslint-formatter-ratchet `github.com/Jmsa/eslint-formatter-ratchet` (commit 2026-03-17); SonarQube Clean as You Code `docs.sonarsource.com/.../clean-as-you-code/about-new-code/`; Sonar AI Code Assurance `sonarsource.com/solutions/ai/ai-code-assurance/` + community thread 2026.1.0; Qlty `github.com/qltysh/qlty` (v0.630.0, 2026-05-08), `docs.qlty.sh`; Code Climate→Qlty `codeclimate.com/legacy/...` (2024-11-11).
**Métricas:** jscpd `github.com/kucherenko/jscpd` (v5.0.4, 2026-06-08); ESLint v10 `eslint.org/blog/2026/02/eslint-v10.0.0-released/` (2026-02-06); eslint-plugin-sonarjs `npm` (v4.0.3, 2026-04-16) + `github.com/SonarSource/SonarJS`; knip `knip.dev` (v6.16.1, 2026-06-06); dpdm `github.com/acrazing/dpdm` (v4.2.0, 2026-05-09); osv-scanner `google.github.io/osv-scanner` (push 2026-06-08); lockfile-lint `github.com/lirantal/lockfile-lint` (v5.0.0, 2026-01-25); GitClear `gitclear.com/ai_assistant_code_quality_2025_research`.
**Anti-alucinação:** agent-lsp `github.com/blackwell-systems/agent-lsp` (v0.13.0, 2026-06-04); CSA Slopsquatting `labs.cloudsecurityalliance.org/research/...slopsquatting...20260419...` (2026-04-19); Nesbitt package defenses `nesbitt.io/2026/04/09/...` (2026-04-09); Semcheck `github.com/rejot-dev/semcheck` (v1.2.1, fev/2026); verify skill `github.com/Piebald-AI/claude-code-system-prompts/.../skill-verify-skill.md`; Claude best practices `code.claude.com/docs/en/best-practices`; SlopCodeBench `arxiv.org/pdf/2603.24755`; GPT-5.5 system card `deploymentsafety.openai.com/gpt-5-5` (2026-04-23); adversarial review `asdlc.io/patterns/adversarial-code-review/`; OpenAPI drift `speakeasy.com/blog/openapi-spec-drift-detection`.
**Babysit:** Claude auto-fix cloud `producthunt.com/products/claude-code-auto-fix-in-the-cloud` (2026-03-27); Devin Autofix `cognition.ai/blog/closing-the-agent-loop-...` (2026-02-10); CodeRabbit Autofix `coderabbit.ai/blog/fix-all-issues-with-ai-agents` (2026-02-19); Greptile skills `github.com/greptileai/skills`; Claude GitHub Actions/Code Review/ultrareview `code.claude.com/docs/en/{github-actions,code-review,ultrareview}`; Nx self-healing `nx.dev/blog/autonomous-ai-workflows-with-nx` (2026-02-03); Snyk Agent Fix 5,3% `safeguard.sh/resources/blog/snyk-agent-fix-autofix-field-test-2026`; resolveReviewThread `nakamasato.medium.com/...` + `github.com/cli/cli/issues/12419`; ICLR 2026 AI review `blog.pebblous.ai/report/iclr-2026-ai-peer-review-crisis`.
---
*Relatório gerado a partir de auditoria paralela do código + transcrição do vídeo + pesquisa web 2026. Próximo passo: aprovar o [`PLANO-QUALITY-GATES.md`](./PLANO-QUALITY-GATES.md) e escolher por onde começar (recomendação: Fase 0 → Fase 1).*

View File

@@ -38,17 +38,15 @@ Request → CORS → Authz pipeline (classify → policies → enforce)
### 🔐 Authentication & Authorization
| Feature | Implementation |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **Dashboard Login** | Password-based auth with JWT tokens (HttpOnly cookies) |
| **API Key Auth** | HMAC-signed keys with CRC validation |
| **OAuth 2.0 + PKCE** | 14 providers (Claude, Codex, GitHub, Cursor, Antigravity, Gemini, Kimi Coding, Kilo Code, Cline, Qwen, Kiro, Qoder, Windsurf, GitLab Duo) |
| **Token Refresh** | Automatic OAuth token refresh before expiry |
| **Secure Cookies** | `AUTH_COOKIE_SECURE=true` for HTTPS environments |
| **Authz Pipeline** | Route classification (PUBLIC / CLIENT_API / MANAGEMENT) — see `docs/architecture/AUTHZ_GUIDE.md` |
| **Route Guard Tiers** | 3-tier model for management routes (LOCAL_ONLY / ALWAYS_PROTECTED / MANAGEMENT) — see `docs/security/ROUTE_GUARD_TIERS.md` |
| **Manage-Scope MCP** | Remote `/api/mcp/*` access gated by API keys with `manage` scope; `/api/cli-tools/runtime/*` stays strict-loopback. See ROUTE_GUARD_TIERS |
| **MCP Scopes** | ~13 granular scopes (read:health, write:combos, execute:completions, etc.) — see `docs/frameworks/MCP-SERVER.md` |
| Feature | Implementation |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **Dashboard Login** | Password-based auth with JWT tokens (HttpOnly cookies) |
| **API Key Auth** | HMAC-signed keys with CRC validation |
| **OAuth 2.0 + PKCE** | 14 providers (Claude, Codex, GitHub, Cursor, Antigravity, Gemini, Kimi Coding, Kilo Code, Cline, Qwen, Kiro, Qoder, Windsurf, GitLab Duo) |
| **Token Refresh** | Automatic OAuth token refresh before expiry |
| **Secure Cookies** | `AUTH_COOKIE_SECURE=true` for HTTPS environments |
| **Authz Pipeline** | Route classification (PUBLIC / CLIENT_API / MANAGEMENT) — see `docs/architecture/AUTHZ_GUIDE.md` |
| **MCP Scopes** | ~13 granular scopes (read:health, write:combos, execute:completions, etc.) — see `docs/frameworks/MCP-SERVER.md` |
### 🛡️ Encryption at Rest
@@ -208,29 +206,6 @@ These rules are enforced by tooling and reviewers:
10. **`exec()` / `spawn()` runtime values via the `env` option** — never string-interpolate external paths or untrusted values into shell-passed scripts. Reference: `src/mitm/cert/install.ts::updateNssDatabases`.
11. **Prefer secure-by-default libraries** — see [tldrsec/awesome-secure-defaults](https://github.com/tldrsec/awesome-secure-defaults) (Helmet.js, DOMPurify, ssrf-req-filter, safe-regex, Google Tink). Reach for them before rolling your own.
## Supply-chain scanner findings (Socket.dev / Snyk / similar)
The published `omniroute` npm artifact bundles the Next.js `output: "standalone"`
build, which means every route handler — including documented privileged
features (MITM, Zed import, Cloud Sync, embedded service supervisor) — ends
up in `.next/server/*.js` minified chunks. Heuristic supply-chain scanners
frequently pattern-match those chunks against malware signatures.
For each finding category we maintain a per-finding maintainer attestation:
- **[`docs/security/SOCKET_DEV_FINDINGS.md`](docs/security/SOCKET_DEV_FINDINGS.md)** —
per-finding map: source file ↔ flagged chunk ↔ behaviour ↔ mitigation
applied in v3.8.6.
- In-source `SECURITY-AUDITOR-NOTE:` blocks at each flagged function point
back to the same document.
For users whose pipeline cannot relax the alert: build with
`OMNIROUTE_BUILD_PROFILE=minimal npm run build`. That replaces the four
sensitive modules with stubs that return HTTP 503 `feature-disabled` at
runtime, so the privileged code paths are physically absent from the bundle.
See [`docs/security/SOCKET_DEV_FINDINGS.md`](docs/security/SOCKET_DEV_FINDINGS.md)
for the publishing recipe.
## References
- [`docs/architecture/AUTHZ_GUIDE.md`](docs/architecture/AUTHZ_GUIDE.md) — authorization pipeline
@@ -238,7 +213,6 @@ for the publishing recipe.
- [`docs/security/COMPLIANCE.md`](docs/security/COMPLIANCE.md) — audit log and retention
- [`docs/security/PUBLIC_CREDS.md`](docs/security/PUBLIC_CREDS.md) — **mandatory** pattern for public upstream credentials
- [`docs/security/ERROR_SANITIZATION.md`](docs/security/ERROR_SANITIZATION.md) — **mandatory** pattern for error responses
- [`docs/security/SOCKET_DEV_FINDINGS.md`](docs/security/SOCKET_DEV_FINDINGS.md) — maintainer attestation for supply-chain scanner findings
- [`docs/architecture/RESILIENCE_GUIDE.md`](docs/architecture/RESILIENCE_GUIDE.md) — circuit breaker + cooldown + lockout
- [`docs/security/STEALTH_GUIDE.md`](docs/security/STEALTH_GUIDE.md) — TLS fingerprinting (legal/ethical notice)
- [`CLAUDE.md`](CLAUDE.md) — hard rules for AI agents

178
Tuto_Qdrant.md Normal file
View File

@@ -0,0 +1,178 @@
# Tutorial Qdrant no OmniRoute (Guia para vídeo)
> ⚠️ **Status (v3.8.0):** Integração Qdrant está **dormente** no pipeline. As funções de upsert/search/delete existem em `src/lib/memory/qdrant.ts` e a UI de configuração está pronta (`MemorySkillsTab.tsx` + endpoint `/api/settings/qdrant/embedding-models`), mas:
>
> - `upsertSemanticMemoryPoint`, `searchSemanticMemory` e `deleteSemanticMemoryPoint` **não são chamadas** pelo pipeline de chat — busca semântica corrente usa apenas o store local em SQLite (ver `docs/frameworks/MEMORY.md`).
> - As rotas `/api/settings/qdrant/health`, `/api/settings/qdrant/search` e `/api/settings/qdrant/cleanup` mencionadas neste tutorial **ainda não foram implementadas**.
> - Os botões "Testar conexão" e "Teste de busca" no painel exigem que essas rotas existam; até lá, são placeholders.
>
> Este documento descreve a UX/configuração planejada. Para o sistema de memória ativo hoje, consulte [`docs/frameworks/MEMORY.md`](docs/frameworks/MEMORY.md). Acompanhe o status da ativação em issues marcadas com `area:qdrant`.
## 1) O que é o Qdrant no OmniRoute
O Qdrant é o banco vetorial usado para memória semântica.
No OmniRoute, ele ajuda a:
- Encontrar contexto por significado (não só palavra exata).
- Reaproveitar memórias antigas com mais precisão.
- Melhorar respostas com base em histórico relevante.
- Escalar melhor quando a base de memória cresce.
---
## 2) Quando o OmniRoute envia dados para o Qdrant
Com Qdrant habilitado e modelo de embedding configurado, o sistema envia vetores quando:
- Memórias são salvas (upsert de memória).
- Fluxos de chat recuperam contexto semântico/híbrido.
- Testes de busca no painel geram embedding e consultam a coleção.
Resumo prático:
- Sem Qdrant: busca mais limitada (texto/chave).
- Com Qdrant: busca por similaridade semântica (mais inteligente).
---
## 3) Pré-requisitos
Você precisa de:
- Instância Qdrant acessível (porta 6333).
- Coleção criada (ex.: `omniroute_memory`).
- Modelo de embedding válido (ex.: OpenRouter).
- Credencial do provider do embedding configurada no OmniRoute.
Exemplo de modelo OpenRouter:
- `openrouter/nvidia/llama-nemotron-embed-v1-1b-v2:free`
Importante:
- O texto do modelo deve estar em formato `provider/model`.
- Se usar modelo com dimensão diferente da coleção, a busca falha.
---
## 4) Como configurar no painel do OmniRoute
No menu:
- `Admin > Settings > Qdrant (Memória vetorial)`
Preencha:
- `Ativar Qdrant`: ligado.
- `Host`: IP ou URL do servidor Qdrant (sem porta no campo Host).
- `Porta`: `6333`.
- `Collection`: `omniroute_memory` (ou nome que você criou).
- `Modelo de embedding`: selecione da lista ou digite manualmente.
- `API Key`: opcional (preencha se seu Qdrant exigir).
Depois:
1. Clique em `Salvar`.
2. Clique em `Testar conexão`.
3. No `Teste de busca`, digite um texto e clique em `Buscar`.
---
## 5) Como criar a coleção no Dashboard do Qdrant (sem comando)
No Qdrant Dashboard:
1. Clique em `Create collection`.
2. Escolha `Global search`.
3. Em tipo de busca, use `Custom`.
4. Configure vetor:
- Vector name: `omniao` (padrão esperado pelo OmniRoute atualmente).
- Size: dimensão do seu modelo de embedding (ex.: 2048 em alguns modelos NVIDIA).
- Distance: `Cosine`.
5. Salve a coleção com nome `omniroute_memory`.
Se já tinha coleção com dimensão errada:
- Recrie a coleção com dimensão correta.
---
## 6) Como validar se está funcionando
Checklist rápido:
1. `Testar conexão` no OmniRoute retorna OK.
2. Busca no painel retorna resultados (não “Sem resultados”).
3. No Qdrant Dashboard, aparecem pontos na coleção (payload + vector).
4. Resultados de chat passam a recuperar contexto mais relevante.
Sinal clássico de problema:
- Dados entram no Qdrant, mas busca do painel não retorna nada.
Causas comuns:
- Dimensão do vetor incompatível.
- Nome do vetor diferente do esperado (`omniao`).
- Modelo inválido/incompleto no campo de embedding.
- Provider sem credencial ativa.
---
## 7) O que melhorou com esta atualização
Nesta melhoria do OmniRoute:
- Suporte a embeddings de qualquer provider compatível (não só OpenAI fixo).
- Endpoint para carregar modelos de embedding na tela de configurações.
- Campo manual para modelo custom quando não aparecer na lista.
- Ajuda visual (`?`) com passo rápido de configuração Qdrant + OpenRouter.
---
## 8) Roteiro curto para seu vídeo
Sugestão de demo (3-5 minutos):
1. Mostrar problema sem Qdrant (busca simples).
2. Abrir Settings e habilitar Qdrant.
3. Configurar host/porta/collection/modelo.
4. Salvar + testar conexão.
5. Fazer `Teste de busca` no painel.
6. Abrir Qdrant Dashboard e mostrar ponto salvo + vetor.
7. Rodar um chat e mostrar melhoria de recuperação semântica.
Mensagem final para a galera:
- "Qdrant no OmniRoute transforma memória de palavra-chave em memória por significado."
---
## 9) Referências de código (para equipe técnica)
**Implementado:**
- UI de configuração Qdrant: `src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx`
- Endpoint de modelos de embedding: `src/app/api/settings/qdrant/embedding-models/route.ts`
- Funções backend (definidas mas dormentes): `src/lib/memory/qdrant.ts` exporta `upsertSemanticMemoryPoint`, `searchSemanticMemory`, `deleteSemanticMemoryPoint`
**Pendente para ativar a integração:**
- Rotas API: `/api/settings/qdrant/health`, `/api/settings/qdrant/search`, `/api/settings/qdrant/cleanup`
- Wire-up no fluxo de chat: `src/lib/memory/retrieval.ts` e `open-sse/handlers/chatCore.ts` precisam chamar `searchSemanticMemory` quando Qdrant estiver habilitado nas settings
- Wire-up no save de memória: `src/lib/memory/extraction.ts` (ou camada equivalente) precisa chamar `upsertSemanticMemoryPoint` após persistir cada memória
Para o sistema de memória ativo hoje (SQLite-only), ver [`docs/frameworks/MEMORY.md`](docs/frameworks/MEMORY.md).
---
## 10) Observação importante de segurança
Nunca exponha em vídeo:
- API key completa do OpenRouter.
- Tokens reais de produção.
- Endpoints internos sem proteção.
Use chaves mascaradas e ambiente de demonstração.

1
_ideia/_fetch/1584.json Normal file
View File

@@ -0,0 +1 @@
{"author":{"id":"MDQ6VXNlcjM3Nzc3MjYx","is_bot":false,"login":"uwuclxdy","name":"cloudy"},"body":"### Problem / Use Case\n\nI'm trying to connect [CoStrict](https://zgsm.sangfor.com) to omniroute but it uses oauth.\n\n### Proposed Solution\n\nAdd support for CoStrict provider\n\n### Alternatives Considered\n\n_No response_\n\n### Acceptance Criteria\n\n- being able to authenticate with costrict account\n- token refresh, multiple accounts (all features as the other oauth providers)\n\n### Area\n\nProvider Support\n\n### Related Provider(s)\n\n_No response_\n\n### Additional Context\n\nread https://claude.ai/share/5d01e7b2-5771-4a96-9067-9c7fe1b4ba7a\n\n### Expected Test Plan\n\n_No response_","comments":[{"id":"IC_kwDORPf6ys8AAAABAXyEqw","author":{"login":"diegosouzapw"},"authorAssociation":"OWNER","body":"Thank you for the suggestion, @uwuclxdy. CoStrict (Sangfor's coding assistant) is an interesting OAuth-based provider.\n\nWe reviewed the shared analysis and the provider appears to use a standard OAuth 2.0 flow. Adding CoStrict would follow our existing OAuth provider pattern:\n1. OAuth constants in `src/lib/oauth/constants/oauth.ts`\n2. Executor in `open-sse/executors/` (likely extending the Claude Code-compatible executor given the Anthropic-style API)\n3. Token refresh flow in `open-sse/services/tokenRefresh.ts`\n\nWe'll track this for a future provider onboarding wave. If you have access to the actual OAuth client endpoints and API documentation beyond the shared analysis, that would help accelerate the integration.\n\nKeeping open for tracking.","createdAt":"2026-04-25T15:03:30Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/diegosouzapw/OmniRoute/issues/1584#issuecomment-4319904939","viewerDidAuthor":true}],"createdAt":"2026-04-25T11:46:48Z","labels":[{"id":"LA_kwDORPf6ys8AAAACYAlrLw","name":"enhancement","description":"New feature or request","color":"a2eeef"}],"number":1584,"title":"[Feature] CoStrict provider","url":"https://github.com/diegosouzapw/OmniRoute/issues/1584"}

1
_ideia/_fetch/1594.json Normal file
View File

@@ -0,0 +1 @@
{"author":{"id":"MDQ6VXNlcjc2ODU0NjI=","is_bot":false,"login":"newbe36524","name":"Newbe36524"},"body":"### Problem / Use Case\n\nMy upstream provider is an account pool. It occasionally returns temporary error messages due to exceptions, such as 403, 401 and other similar status codes.I want to retain and return these original errors without automatically downgrading the provider.The provider is still fully valid, and the issue can be resolved simply by retrying at the downstream level.\n\n### Proposed Solution\n\nI want to exclude specific providers or their keys from the break mechanism, so that they remain permanently enabled and will never be downgraded under any circumstances.\nCurrently, I rely on an external standalone process to check every second whether these providers have been downgraded, and re-enable them automatically if so. This workaround is cumbersome and inconvenient.\n\n### Alternatives Considered\n\n_No response_\n\n### Acceptance Criteria\n\nProvider can be set as permanent alive\n\n### Area\n\nProxy / Routing\n\n### Related Provider(s)\n\nCustom provider\n\n### Additional Context\n\n_No response_\n\n### Expected Test Plan\n\n_No response_","comments":[{"id":"IC_kwDORPf6ys8AAAABAXyEwg","author":{"login":"diegosouzapw"},"authorAssociation":"OWNER","body":"Thank you for the feature request, @newbe36524. This is a valid use case — account pool providers where transient 403/401 errors are expected and should be retried by the downstream client rather than triggering the circuit breaker.\n\nOmniRoute v3.7.0 already made progress in this direction:\n- **Removed 429 from `PROVIDER_FAILURE_ERROR_CODES`** — rate limits no longer trigger the provider-wide circuit breaker\n- **Configurable failure thresholds** via `PROVIDER_PROFILES` — different provider types can have different tolerance levels\n\nWhat you're describing would be a natural extension: a per-provider or per-connection **`circuitBreakerEnabled: false`** flag that completely bypasses the circuit breaker for specific connections, letting all errors pass through as-is for the downstream client to handle.\n\nImplementation would touch:\n- `open-sse/services/accountFallback.ts` — skip `markProviderFailure()` when flag is set\n- Provider `providerSpecificData` schema — add `circuitBreakerBypass: boolean`\n- Dashboard UI — toggle in the provider connection settings\n\nKeeping open for tracking. This is a clean, scoped feature that could land in a near-term release.","createdAt":"2026-04-25T15:03:31Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[{"content":"THUMBS_UP","users":{"totalCount":1}}],"url":"https://github.com/diegosouzapw/OmniRoute/issues/1594#issuecomment-4319904962","viewerDidAuthor":true}],"createdAt":"2026-04-25T13:58:11Z","labels":[{"id":"LA_kwDORPf6ys8AAAACYAlrLw","name":"enhancement","description":"New feature or request","color":"a2eeef"}],"number":1594,"title":"[Feature] permanent provider or the internal key inside the provider","url":"https://github.com/diegosouzapw/OmniRoute/issues/1594"}

1
_ideia/_fetch/1716.json Normal file
View File

@@ -0,0 +1 @@
{"author":{"id":"MDQ6VXNlcjE0NDAzMDM=","is_bot":false,"login":"matteoantoci","name":"Matteo Antoci"},"body":"## Enhancement: Shorter timeouts for zombie stream detection\n\n### Current behavior\n\nAll timeout-sensitive phases share `FETCH_TIMEOUT_MS` / `STREAM_IDLE_TIMEOUT_MS` (default 10 minutes). Additionally, there is **no request-level deadline** — if a request gets stuck in a pre-fetch phase (rate limiter queue, account semaphore, translation, etc.), it hangs indefinitely with no timeout to recover.\n\nObserved failure modes:\n- Upstream never returns HTTP headers → 10 min hang\n- Upstream returns HTTP 200 but never sends SSE data → 10 min hang before `ensureStreamReadiness` catches it\n- Upstream sends initial content then stalls mid-stream → 10 min hang before idle timeout\n- Request stuck in rate limiter queue or account fallback → **indefinite hang** (no timeout covers this phase)\n\n### Proposed behavior\n\nSplit timeouts into distinct phases, plus add a request-level hard deadline for pre-streaming phases:\n\n| Phase | Proposed default | Current | Env override |\n|-------|-----------------|---------|-------------|\n| **Request deadline** (pre-streaming phases) | 600s | none | `FETCH_TIMEOUT_MS` |\n| Fetch headers (time to HTTP response) | 60s | 600s | `FETCH_HEADERS_TIMEOUT_MS` |\n| First content (time to first useful SSE event) | 60s | 600s | `STREAM_FIRST_CONTENT_TIMEOUT_MS` |\n| Stream idle (mid-stream pauses) | 120s | 600s | `STREAM_IDLE_TIMEOUT_MS` |\n\n**Request deadline** covers the entire pre-streaming lifecycle (rate limiter wait, account fallback, translation, fetch setup, `ensureStreamReadiness`). Once the stream is confirmed active (useful content received), the deadline is cancelled — active streams are governed only by the idle timeout, which resets on every chunk. This means reasoning models that think for 10+ minutes are NOT cut, as long as they send tokens.\n\n### Implementation\n\n1. Add `STREAM_FIRST_CONTENT_TIMEOUT_MS` (default 60s) to `runtimeTimeouts.ts`\n2. Change `FETCH_HEADERS_TIMEOUT_MS` default from `fetchTimeoutMs` to 60s\n3. Lower `DEFAULT_STREAM_IDLE_TIMEOUT_MS` from 600s to 120s\n4. Use `STREAM_FIRST_CONTENT_TIMEOUT_MS` in `ensureStreamReadiness()` call (currently uses `STREAM_IDLE_TIMEOUT_MS`)\n5. Use `FETCH_HEADERS_TIMEOUT_MS` in `BaseExecutor.execute()` fetch-start timeout (currently uses `getTimeoutMs()`)\n6. Add `requestTimeoutMs` option to `createStreamController()` — starts a hard deadline timer when the request begins\n7. Cancel the deadline timer once `ensureStreamReadiness` passes (stream confirmed active)\n8. The deadline uses `FETCH_TIMEOUT_MS` as the default, configurable via env var\n\n### Impact\n\n- Zombie streams detected in ~60s instead of ~10min\n- Mid-stream stalls detected in ~2min instead of ~10min\n- **Pre-fetch hangs (rate limiter, account fallback) now covered** — 600s hard deadline prevents indefinite hangs\n- Combo fallback triggers much faster\n- No change to actively streaming behavior — deadline is cancelled once stream starts, and idle timer resets on every chunk\n- All timeouts are env-overridable for operators who need different values\n\n### Context\n\nObserved with mimo-v2.5-pro via opencode-go in three separate incidents:\n1. Provider returned HTTP 200 but never sent SSE data → 15+ min hang\n2. Provider sent initial SSE content then stalled completely → 13+ min hang\n3. Request stuck after account fallback (429 on first account, second account's request never reached fetch) → 24+ min hang with zero log output after fallback","comments":[],"createdAt":"2026-04-28T09:51:04Z","labels":[],"number":1716,"title":"[Feature] Separate fetch-start and first-content timeouts from stream idle timeout","url":"https://github.com/diegosouzapw/OmniRoute/issues/1716"}

1
_ideia/_fetch/1718.json Normal file
View File

@@ -0,0 +1 @@
{"author":{"id":"MDQ6VXNlcjE0NDAzMDM=","is_bot":false,"login":"matteoantoci","name":"Matteo Antoci"},"body":"## Enhancement: Propagate upstream error details to the client\n\n### Problem\n\nWhen an upstream provider returns an error, the client receives a generic message like:\n\n```\n[400]: Error from provider: Provider returned error\n```\n\nThe actual upstream error body is captured internally but never included in the response. This makes it difficult to debug issues — the real error (e.g., `context_length_exceeded`, `invalid_tool_call`, etc.) is hidden behind the generic wrapper.\n\n### Proposed behavior\n\nAdd an optional `upstream_details` field to error response bodies alongside the existing `error.message`/`type`/`code` structure. The existing error structure stays unchanged (OpenAI-compatible).\n\nExample response:\n\n```json\n{\n \"error\": {\n \"message\": \"[400]: Error from provider: Provider returned error\",\n \"type\": \"invalid_request_error\",\n \"code\": \"bad_request\"\n },\n \"upstream_details\": {\n \"error\": { \"message\": \"context_length_exceeded\", \"type\": \"invalid_request_error\" }\n }\n}\n```\n\n### Where this helps\n\n- Clients using providers that wrap errors in generic messages (e.g., opencode-go)\n- Debugging 400s from upstream providers where the real error is in the response body\n- Understanding why a specific request failed without checking server-side logs\n\n### Implementation notes\n\nThe upstream error body is already parsed and available in the error handling path. The change involves:\n1. Adding an optional parameter to error builder functions (`buildErrorBody`, `errorResponse`, `writeStreamError`, `createErrorResult`)\n2. Passing the parsed upstream body through at the relevant error sites","comments":[],"createdAt":"2026-04-28T10:04:16Z","labels":[],"number":1718,"title":"[Feature] expose upstream error details in client-facing error responses","url":"https://github.com/diegosouzapw/OmniRoute/issues/1718"}

1
_ideia/_fetch/1731.json Normal file
View File

@@ -0,0 +1 @@
{"author":{"id":"MDQ6VXNlcjE0NDAzMDM=","is_bot":false,"login":"matteoantoci","name":"Matteo Antoci"},"body":"## Problem\n\nWhen a combo uses the `auto` strategy, targets are reordered by score. If the top-scored targets all share the same provider (e.g., 4 opencode-go models), a single provider quota exhaustion forces the combo to burn through every same-provider target one by one — each cycling through all accounts getting 429 — before reaching a cross-provider fallback (e.g., glm).\n\nObserved behavior: opencode-go quota exhausted → combo spent ~5 minutes cycling through mimo → kimi → qwen → deepseek (all opencode-go), each spending 30-60s on account fallback loops returning 429, before finally trying glm.\n\nThe auto-selection algorithm has no awareness that all top-scored targets share the same provider, so cross-provider diversity is zero in the fallback chain.\n\n## Expected Behavior\n\nBefore: opencode-go/mimo (429) → opencode-go/kimi (429) → opencode-go/qwen (429) → opencode-go/deepseek (429) → glm/glm-5.1 (success) — **~5 minutes**\n\nAfter: opencode-go/mimo (429) → skip kimi/qwen/deepseek → glm/glm-5.1 (success) — **~10 seconds**\n\n## Root Cause\n\nTwo issues compound:\n\n1. **`isModelAvailable` pre-check doesn't catch quota exhaustion** — when all accounts return 429 \"Subscription quota exceeded\", the account-level cooldown is only 3-5s (base cooldown for OAuth/API key profiles). By the time the next combo target is evaluated, the cooldown has expired and the account appears available again.\n\n2. **429 is excluded from the provider circuit breaker** — `PROVIDER_FAILURE_ERROR_CODES = {408, 500, 502, 503, 504}` at `accountFallback.ts:59`. Even hundreds of consecutive 429s won't open the provider breaker, so there's no durable cross-request backoff for rate-limited providers.\n\n<details><summary>Log evidence (14:00-14:02 UTC, 2026-04-28)</summary>\n\n```\n14:00:30 Model opencode-go/mimo-v2.5-pro failed, trying next (429)\n14:00:31 Trying model 2/5: opencode-go/kimi-k2.6\n14:01:18 Model opencode-go/kimi-k2.6 failed, trying next (429)\n14:01:18 Trying model 3/5: opencode-go/qwen3.6-plus\n14:02:10 Model opencode-go/qwen3.6-plus succeeded (146978ms, 1 fallbacks)\n```\n\nAll accounts returned 429 \"Subscription quota exceeded\" for each model in sequence. Total cascade: ~2 minutes before a working path was found.\n\n</details>\n\n<details><summary>Additional evidence: overnight harness (2026-04-29)</summary>\n\n```\n03:40:50 429 mimo-v2.5-pro \"Subscription quota exceeded\"\n03:40:56 429 mimo-v2.5-pro \"Subscription quota exceeded\"\n03:51:03 429 mimo-v2.5-pro \"Subscription quota exceeded\"\n03:51:09 429 mimo-v2.5-pro \"Subscription quota exceeded\"\n03:51:15 429 mimo-v2.5-pro \"Subscription quota exceeded\"\n...\n06:48:39 429 mimo-v2.5-pro \"Subscription quota exceeded\"\n```\n\nAll 429s are from the same provider. The combo never reached the cross-provider fallback (glm). Every request cycled through all 3 accounts × all opencode-go targets before giving up.\n\n</details>","comments":[],"createdAt":"2026-04-28T15:31:43Z","labels":[],"number":1731,"title":"[Feature] (combo): provider-level exhaustion tracking to skip same-provider targets","url":"https://github.com/diegosouzapw/OmniRoute/issues/1731"}

1
_ideia/_fetch/1735.json Normal file
View File

@@ -0,0 +1 @@
{"author":{"id":"MDQ6VXNlcjQwODM4MTI=","is_bot":false,"login":"apoapostolov","name":"Apostol Apostolov"},"body":"## Problem\nCombo building needs a quota-aware ranking rule that favors models whose remaining quota can last through the rest of the current quota window.\n\n## Proposed rule: Highest Remaining Quota Rate\nFor any service with a fixed quota window:\n\n- Let `remaining_quota` be the fraction of quota still available, normalized to `0..1`.\n- Let `remaining_days` be the time left in the current quota window, expressed in days.\n- Compute `remaining_quota_rate = remaining_quota / remaining_days`.\n\nFor weekly quotas:\n\n- The expected pacing threshold is `1/7` quota per day.\n- Prefer models where `remaining_quota_rate > 1/7`.\n- If `remaining_quota_rate <= 1/7`, stop using that model in active combos until it recovers above the threshold.\n\n## Example\nIf a model has `60%` of its weekly quota left and `2d 10hr` remaining:\n\n- `remaining_days = 2.4167`\n- `remaining_quota_rate = 0.60 / 2.4167 = 0.248/day`\n- Since `0.248 > 1/7`, the model should remain eligible and be preferred over lower-rate options.\n\n## Expected behavior\n- Combos should rank models using the `remaining_quota_rate` rule whenever a quota window is known.\n- Models above the threshold should be preferred or retained.\n- Models at or below the threshold should be dropped from active use.\n- The rule should be applied consistently during combo evaluation and refresh.\n\n## Notes\n- If a quota is measured in calls instead of percentage, normalize it to a fraction of the current window before applying the rule.\n- The same rule should generalize to other quota windows by using `1 / window_length_days` as the threshold.\n","comments":[],"createdAt":"2026-04-28T17:54:35Z","labels":[],"number":1735,"title":"[Feature] Combo Builder: quota-aware Highest Remaining Quota rule","url":"https://github.com/diegosouzapw/OmniRoute/issues/1735"}

1
_ideia/_fetch/1736.json Normal file
View File

@@ -0,0 +1 @@
{"author":{"id":"MDQ6VXNlcjQwODM4MTI=","is_bot":false,"login":"apoapostolov","name":"Apostol Apostolov"},"body":"## Problem\nThe /Home dashboard should be customizable so users can choose which widgets appear there and in what order. Right now the home page is too fixed, and some widgets are only useful for specific workflows.\n\n## Request\nAdd a /Home customization layer that lets users:\n- Show or hide individual widgets on /Home.\n- Reorder widgets, including pinning a widget to the top.\n- Reuse blocks from other pages where it makes sense, instead of forcing /Home to be a fixed layout.\n\n## Examples\n- Turn off the Onboarding widget after setup.\n- Hide Providers Status when it is not useful for the current workflow.\n- Pin the Limits and Quotas block to the top when tracking monthly usage for GLM, Codex, Claude, and Copilot.\n\n## Expected behavior\n- Widget visibility should be persisted per user.\n- Widget order should be persistent across refreshes and restarts.\n- The /Home page should still have a sensible default layout for first-time users.\n\n## Notes\n- The goal is not a fully freeform page builder. A controlled widget picker and ordering system is enough.\n- This should probably apply only to /Home at first, not every dashboard page.\n","comments":[],"createdAt":"2026-04-28T18:03:58Z","labels":[],"number":1736,"title":"[Feature] Dashboard customization for /Home","url":"https://github.com/diegosouzapw/OmniRoute/issues/1736"}

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