chore(merge): sync release/v3.8.0 with remote — resolve .gitignore conflict

This commit is contained in:
diegosouzapw
2026-05-20 01:38:40 -03:00
73 changed files with 5283 additions and 3079 deletions

View File

@@ -1,888 +0,0 @@
---
name: implement-features-cx
description: Analyze open feature request issues, implement viable ones on dedicated branches, and respond to authors
---
# /implement-features — Feature Request Harvest, Research & Implementation Workflow
## Overview
A **5-phase** workflow that systematically harvests feature requests from GitHub issues, creates structured idea files, researches solutions across the internet and Git repositories, presents a consolidated report for user approval, then generates detailed implementation plans and executes them.
## Codex Execution Notes
- Treat `// turbo` / `// turbo-all` as instructions to use `multi_tool_use.parallel` for independent reads, checks, and GitHub calls.
- Approval gates are hard stops. Present the report/plan in the final response and do not move to implementation phases until the user explicitly approves.
- Keep harvest/research bounded enough to produce the approval report quickly; do not start implementation while still in report phases.
**Output directory structure:**
```
_ideia/
├── viable/ # Features approved for implementation
│ ├── need_details/ # ❓ Good idea but waiting for author clarification (issues stay OPEN)
│ │ └── 1015-warp-terminal-mitm.md
│ ├── 1046-native-playground.md # ✅ Ready — researched and planned
│ └── 1046-native-playground.requirements.md
├── defer/ # ⏭️ Good ideas deferred for future cycles (issues CLOSED)
│ └── 1041-smart-auto-combos.md
└── notfit/ # ❌ Out of scope / already exists (issues CLOSED)
└── 945-telegram-integration.md
_tasks/features-vX.Y.Z/ # Implementation plans (per-release)
└── 1046-native-playground.plan.md
```
> **LIFECYCLE RULE:** `viable/` files are **DELETED** once the feature is implemented — they are not moved. Only unimplemented features live in `viable/` (or `viable/need_details/`). Files in `defer/` and `notfit/` remain as permanent reference.
> **BRANCH RULE**: All implementation work MUST happen on the current `release/vX.Y.Z` branch. Never create separate `feat/` branches. If no release branch exists yet, create one first using `/generate-release` Phase 1 steps 15.
---
## Phase 0 — Pre-flight Triage (NEW)
Before harvesting, run a deterministic triage script that decides which issues to absorb, which to leave dormant, which were already delivered, and which need lifecycle cleanup. This phase replaces the old Phase 1.1/1.2 and gates the rest of the workflow on the triage JSON.
### 0.1 Identify the Repository
// turbo
- Run: `git -C <project_root> remote get-url origin` to extract owner/repo.
### 0.2 Ensure Release Branch Exists
// turbo
```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.
### 0.3 Run feature-triage script
// turbo
```bash
node scripts/features/feature-triage.mjs \
--owner <OWNER> --repo <REPO> \
--output _ideia/_triage.json \
--verbose
```
Read `_ideia/_triage.json` into context. Buckets present: `absorb`, `dormant`, `already_delivered`, `skip_assigned`, `skip_has_pr`, `stale_need_details`, `stale_defer`, `closed_externally`.
> **Defaults** (overridable via flags or env vars):
> quarantine=14d, override-thumbs=5, override-commenters=3, stale-needs=30d, stale-defer=90d.
### 0.4 Apply deterministic actions (in this exact order)
For each bucket, perform the action described. **Order matters**`already_delivered` runs first because its close action precludes any other processing.
1. **`already_delivered`** — pick comment template based on `version_source` + `confidence`:
- `version_source == "tag_after_merge"` AND `confidence == "high"` → template **HIGH** (see Phase 2.5.3)
- `version_source == "tag_after_merge"` AND `confidence == "medium"` → template **MEDIUM** (asks for verification)
- `version_source == "branch_unreleased"` → template **unreleased**
- Then `gh issue close <N> --repo <O>/<R> --comment "<rendered template>"`
2. **`closed_externally`** — for each entry, `rm` the file (log to stderr what was removed).
3. **`stale_need_details`** — for each entry, post the stale template (see Phase 2.5.3), close the issue, then `mv <file> _ideia/notfit/stale/`.
4. **`skip_assigned` / `skip_has_pr`** — no action (silent skip).
5. **`dormant`** — no action (total silence; the JSON records the decision for internal visibility only).
6. **`warnings`** — log each warning to stderr; include them in the Phase 3 report.
> **Note**: issues with `confidence == "low"` are not in `already_delivered` — they appear in `absorb`/`dormant` with a warning, so step 0.4.1 never sees them.
### 0.5 Incremental re-sync for existing idea files in `absorb`
For each `absorb` entry where `existing_idea_file != null`, the script already updated the file via `resync.mjs`. No additional action needed in this step — but verify with `git status` that only expected idea files were modified.
If the entry has `needs_reclassification: true`, move the file out of `_ideia/viable/need_details/` back to `_ideia/` root for Phase 2 to re-classify.
---
## Phase 1 — Harvest: Collect & Catalog Feature Ideas
> Phases 1.1 and 1.2 are now handled by Phase 0.1 and 0.2.
### 1.3 Process triage results
Instead of re-fetching every open issue, use the `_ideia/_triage.json` produced by Phase 0.3. Iterate only over:
- `buckets.absorb[]` — issues that passed quarantine (age ≥ 14d OR engagement override)
- `buckets.stale_defer[]` — deferred ideas due for re-evaluation
For each `absorb` entry, the JSON already includes `number`, `title`, `author`, `created_at`, `age_days`, `thumbs`, `commenters`, `labels`, `existing_idea_file`, and `last_synced_comment_id`. Fetch the full issue body only if needed for Phase 2 research.
For each `stale_defer` entry, **treat it as a fresh idea**:
- Re-run Phase 2 (Research) from scratch — codebase may have evolved in 90+ days, opening new architectural possibilities
- Re-run Phase 2.5 (Organize & Respond) and let the new verdict decide:
- If still **DEFER** → stay in `_ideia/defer/`, but bump `snapshot.classified_at` so the next check is 90 days from now
- If **VIABLE** → move to `_ideia/viable/`, post the "we're picking this back up" variant of the VIABLE comment
- If **NOT FIT** → move to `_ideia/notfit/`, close issue with NOT FIT template
You may batch `gh issue view` calls in parallel (up to 4 at a time) when fresh fetches are required.
> Old behavior (fetching every open issue with `gh issue list`) is replaced by Phase 0.3.
### 1.4 Create Idea Files (initially in `_ideia/` root)
> **If `existing_idea_file != null` in the triage JSON**, the file was already re-synced in Phase 0.5 — skip the create/update step and proceed to Phase 2 for that issue.
>
> **If `needs_reclassification == true`**, the file was moved back to `_ideia/` root in Phase 0.5 — treat it as a fresh idea for the rest of the run.
For each feature request, create a structured idea file in `<project_root>/_ideia/`:
**Filename convention**: `<NUMBER>-<kebab-case-short-title>.md`
Example: `1046-native-playground.md`, `1041-smart-auto-combos.md`
#### 1.4a — If the idea file does NOT exist yet, create it:
```markdown
---
issue: <NUMBER>
last_synced_at: <ISO_TIMESTAMP_NOW>
last_synced_comment_id: <MAX_COMMENT_ID_OR_0>
snapshot:
thumbs: <THUMBS_COUNT>
commenters: <COMMENTERS_COUNT>
age_days: <AGE_DAYS>
labels: [<LABEL_LIST>]
state: open
classified_at: <ISO_TIMESTAMP_NOW>
---
# Feature: <Title from Issue>
> GitHub Issue: #<NUMBER> — opened by @<author> on <date>
> Status: 📋 Cataloged | Priority: TBD
## 📝 Original Request
<Paste the FULL issue body here, preserving all formatting, images, and code blocks>
## 💬 Community Discussion
<Summarize ALL comments chronologically, noting who said what and any decisions or objections raised>
### Participants
- @<author> — Original requester
- @<commenter1> — <brief role/opinion>
- ...
### Key Points
- <bullet list of the most important discussion points>
- <agreements reached>
- <objections raised>
## 🎯 Refined Feature Description
<YOUR interpretation and enrichment of the feature request. Expand on what was asked, fill in logical gaps, provide concrete examples of how it would work. This section should be MORE detailed and clearer than the original request.>
### What it solves
- <problem 1>
- <problem 2>
### How it should work (high level)
1. <step 1>
2. <step 2>
3. ...
### Affected areas
- <list of codebase areas, modules, files likely affected>
## 📎 Attachments & References
- <any image URLs, mockup links, or external references from the issue>
## 🔗 Related Ideas
- <links to related \_ideia/ files if any overlap found>
```
#### 1.4b — If the idea file ALREADY exists, update it:
- Append new comments from the issue to the **Community Discussion** section.
- Update the **Refined Feature Description** if new information changes the understanding.
- Add any new **Related Ideas** cross-references found.
- **Do NOT overwrite** existing content — append and enrich it.
### 1.5 Cross-Reference & Deduplication
After processing all issues:
- Scan all `_ideia/*.md` files for overlapping features.
- If two features are substantially the same, add `🔗 Related Ideas` cross-references to both.
- If one is a strict subset of another, note it in the smaller file: `> This feature is a subset of #<OTHER_NUMBER>. Consider implementing together.`
---
## Phase 2 — Research: Find Solutions & Build Requirements
For each cataloged idea that is **viable** (aligns with the project's goals):
### 2.1 Viability Pre-Check
Before investing in research, quickly assess:
- [ ] Does this feature align with the project's goals and architecture?
- [ ] Is it technically feasible with the current codebase?
- [ ] Does it duplicate existing functionality?
- [ ] Would it introduce breaking changes or security risks?
- [ ] Is there enough detail to understand what's needed?
**Verdict options:**
| Verdict | When | Action |
| --------------------- | ------------------------------------- | --------------------------- |
| ✅ **VIABLE** | Good idea, enough context | Proceed to Research |
| ❓ **NEEDS DETAIL** | Good idea, insufficient spec | Skip research, ask author |
| ⏭️ **DEFER** | Good idea, too complex for this cycle | Catalog only, skip research |
| ❌ **NOT FIT** | Doesn't fit the project | Explain why |
| 🔁 **ALREADY EXISTS** | Feature already implemented | Point to existing feature |
### 2.2 Internet Research (for VIABLE features)
For each viable feature, perform systematic research:
**Step 1 — Web search for similar implementations:**
```
WebSearch("how to implement <feature description> in <tech stack>")
WebSearch("<feature keyword> implementation nextjs typescript 2025 2026")
WebSearch("<feature keyword> open source library npm")
```
**Step 2 — Find reference Git repositories:**
```
WebSearch("site:github.com <feature keyword> <tech stack> stars:>100")
WebSearch("github <feature keyword> implementation recently updated 2026")
```
- Find **up to 10 relevant repositories**, sorted by most recently updated.
- For each repository:
- Note the repo URL, star count, last commit date
- Read its README and relevant source files via `WebFetch`
- Extract the architectural approach, patterns used, and key code snippets
**Step 3 — Read API docs and standards:**
If the feature involves an external API, protocol, or standard:
- Find and read the official documentation
- Note version requirements, authentication patterns, rate limits
### 2.3 Create Requirements File
For each researched feature, create a requirements file alongside its idea file:
**Filename**: `<NUMBER>-<kebab-case-short-title>.requirements.md`
```markdown
# Requirements: <Feature Title>
> Feature Idea: [#<NUMBER>](./<NUMBER>-<kebab-case-short-title>.md)
> Research Date: <YYYY-MM-DD>
> Verdict: ✅ VIABLE
## 🔍 Research Summary
<Brief summary of what was found during research>
## 📚 Reference Implementations
| # | Repository | Stars | Last Updated | Approach | Relevance |
| --- | ---------------- | ----- | ------------ | -------- | ------------ |
| 1 | [repo/name](url) | ⭐ N | YYYY-MM-DD | <brief> | High/Med/Low |
| 2 | ... | | | | |
### Key Patterns Found
- <pattern 1 with code snippet or link>
- <pattern 2>
## 📐 Proposed Solution Architecture
### Approach
<Describe the chosen approach based on research findings>
### New Files
| File | Purpose |
| --------------------- | ------------- |
| `path/to/new/file.ts` | <description> |
### Modified Files
| File | Changes |
| -------------------------- | -------------- |
| `path/to/existing/file.ts` | <what changes> |
### Database Changes
- <migrations needed, if any>
### API Changes
- <new/modified endpoints, if any>
### UI Changes
- <new/modified pages/components, if any>
## ⚙️ Implementation Effort
- **Estimated complexity**: Low / Medium / High / Very High
- **Estimated files changed**: ~N
- **Dependencies needed**: <new npm packages, if any>
- **Breaking changes**: Yes/No — <details>
- **i18n impact**: <number of new translation keys>
- **Test coverage needed**: <brief description>
## ⚠️ Open Questions
- <question 1>
- <question 2>
## 🔗 External References
- <documentation URLs>
- <API references>
```
---
## Phase 2.5 — Organize & Respond: Sort Files and Post GitHub Comments
### 2.5.1 Create Directory Structure
// turbo
```bash
mkdir -p <project_root>/_ideia/viable
mkdir -p <project_root>/_ideia/viable/need_details
mkdir -p <project_root>/_ideia/defer
mkdir -p <project_root>/_ideia/notfit
```
### 2.5.2 Move Idea Files to Category Subdirectories
After classification, move EVERY idea file to its correct subdirectory:
```bash
# ✅ VIABLE — move idea + requirements files
mv _ideia/<NUMBER>-*.md _ideia/viable/
mv _ideia/<NUMBER>-*.requirements.md _ideia/viable/
# ❓ NEEDS DETAIL — viable but waiting for author response
mv _ideia/<NUMBER>-*.md _ideia/viable/need_details/
# ⏭️ DEFER — move idea files only
mv _ideia/<NUMBER>-*.md _ideia/defer/
# ❌ NOT FIT & 🔁 ALREADY EXISTS — move idea files only
mv _ideia/<NUMBER>-*.md _ideia/notfit/
```
No files should remain in `_ideia/` root after this step (except subdirectories).
### 2.5.3 Post GitHub Comments by Category
**Each category has a specific comment template and action:**
---
#### For 🔁 ALREADY EXISTS — Comment + CLOSE issue
// turbo
The feature already exists in the system. Explain WHERE it is and HOW to use it.
```markdown
Hi @<author>! Thanks for the suggestion! 🙏
Great news — this functionality **already exists** in OmniRoute:
**📍 Where to find it:** <exact dashboard path or settings location>
**🔧 How to use it:**
1. <step 1>
2. <step 2>
3. <step 3>
If you have any trouble finding or using it, feel free to ask in a Discussion. We're always happy to help!
Closing this as the feature is already available. 🎉
```
```bash
gh issue close <NUMBER> --repo <owner>/<repo> --comment "<comment above>"
```
---
#### For ⏭️ DEFER — Comment + CLOSE issue
// turbo
Thank the user, explain the idea was cataloged, and that we'll study it before implementing.
```markdown
Hi @<author>! Thanks for this thoughtful feature request! 🙏
We really appreciate the detailed proposal. We've **cataloged your idea** and it's now part of our improvement backlog.
Due to the **significant architectural impact** of this feature, we'll need to conduct thorough use-case studies and architectural analysis before we start development. This ensures we build it right and don't introduce regressions.
**What happens next:**
- Your idea is saved in our internal feature backlog
- We'll conduct architecture studies when this area is prioritized
- We'll notify you here when development begins
Thank you for contributing to OmniRoute's roadmap! Your input helps shape the product. 🚀
```
```bash
gh issue close <NUMBER> --repo <owner>/<repo> --comment "<comment above>"
```
---
#### For ❌ NOT FIT — Comment + CLOSE issue
// turbo
Politely explain why the feature doesn't fit the project scope.
```markdown
Hi @<author>! Thanks for the suggestion! 🙏
After careful analysis, we've determined that this feature **falls outside OmniRoute's core scope** as a proxy/router.
**Reason:** <explain why — e.g., "Telegram integration belongs in the application/orchestrator layer that consumes OmniRoute's API, not inside the router itself.">
**Alternative:** <suggest an alternative approach if possible>
We appreciate you thinking of ways to improve OmniRoute! If you'd like to discuss this further, feel free to open a Discussion. 🙏
```
```bash
gh issue close <NUMBER> --repo <owner>/<repo> --comment "<comment above>"
```
---
#### For ❓ NEEDS DETAIL — Comment (keep OPEN)
// turbo
Ask for the specific missing details needed.
```markdown
Hi @<author>! Thanks for the feature request — it's an interesting idea and we'd love to explore it further. 🙏
To move forward, we need a few more details:
1. <specific question 1>
2. <specific question 2>
3. <specific question 3>
If you know of any **open-source projects or repositories** that implement something similar, please share links — it would help us design the best solution.
Looking forward to your response! 🚀
```
---
#### For ✅ VIABLE — Comment (keep OPEN)
// turbo
Thank the user, confirm we've cataloged their idea, and explain it may be implemented in future versions.
```markdown
Hi @<author>! Thanks for the great feature suggestion! 🙏
We've analyzed your request and it aligns well with OmniRoute's roadmap. We've **cataloged this feature** and it's in our implementation backlog.
**Status:** 📋 Cataloged for future implementation
This feature may be included in upcoming releases. We'll **respond to this issue and tag you** as soon as implementation begins so you can test it.
Thank you for helping improve OmniRoute! 🚀
```
**⚠️ Do NOT close viable issues — they remain OPEN for tracking.**
---
#### For 🎉 ALREADY DELIVERED — HIGH confidence
// turbo
Used when triage `confidence == "high"` and `version_source == "tag_after_merge"`. Close the issue with a celebratory comment pointing at the shipped version + PR.
```markdown
Hi @<author>! 🎉
Great news — this functionality was already delivered in version **<VERSION>** through PR #<PR_NUMBER> (<PR_TITLE>).
**How to try it:**
\`\`\`bash
git pull origin main && npm install
npm run dev
\`\`\`
If your use case is slightly different from what was shipped, feel free to reopen this issue or open a new one with the specific gap. Thanks for helping shape OmniRoute! 🚀
```
```bash
gh issue close <NUMBER> --repo <owner>/<repo> --comment "<comment above>"
```
---
#### For 🎉 ALREADY DELIVERED — MEDIUM confidence
// turbo
Used when triage `confidence == "medium"`. More cautious — asks the author to verify.
```markdown
Hi @<author>! 🎉
This functionality appears to have been delivered in version **<VERSION>** based on related changes (PR #<PR_NUMBER>, CHANGELOG, commit history).
Could you please verify if the current release covers your request? If yes, feel free to close. If not, comment back with the gap and we'll reopen for further work.
**How to verify:**
\`\`\`bash
git pull origin main && npm install
\`\`\`
Thanks for contributing! 🚀
```
```bash
gh issue close <NUMBER> --repo <owner>/<repo> --comment "<comment above>"
```
---
#### For 🎉 ALREADY DELIVERED — branch_unreleased
// turbo
Used when `version_source == "branch_unreleased"` (regardless of confidence). The fix is on a release branch that hasn't been tagged yet.
```markdown
Hi @<author>! 🎉
This functionality has been implemented in the upcoming release (branch `release/<VERSION>`, PR #<PR_NUMBER>) and will ship in the next release.
You can already try it on the release branch:
\`\`\`bash
git fetch origin && git checkout release/<VERSION>
npm install && npm run dev
\`\`\`
Closing now since the work is done — feel free to reopen if you spot any gaps after testing. 🚀
```
```bash
gh issue close <NUMBER> --repo <owner>/<repo> --comment "<comment above>"
```
---
#### For ⏰ STALE NEED_DETAILS — Close after 30d without author reply
// turbo
Used for entries in `buckets.stale_need_details`. Polite close + invite to reopen + `mv` file to `notfit/stale/`.
```markdown
Hi @<author>! 🙏
Since we haven't heard back from you in about 30 days regarding the details we asked for, we're closing this issue to keep the backlog clean.
**No worries** — please feel free to **reopen** this issue whenever you have the details handy. Just click "Reopen" and reply with the missing information, and we'll pick it back up.
Thanks for thinking of OmniRoute! 🚀
```
```bash
gh issue close <NUMBER> --repo <owner>/<repo> --comment "<comment above>"
mkdir -p _ideia/notfit/stale
mv <FILE_PATH> _ideia/notfit/stale/
```
---
## Phase 3 — Report: Present Findings to User
### 3.1 🛑 MANDATORY STOP — Present Consolidated Report
After completing Phase 1, Phase 2, and Phase 2.5, **STOP and present the following report** in the chat. Do NOT proceed to implementation.
Present a structured report containing:
#### 3.1a — Feature Summary Table
| # | Issue | Title | Verdict | Location | Action |
| --- | ----- | ----- | --------------------- | ----------------------------- | ----------------------------------------- |
| 1 | #N | Title | ✅ VIABLE | `_ideia/viable/` | Issue OPEN, comment posted |
| 2 | #N | Title | ⏭️ DEFER | `_ideia/defer/` | Issue CLOSED with explanation |
| 3 | #N | Title | ❌ NOT FIT | `_ideia/notfit/` | Issue CLOSED with explanation |
| 4 | #N | Title | 🔁 EXISTS | `_ideia/notfit/` | Issue CLOSED with guidance |
| 5 | #N | Title | ❓ NEEDS DETAIL | `_ideia/viable/need_details/` | Issue OPEN, questions posted |
| 6 | #N | Title | 🎉 ALREADY DELIVERED | (closed) | Issue CLOSED, version + PR cited |
| 7 | #N | Title | 💤 DORMANT | (no file) | Silent skip — quarantine not met yet |
| 8 | #N | Title | 👤 SKIP_ASSIGNED | (no file) | Silent skip — has assignee |
| 9 | #N | Title | 🔗 SKIP_HAS_PR | (no file) | Silent skip — has open linked PR |
| 10 | #N | Title | ⏰ STALE NEED_DETAILS | `_ideia/notfit/stale/` | Issue CLOSED politely after 30d |
| 11 | #N | Title | ♻️ STALE DEFER | (re-classified) | Re-ran Phase 2; new verdict applied |
| 12 | #N | Title | 🗑️ CLOSED EXTERNALLY | (file deleted) | Idea file removed; issue closed elsewhere |
#### 3.1b — Viable Features Detail
For each VIABLE feature, provide a brief paragraph:
- What was found during research
- The proposed approach
- Key risks or unknowns
- Which reference repositories were most useful
#### 3.1c — Issues Requiring Author Feedback
For features marked ❓ NEEDS DETAIL, list:
- What specific information is missing
- What examples or repository references would help
#### 3.1d — Ask for User Confirmation
End the report with:
> **Ready to proceed with implementation?**
>
> - Reply **"sim"** or **"yes"** to generate full implementation plans for all VIABLE features.
> - Reply with specific issue numbers to select only certain features.
> - Reply **"não"** or **"no"** to stop here.
---
## Phase 4 — Plan: Generate Implementation Plans (after user says "yes")
> **⚠️ Do NOT enter this phase without explicit user approval from Phase 3.**
### 4.1 Create Task Directory
```bash
mkdir -p <project_root>/_tasks/features-vX.Y.Z/
```
### 4.2 Generate One Implementation Plan Per Feature
For each VIABLE feature approved by the user, create:
**Filename**: `_tasks/features-vX.Y.Z/<NUMBER>-<kebab-case-title>.plan.md`
```markdown
# Implementation Plan: <Feature Title>
> Issue: #<NUMBER>
> Idea: [\_ideia/viable/<NUMBER>-title.md](../../_ideia/viable/<NUMBER>-title.md)
> Requirements: [\_ideia/viable/<NUMBER>-title.requirements.md](../../_ideia/viable/<NUMBER>-title.requirements.md)
> Branch: `release/vX.Y.Z`
## Overview
<Brief description of what will be built>
## Pre-Implementation Checklist
- [ ] Read all related source files listed below
- [ ] Confirm no conflicts with in-flight PRs
- [ ] Verify database migration numbering
## Implementation Steps
### Step 1: <Title>
**Files:**
- `path/to/file.ts` — <what to change>
**Details:**
<Detailed description of the change, including code patterns to follow, function signatures, etc.>
### Step 2: <Title>
...
### Step N: Tests
**New test files:**
- `tests/unit/<test-file>.test.mjs` — <what to test>
**Test cases:**
- [ ] <test case 1>
- [ ] <test case 2>
### Step N+1: i18n
**Translation keys to add:**
- `<namespace>.<key>` — "<English value>"
### Step N+2: Documentation
- [ ] Update CHANGELOG.md
- [ ] Update relevant docs/ files
## Verification Plan
1. Run `npm run build` — must pass
2. Run `npm test` — all tests must pass
3. Run `npm run lint` — no new errors
4. <Manual verification steps>
## Commit Plan
```
feat: <description> (#<NUMBER>)
```
```
### 4.3 Present Plans for Final Approval
Present a summary of all generated plans:
> **Implementation plans generated:**
>
> | # | Feature | Plan File | Steps | Effort |
> | --- | ------- | ---------------------------------------- | ------- | ------ |
> | 1 | <title> | `_tasks/features-vX.Y.Z/N-title.plan.md` | N steps | Medium |
>
> Reply **"sim"** or **"yes"** to begin implementation of all features.
> Reply with specific issue numbers to implement only certain ones.
---
## Phase 5 — Execute: Implement the Plans (after user says "yes")
> **⚠️ Do NOT enter this phase without explicit user approval from Phase 4.**
### 5.1 Implement Each Feature
For each approved plan, execute it step by step:
1. **Follow the plan** — implement exactly as specified in the `.plan.md` file
2. **Build** — Run `npm run build` after each feature to verify compilation
3. **Test** — Run `npm test` to ensure no regressions
4. **Commit** — Commit with: `feat: <description> (#<NUMBER>)`
5. **Update the plan** — Mark completed steps with `[x]` in the plan file
6. **Continue** — Move to the next feature (do NOT switch branches)
### 5.2 Respond to Authors (Update Viable Issues)
For each implemented feature, **close the issue with a final comment**:
````markdown
✅ **Implemented in `release/vX.Y.Z`!**
Hi @<author>! Great news — your feature request has been implemented! 🎉
**What was done:**
- <bullet list of what was built>
**How to try it:**
```bash
git fetch origin && git checkout release/vX.Y.Z
npm install && npm run dev
```
````
This will be included in the upcoming **vX.Y.Z** release. Feel free to reopen if you spot any issues! 🚀
````
```bash
gh issue close <NUMBER> --repo <owner>/<repo> --comment "<comment above>"
````
Then **DELETE the idea file** — it has served its purpose:
```bash
# ✅ Implemented files are DELETED (not moved)
rm _ideia/viable/<NUMBER>-<title>.md
rm _ideia/viable/<NUMBER>-<title>.requirements.md # if exists
```
> **Why delete?** `viable/` only holds features that still NEED to be done. Once implemented, the commit history and CHANGELOG are the source of truth. Keeping the file would be confusing.
### 5.3 Finalize & Push
After implementing all approved features:
1. **Update CHANGELOG.md** on the release branch with all new feature entries
2. Push the release branch: `git push origin release/vX.Y.Z`
3. Run `/generate-release` workflow Phase 1 steps 710 (tests → commit → push → open PR to main → wait for user)
### 5.4 Final Summary Report
Present a final summary report to the user:
| Issue | Title | Verdict | Action | Commit |
| ----- | ----- | --------------- | -------------------------------------------------- | --------- |
| #N | Title | ✅ Implemented | Issue closed, idea file deleted | `abc1234` |
| #N | Title | ⏭️ Deferred | Issue closed + saved in `_ideia/defer/` | — |
| #N | Title | ❌ Not Fit | Issue closed + saved in `_ideia/notfit/` | — |
| #N | Title | 🔁 Exists | Issue closed + saved in `_ideia/notfit/` | — |
| #N | Title | ❓ Needs Detail | Issue OPEN, moved to `_ideia/viable/need_details/` | — |
Include all counters from `_ideia/_triage.json` `counts` field plus:
- Total features harvested (= `counts.total_fetched`)
- Total absorbed and processed (= `counts.absorb`)
- Total dormant (skipped quarantine) (= `counts.dormant`)
- Total already-delivered (closed with version reference) (= `counts.already_delivered`)
- Total skipped (assigned + has PR) (= `counts.skip_assigned + counts.skip_has_pr`)
- Total stale need_details (closed after 30d silence) (= `counts.stale_need_details`)
- Total stale defer (re-classified) (= `counts.stale_defer`)
- Total cleaned up (closed externally) (= `counts.closed_externally`)
- Total ideas cataloged (`viable/need_details/` + `defer/` + `notfit/`)
- Total features implemented (idea files deleted, issues closed)
- Total issues closed
- Total issues left open
- Test results (pass/fail count)
- All `warnings[]` entries from `_triage.json`

View File

@@ -1,881 +0,0 @@
---
description: Analyze open feature request issues, implement viable ones on dedicated branches, and respond to authors
---
# /implement-features — Feature Request Harvest, Research & Implementation Workflow
## Overview
A **5-phase** workflow that systematically harvests feature requests from GitHub issues, creates structured idea files, researches solutions across the internet and Git repositories, presents a consolidated report for user approval, then generates detailed implementation plans and executes them.
**Output directory structure:**
```
_ideia/
├── viable/ # Features approved for implementation
│ ├── need_details/ # ❓ Good idea but waiting for author clarification (issues stay OPEN)
│ │ └── 1015-warp-terminal-mitm.md
│ ├── 1046-native-playground.md # ✅ Ready — researched and planned
│ └── 1046-native-playground.requirements.md
├── defer/ # ⏭️ Good ideas deferred for future cycles (issues CLOSED)
│ └── 1041-smart-auto-combos.md
└── notfit/ # ❌ Out of scope / already exists (issues CLOSED)
└── 945-telegram-integration.md
_tasks/features-vX.Y.Z/ # Implementation plans (per-release)
└── 1046-native-playground.plan.md
```
> **LIFECYCLE RULE:** `viable/` files are **DELETED** once the feature is implemented — they are not moved. Only unimplemented features live in `viable/` (or `viable/need_details/`). Files in `defer/` and `notfit/` remain as permanent reference.
> **BRANCH RULE**: All implementation work MUST happen on the current `release/vX.Y.Z` branch. Never create separate `feat/` branches. If no release branch exists yet, create one first using `/generate-release` Phase 1 steps 15.
---
## Phase 0 — Pre-flight Triage (NEW)
Before harvesting, run a deterministic triage script that decides which issues to absorb, which to leave dormant, which were already delivered, and which need lifecycle cleanup. This phase replaces the old Phase 1.1/1.2 and gates the rest of the workflow on the triage JSON.
### 0.1 Identify the Repository
// turbo
- Run: `git -C <project_root> remote get-url origin` to extract owner/repo.
### 0.2 Ensure Release Branch Exists
// turbo
```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.
### 0.3 Run feature-triage script
// turbo
```bash
node scripts/features/feature-triage.mjs \
--owner <OWNER> --repo <REPO> \
--output _ideia/_triage.json \
--verbose
```
Read `_ideia/_triage.json` into context. Buckets present: `absorb`, `dormant`, `already_delivered`, `skip_assigned`, `skip_has_pr`, `stale_need_details`, `stale_defer`, `closed_externally`.
> **Defaults** (overridable via flags or env vars):
> quarantine=14d, override-thumbs=5, override-commenters=3, stale-needs=30d, stale-defer=90d.
### 0.4 Apply deterministic actions (in this exact order)
For each bucket, perform the action described. **Order matters**`already_delivered` runs first because its close action precludes any other processing.
1. **`already_delivered`** — pick comment template based on `version_source` + `confidence`:
- `version_source == "tag_after_merge"` AND `confidence == "high"` → template **HIGH** (see Phase 2.5.3)
- `version_source == "tag_after_merge"` AND `confidence == "medium"` → template **MEDIUM** (asks for verification)
- `version_source == "branch_unreleased"` → template **unreleased**
- Then `gh issue close <N> --repo <O>/<R> --comment "<rendered template>"`
2. **`closed_externally`** — for each entry, `rm` the file (log to stderr what was removed).
3. **`stale_need_details`** — for each entry, post the stale template (see Phase 2.5.3), close the issue, then `mv <file> _ideia/notfit/stale/`.
4. **`skip_assigned` / `skip_has_pr`** — no action (silent skip).
5. **`dormant`** — no action (total silence; the JSON records the decision for internal visibility only).
6. **`warnings`** — log each warning to stderr; include them in the Phase 3 report.
> **Note**: issues with `confidence == "low"` are not in `already_delivered` — they appear in `absorb`/`dormant` with a warning, so step 0.4.1 never sees them.
### 0.5 Incremental re-sync for existing idea files in `absorb`
For each `absorb` entry where `existing_idea_file != null`, the script already updated the file via `resync.mjs`. No additional action needed in this step — but verify with `git status` that only expected idea files were modified.
If the entry has `needs_reclassification: true`, move the file out of `_ideia/viable/need_details/` back to `_ideia/` root for Phase 2 to re-classify.
---
## Phase 1 — Harvest: Collect & Catalog Feature Ideas
> Phases 1.1 and 1.2 are now handled by Phase 0.1 and 0.2.
### 1.3 Process triage results
Instead of re-fetching every open issue, use the `_ideia/_triage.json` produced by Phase 0.3. Iterate only over:
- `buckets.absorb[]` — issues that passed quarantine (age ≥ 14d OR engagement override)
- `buckets.stale_defer[]` — deferred ideas due for re-evaluation
For each `absorb` entry, the JSON already includes `number`, `title`, `author`, `created_at`, `age_days`, `thumbs`, `commenters`, `labels`, `existing_idea_file`, and `last_synced_comment_id`. Fetch the full issue body only if needed for Phase 2 research.
For each `stale_defer` entry, **treat it as a fresh idea**:
- Re-run Phase 2 (Research) from scratch — codebase may have evolved in 90+ days, opening new architectural possibilities
- Re-run Phase 2.5 (Organize & Respond) and let the new verdict decide:
- If still **DEFER** → stay in `_ideia/defer/`, but bump `snapshot.classified_at` so the next check is 90 days from now
- If **VIABLE** → move to `_ideia/viable/`, post the "we're picking this back up" variant of the VIABLE comment
- If **NOT FIT** → move to `_ideia/notfit/`, close issue with NOT FIT template
You may batch `gh issue view` calls in parallel (up to 4 at a time) when fresh fetches are required.
> Old behavior (fetching every open issue with `gh issue list`) is replaced by Phase 0.3.
### 1.4 Create Idea Files (initially in `_ideia/` root)
> **If `existing_idea_file != null` in the triage JSON**, the file was already re-synced in Phase 0.5 — skip the create/update step and proceed to Phase 2 for that issue.
>
> **If `needs_reclassification == true`**, the file was moved back to `_ideia/` root in Phase 0.5 — treat it as a fresh idea for the rest of the run.
For each feature request, create a structured idea file in `<project_root>/_ideia/`:
**Filename convention**: `<NUMBER>-<kebab-case-short-title>.md`
Example: `1046-native-playground.md`, `1041-smart-auto-combos.md`
#### 1.4a — If the idea file does NOT exist yet, create it:
```markdown
---
issue: <NUMBER>
last_synced_at: <ISO_TIMESTAMP_NOW>
last_synced_comment_id: <MAX_COMMENT_ID_OR_0>
snapshot:
thumbs: <THUMBS_COUNT>
commenters: <COMMENTERS_COUNT>
age_days: <AGE_DAYS>
labels: [<LABEL_LIST>]
state: open
classified_at: <ISO_TIMESTAMP_NOW>
---
# Feature: <Title from Issue>
> GitHub Issue: #<NUMBER> — opened by @<author> on <date>
> Status: 📋 Cataloged | Priority: TBD
## 📝 Original Request
<Paste the FULL issue body here, preserving all formatting, images, and code blocks>
## 💬 Community Discussion
<Summarize ALL comments chronologically, noting who said what and any decisions or objections raised>
### Participants
- @<author> — Original requester
- @<commenter1> — <brief role/opinion>
- ...
### Key Points
- <bullet list of the most important discussion points>
- <agreements reached>
- <objections raised>
## 🎯 Refined Feature Description
<YOUR interpretation and enrichment of the feature request. Expand on what was asked, fill in logical gaps, provide concrete examples of how it would work. This section should be MORE detailed and clearer than the original request.>
### What it solves
- <problem 1>
- <problem 2>
### How it should work (high level)
1. <step 1>
2. <step 2>
3. ...
### Affected areas
- <list of codebase areas, modules, files likely affected>
## 📎 Attachments & References
- <any image URLs, mockup links, or external references from the issue>
## 🔗 Related Ideas
- <links to related \_ideia/ files if any overlap found>
```
#### 1.4b — If the idea file ALREADY exists, update it:
- Append new comments from the issue to the **Community Discussion** section.
- Update the **Refined Feature Description** if new information changes the understanding.
- Add any new **Related Ideas** cross-references found.
- **Do NOT overwrite** existing content — append and enrich it.
### 1.5 Cross-Reference & Deduplication
After processing all issues:
- Scan all `_ideia/*.md` files for overlapping features.
- If two features are substantially the same, add `🔗 Related Ideas` cross-references to both.
- If one is a strict subset of another, note it in the smaller file: `> This feature is a subset of #<OTHER_NUMBER>. Consider implementing together.`
---
## Phase 2 — Research: Find Solutions & Build Requirements
For each cataloged idea that is **viable** (aligns with the project's goals):
### 2.1 Viability Pre-Check
Before investing in research, quickly assess:
- [ ] Does this feature align with the project's goals and architecture?
- [ ] Is it technically feasible with the current codebase?
- [ ] Does it duplicate existing functionality?
- [ ] Would it introduce breaking changes or security risks?
- [ ] Is there enough detail to understand what's needed?
**Verdict options:**
| Verdict | When | Action |
| --------------------- | ------------------------------------- | --------------------------- |
| ✅ **VIABLE** | Good idea, enough context | Proceed to Research |
| ❓ **NEEDS DETAIL** | Good idea, insufficient spec | Skip research, ask author |
| ⏭️ **DEFER** | Good idea, too complex for this cycle | Catalog only, skip research |
| ❌ **NOT FIT** | Doesn't fit the project | Explain why |
| 🔁 **ALREADY EXISTS** | Feature already implemented | Point to existing feature |
### 2.2 Internet Research (for VIABLE features)
For each viable feature, perform systematic research:
**Step 1 — Web search for similar implementations:**
```
WebSearch("how to implement <feature description> in <tech stack>")
WebSearch("<feature keyword> implementation nextjs typescript 2025 2026")
WebSearch("<feature keyword> open source library npm")
```
**Step 2 — Find reference Git repositories:**
```
WebSearch("site:github.com <feature keyword> <tech stack> stars:>100")
WebSearch("github <feature keyword> implementation recently updated 2026")
```
- Find **up to 10 relevant repositories**, sorted by most recently updated.
- For each repository:
- Note the repo URL, star count, last commit date
- Read its README and relevant source files via `WebFetch`
- Extract the architectural approach, patterns used, and key code snippets
**Step 3 — Read API docs and standards:**
If the feature involves an external API, protocol, or standard:
- Find and read the official documentation
- Note version requirements, authentication patterns, rate limits
### 2.3 Create Requirements File
For each researched feature, create a requirements file alongside its idea file:
**Filename**: `<NUMBER>-<kebab-case-short-title>.requirements.md`
```markdown
# Requirements: <Feature Title>
> Feature Idea: [#<NUMBER>](./<NUMBER>-<kebab-case-short-title>.md)
> Research Date: <YYYY-MM-DD>
> Verdict: ✅ VIABLE
## 🔍 Research Summary
<Brief summary of what was found during research>
## 📚 Reference Implementations
| # | Repository | Stars | Last Updated | Approach | Relevance |
| --- | ---------------- | ----- | ------------ | -------- | ------------ |
| 1 | [repo/name](url) | ⭐ N | YYYY-MM-DD | <brief> | High/Med/Low |
| 2 | ... | | | | |
### Key Patterns Found
- <pattern 1 with code snippet or link>
- <pattern 2>
## 📐 Proposed Solution Architecture
### Approach
<Describe the chosen approach based on research findings>
### New Files
| File | Purpose |
| --------------------- | ------------- |
| `path/to/new/file.ts` | <description> |
### Modified Files
| File | Changes |
| -------------------------- | -------------- |
| `path/to/existing/file.ts` | <what changes> |
### Database Changes
- <migrations needed, if any>
### API Changes
- <new/modified endpoints, if any>
### UI Changes
- <new/modified pages/components, if any>
## ⚙️ Implementation Effort
- **Estimated complexity**: Low / Medium / High / Very High
- **Estimated files changed**: ~N
- **Dependencies needed**: <new npm packages, if any>
- **Breaking changes**: Yes/No — <details>
- **i18n impact**: <number of new translation keys>
- **Test coverage needed**: <brief description>
## ⚠️ Open Questions
- <question 1>
- <question 2>
## 🔗 External References
- <documentation URLs>
- <API references>
```
---
## Phase 2.5 — Organize & Respond: Sort Files and Post GitHub Comments
### 2.5.1 Create Directory Structure
// turbo
```bash
mkdir -p <project_root>/_ideia/viable
mkdir -p <project_root>/_ideia/viable/need_details
mkdir -p <project_root>/_ideia/defer
mkdir -p <project_root>/_ideia/notfit
```
### 2.5.2 Move Idea Files to Category Subdirectories
After classification, move EVERY idea file to its correct subdirectory:
```bash
# ✅ VIABLE — move idea + requirements files
mv _ideia/<NUMBER>-*.md _ideia/viable/
mv _ideia/<NUMBER>-*.requirements.md _ideia/viable/
# ❓ NEEDS DETAIL — viable but waiting for author response
mv _ideia/<NUMBER>-*.md _ideia/viable/need_details/
# ⏭️ DEFER — move idea files only
mv _ideia/<NUMBER>-*.md _ideia/defer/
# ❌ NOT FIT & 🔁 ALREADY EXISTS — move idea files only
mv _ideia/<NUMBER>-*.md _ideia/notfit/
```
No files should remain in `_ideia/` root after this step (except subdirectories).
### 2.5.3 Post GitHub Comments by Category
**Each category has a specific comment template and action:**
---
#### For 🔁 ALREADY EXISTS — Comment + CLOSE issue
// turbo
The feature already exists in the system. Explain WHERE it is and HOW to use it.
```markdown
Hi @<author>! Thanks for the suggestion! 🙏
Great news — this functionality **already exists** in OmniRoute:
**📍 Where to find it:** <exact dashboard path or settings location>
**🔧 How to use it:**
1. <step 1>
2. <step 2>
3. <step 3>
If you have any trouble finding or using it, feel free to ask in a Discussion. We're always happy to help!
Closing this as the feature is already available. 🎉
```
```bash
gh issue close <NUMBER> --repo <owner>/<repo> --comment "<comment above>"
```
---
#### For ⏭️ DEFER — Comment + CLOSE issue
// turbo
Thank the user, explain the idea was cataloged, and that we'll study it before implementing.
```markdown
Hi @<author>! Thanks for this thoughtful feature request! 🙏
We really appreciate the detailed proposal. We've **cataloged your idea** and it's now part of our improvement backlog.
Due to the **significant architectural impact** of this feature, we'll need to conduct thorough use-case studies and architectural analysis before we start development. This ensures we build it right and don't introduce regressions.
**What happens next:**
- Your idea is saved in our internal feature backlog
- We'll conduct architecture studies when this area is prioritized
- We'll notify you here when development begins
Thank you for contributing to OmniRoute's roadmap! Your input helps shape the product. 🚀
```
```bash
gh issue close <NUMBER> --repo <owner>/<repo> --comment "<comment above>"
```
---
#### For ❌ NOT FIT — Comment + CLOSE issue
// turbo
Politely explain why the feature doesn't fit the project scope.
```markdown
Hi @<author>! Thanks for the suggestion! 🙏
After careful analysis, we've determined that this feature **falls outside OmniRoute's core scope** as a proxy/router.
**Reason:** <explain why — e.g., "Telegram integration belongs in the application/orchestrator layer that consumes OmniRoute's API, not inside the router itself.">
**Alternative:** <suggest an alternative approach if possible>
We appreciate you thinking of ways to improve OmniRoute! If you'd like to discuss this further, feel free to open a Discussion. 🙏
```
```bash
gh issue close <NUMBER> --repo <owner>/<repo> --comment "<comment above>"
```
---
#### For ❓ NEEDS DETAIL — Comment (keep OPEN)
// turbo
Ask for the specific missing details needed.
```markdown
Hi @<author>! Thanks for the feature request — it's an interesting idea and we'd love to explore it further. 🙏
To move forward, we need a few more details:
1. <specific question 1>
2. <specific question 2>
3. <specific question 3>
If you know of any **open-source projects or repositories** that implement something similar, please share links — it would help us design the best solution.
Looking forward to your response! 🚀
```
---
#### For ✅ VIABLE — Comment (keep OPEN)
// turbo
Thank the user, confirm we've cataloged their idea, and explain it may be implemented in future versions.
```markdown
Hi @<author>! Thanks for the great feature suggestion! 🙏
We've analyzed your request and it aligns well with OmniRoute's roadmap. We've **cataloged this feature** and it's in our implementation backlog.
**Status:** 📋 Cataloged for future implementation
This feature may be included in upcoming releases. We'll **respond to this issue and tag you** as soon as implementation begins so you can test it.
Thank you for helping improve OmniRoute! 🚀
```
**⚠️ Do NOT close viable issues — they remain OPEN for tracking.**
---
#### For 🎉 ALREADY DELIVERED — HIGH confidence
// turbo
Used when triage `confidence == "high"` and `version_source == "tag_after_merge"`. Close the issue with a celebratory comment pointing at the shipped version + PR.
```markdown
Hi @<author>! 🎉
Great news — this functionality was already delivered in version **<VERSION>** through PR #<PR_NUMBER> (<PR_TITLE>).
**How to try it:**
\`\`\`bash
git pull origin main && npm install
npm run dev
\`\`\`
If your use case is slightly different from what was shipped, feel free to reopen this issue or open a new one with the specific gap. Thanks for helping shape OmniRoute! 🚀
```
```bash
gh issue close <NUMBER> --repo <owner>/<repo> --comment "<comment above>"
```
---
#### For 🎉 ALREADY DELIVERED — MEDIUM confidence
// turbo
Used when triage `confidence == "medium"`. More cautious — asks the author to verify.
```markdown
Hi @<author>! 🎉
This functionality appears to have been delivered in version **<VERSION>** based on related changes (PR #<PR_NUMBER>, CHANGELOG, commit history).
Could you please verify if the current release covers your request? If yes, feel free to close. If not, comment back with the gap and we'll reopen for further work.
**How to verify:**
\`\`\`bash
git pull origin main && npm install
\`\`\`
Thanks for contributing! 🚀
```
```bash
gh issue close <NUMBER> --repo <owner>/<repo> --comment "<comment above>"
```
---
#### For 🎉 ALREADY DELIVERED — branch_unreleased
// turbo
Used when `version_source == "branch_unreleased"` (regardless of confidence). The fix is on a release branch that hasn't been tagged yet.
```markdown
Hi @<author>! 🎉
This functionality has been implemented in the upcoming release (branch `release/<VERSION>`, PR #<PR_NUMBER>) and will ship in the next release.
You can already try it on the release branch:
\`\`\`bash
git fetch origin && git checkout release/<VERSION>
npm install && npm run dev
\`\`\`
Closing now since the work is done — feel free to reopen if you spot any gaps after testing. 🚀
```
```bash
gh issue close <NUMBER> --repo <owner>/<repo> --comment "<comment above>"
```
---
#### For ⏰ STALE NEED_DETAILS — Close after 30d without author reply
// turbo
Used for entries in `buckets.stale_need_details`. Polite close + invite to reopen + `mv` file to `notfit/stale/`.
```markdown
Hi @<author>! 🙏
Since we haven't heard back from you in about 30 days regarding the details we asked for, we're closing this issue to keep the backlog clean.
**No worries** — please feel free to **reopen** this issue whenever you have the details handy. Just click "Reopen" and reply with the missing information, and we'll pick it back up.
Thanks for thinking of OmniRoute! 🚀
```
```bash
gh issue close <NUMBER> --repo <owner>/<repo> --comment "<comment above>"
mkdir -p _ideia/notfit/stale
mv <FILE_PATH> _ideia/notfit/stale/
```
---
## Phase 3 — Report: Present Findings to User
### 3.1 🛑 MANDATORY STOP — Present Consolidated Report
After completing Phase 1, Phase 2, and Phase 2.5, **STOP and present the following report** in the chat. Do NOT proceed to implementation.
Present a structured report containing:
#### 3.1a — Feature Summary Table
| # | Issue | Title | Verdict | Location | Action |
| --- | ----- | ----- | --------------------- | ----------------------------- | ----------------------------------------- |
| 1 | #N | Title | ✅ VIABLE | `_ideia/viable/` | Issue OPEN, comment posted |
| 2 | #N | Title | ⏭️ DEFER | `_ideia/defer/` | Issue CLOSED with explanation |
| 3 | #N | Title | ❌ NOT FIT | `_ideia/notfit/` | Issue CLOSED with explanation |
| 4 | #N | Title | 🔁 EXISTS | `_ideia/notfit/` | Issue CLOSED with guidance |
| 5 | #N | Title | ❓ NEEDS DETAIL | `_ideia/viable/need_details/` | Issue OPEN, questions posted |
| 6 | #N | Title | 🎉 ALREADY DELIVERED | (closed) | Issue CLOSED, version + PR cited |
| 7 | #N | Title | 💤 DORMANT | (no file) | Silent skip — quarantine not met yet |
| 8 | #N | Title | 👤 SKIP_ASSIGNED | (no file) | Silent skip — has assignee |
| 9 | #N | Title | 🔗 SKIP_HAS_PR | (no file) | Silent skip — has open linked PR |
| 10 | #N | Title | ⏰ STALE NEED_DETAILS | `_ideia/notfit/stale/` | Issue CLOSED politely after 30d |
| 11 | #N | Title | ♻️ STALE DEFER | (re-classified) | Re-ran Phase 2; new verdict applied |
| 12 | #N | Title | 🗑️ CLOSED EXTERNALLY | (file deleted) | Idea file removed; issue closed elsewhere |
#### 3.1b — Viable Features Detail
For each VIABLE feature, provide a brief paragraph:
- What was found during research
- The proposed approach
- Key risks or unknowns
- Which reference repositories were most useful
#### 3.1c — Issues Requiring Author Feedback
For features marked ❓ NEEDS DETAIL, list:
- What specific information is missing
- What examples or repository references would help
#### 3.1d — Ask for User Confirmation
End the report with:
> **Ready to proceed with implementation?**
>
> - Reply **"sim"** or **"yes"** to generate full implementation plans for all VIABLE features.
> - Reply with specific issue numbers to select only certain features.
> - Reply **"não"** or **"no"** to stop here.
---
## Phase 4 — Plan: Generate Implementation Plans (after user says "yes")
> **⚠️ Do NOT enter this phase without explicit user approval from Phase 3.**
### 4.1 Create Task Directory
```bash
mkdir -p <project_root>/_tasks/features-vX.Y.Z/
```
### 4.2 Generate One Implementation Plan Per Feature
For each VIABLE feature approved by the user, create:
**Filename**: `_tasks/features-vX.Y.Z/<NUMBER>-<kebab-case-title>.plan.md`
```markdown
# Implementation Plan: <Feature Title>
> Issue: #<NUMBER>
> Idea: [\_ideia/viable/<NUMBER>-title.md](../../_ideia/viable/<NUMBER>-title.md)
> Requirements: [\_ideia/viable/<NUMBER>-title.requirements.md](../../_ideia/viable/<NUMBER>-title.requirements.md)
> Branch: `release/vX.Y.Z`
## Overview
<Brief description of what will be built>
## Pre-Implementation Checklist
- [ ] Read all related source files listed below
- [ ] Confirm no conflicts with in-flight PRs
- [ ] Verify database migration numbering
## Implementation Steps
### Step 1: <Title>
**Files:**
- `path/to/file.ts` — <what to change>
**Details:**
<Detailed description of the change, including code patterns to follow, function signatures, etc.>
### Step 2: <Title>
...
### Step N: Tests
**New test files:**
- `tests/unit/<test-file>.test.mjs` — <what to test>
**Test cases:**
- [ ] <test case 1>
- [ ] <test case 2>
### Step N+1: i18n
**Translation keys to add:**
- `<namespace>.<key>` — "<English value>"
### Step N+2: Documentation
- [ ] Update CHANGELOG.md
- [ ] Update relevant docs/ files
## Verification Plan
1. Run `npm run build` — must pass
2. Run `npm test` — all tests must pass
3. Run `npm run lint` — no new errors
4. <Manual verification steps>
## Commit Plan
```
feat: <description> (#<NUMBER>)
```
```
### 4.3 Present Plans for Final Approval
Present a summary of all generated plans:
> **Implementation plans generated:**
>
> | # | Feature | Plan File | Steps | Effort |
> | --- | ------- | ---------------------------------------- | ------- | ------ |
> | 1 | <title> | `_tasks/features-vX.Y.Z/N-title.plan.md` | N steps | Medium |
>
> Reply **"sim"** or **"yes"** to begin implementation of all features.
> Reply with specific issue numbers to implement only certain ones.
---
## Phase 5 — Execute: Implement the Plans (after user says "yes")
> **⚠️ Do NOT enter this phase without explicit user approval from Phase 4.**
### 5.1 Implement Each Feature
For each approved plan, execute it step by step:
1. **Follow the plan** — implement exactly as specified in the `.plan.md` file
2. **Build** — Run `npm run build` after each feature to verify compilation
3. **Test** — Run `npm test` to ensure no regressions
4. **Commit** — Commit with: `feat: <description> (#<NUMBER>)`
5. **Update the plan** — Mark completed steps with `[x]` in the plan file
6. **Continue** — Move to the next feature (do NOT switch branches)
### 5.2 Respond to Authors (Update Viable Issues)
For each implemented feature, **close the issue with a final comment**:
````markdown
✅ **Implemented in `release/vX.Y.Z`!**
Hi @<author>! Great news — your feature request has been implemented! 🎉
**What was done:**
- <bullet list of what was built>
**How to try it:**
```bash
git fetch origin && git checkout release/vX.Y.Z
npm install && npm run dev
```
````
This will be included in the upcoming **vX.Y.Z** release. Feel free to reopen if you spot any issues! 🚀
````
```bash
gh issue close <NUMBER> --repo <owner>/<repo> --comment "<comment above>"
````
Then **DELETE the idea file** — it has served its purpose:
```bash
# ✅ Implemented files are DELETED (not moved)
rm _ideia/viable/<NUMBER>-<title>.md
rm _ideia/viable/<NUMBER>-<title>.requirements.md # if exists
```
> **Why delete?** `viable/` only holds features that still NEED to be done. Once implemented, the commit history and CHANGELOG are the source of truth. Keeping the file would be confusing.
### 5.3 Finalize & Push
After implementing all approved features:
1. **Update CHANGELOG.md** on the release branch with all new feature entries
2. Push the release branch: `git push origin release/vX.Y.Z`
3. Run `/generate-release` workflow Phase 1 steps 710 (tests → commit → push → open PR to main → wait for user)
### 5.4 Final Summary Report
Present a final summary report to the user:
| Issue | Title | Verdict | Action | Commit |
| ----- | ----- | --------------- | -------------------------------------------------- | --------- |
| #N | Title | ✅ Implemented | Issue closed, idea file deleted | `abc1234` |
| #N | Title | ⏭️ Deferred | Issue closed + saved in `_ideia/defer/` | — |
| #N | Title | ❌ Not Fit | Issue closed + saved in `_ideia/notfit/` | — |
| #N | Title | 🔁 Exists | Issue closed + saved in `_ideia/notfit/` | — |
| #N | Title | ❓ Needs Detail | Issue OPEN, moved to `_ideia/viable/need_details/` | — |
Include all counters from `_ideia/_triage.json` `counts` field plus:
- Total features harvested (= `counts.total_fetched`)
- Total absorbed and processed (= `counts.absorb`)
- Total dormant (skipped quarantine) (= `counts.dormant`)
- Total already-delivered (closed with version reference) (= `counts.already_delivered`)
- Total skipped (assigned + has PR) (= `counts.skip_assigned + counts.skip_has_pr`)
- Total stale need_details (closed after 30d silence) (= `counts.stale_need_details`)
- Total stale defer (re-classified) (= `counts.stale_defer`)
- Total cleaned up (closed externally) (= `counts.closed_externally`)
- Total ideas cataloged (`viable/need_details/` + `defer/` + `notfit/`)
- Total features implemented (idea files deleted, issues closed)
- Total issues closed
- Total issues left open
- Test results (pass/fail count)
- All `warnings[]` entries from `_triage.json`

View File

@@ -1,881 +0,0 @@
---
description: Analyze open feature request issues, implement viable ones on dedicated branches, and respond to authors
---
# /implement-features — Feature Request Harvest, Research & Implementation Workflow
## Overview
A **5-phase** workflow that systematically harvests feature requests from GitHub issues, creates structured idea files, researches solutions across the internet and Git repositories, presents a consolidated report for user approval, then generates detailed implementation plans and executes them.
**Output directory structure:**
```
_ideia/
├── viable/ # Features approved for implementation
│ ├── need_details/ # ❓ Good idea but waiting for author clarification (issues stay OPEN)
│ │ └── 1015-warp-terminal-mitm.md
│ ├── 1046-native-playground.md # ✅ Ready — researched and planned
│ └── 1046-native-playground.requirements.md
├── defer/ # ⏭️ Good ideas deferred for future cycles (issues CLOSED)
│ └── 1041-smart-auto-combos.md
└── notfit/ # ❌ Out of scope / already exists (issues CLOSED)
└── 945-telegram-integration.md
_tasks/features-vX.Y.Z/ # Implementation plans (per-release)
└── 1046-native-playground.plan.md
```
> **LIFECYCLE RULE:** `viable/` files are **DELETED** once the feature is implemented — they are not moved. Only unimplemented features live in `viable/` (or `viable/need_details/`). Files in `defer/` and `notfit/` remain as permanent reference.
> **BRANCH RULE**: All implementation work MUST happen on the current `release/vX.Y.Z` branch. Never create separate `feat/` branches. If no release branch exists yet, create one first using `/generate-release` Phase 1 steps 15.
---
## Phase 0 — Pre-flight Triage (NEW)
Before harvesting, run a deterministic triage script that decides which issues to absorb, which to leave dormant, which were already delivered, and which need lifecycle cleanup. This phase replaces the old Phase 1.1/1.2 and gates the rest of the workflow on the triage JSON.
### 0.1 Identify the Repository
// turbo
- Run: `git -C <project_root> remote get-url origin` to extract owner/repo.
### 0.2 Ensure Release Branch Exists
// turbo
```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.
### 0.3 Run feature-triage script
// turbo
```bash
node scripts/features/feature-triage.mjs \
--owner <OWNER> --repo <REPO> \
--output _ideia/_triage.json \
--verbose
```
Read `_ideia/_triage.json` into context. Buckets present: `absorb`, `dormant`, `already_delivered`, `skip_assigned`, `skip_has_pr`, `stale_need_details`, `stale_defer`, `closed_externally`.
> **Defaults** (overridable via flags or env vars):
> quarantine=14d, override-thumbs=5, override-commenters=3, stale-needs=30d, stale-defer=90d.
### 0.4 Apply deterministic actions (in this exact order)
For each bucket, perform the action described. **Order matters**`already_delivered` runs first because its close action precludes any other processing.
1. **`already_delivered`** — pick comment template based on `version_source` + `confidence`:
- `version_source == "tag_after_merge"` AND `confidence == "high"` → template **HIGH** (see Phase 2.5.3)
- `version_source == "tag_after_merge"` AND `confidence == "medium"` → template **MEDIUM** (asks for verification)
- `version_source == "branch_unreleased"` → template **unreleased**
- Then `gh issue close <N> --repo <O>/<R> --comment "<rendered template>"`
2. **`closed_externally`** — for each entry, `rm` the file (log to stderr what was removed).
3. **`stale_need_details`** — for each entry, post the stale template (see Phase 2.5.3), close the issue, then `mv <file> _ideia/notfit/stale/`.
4. **`skip_assigned` / `skip_has_pr`** — no action (silent skip).
5. **`dormant`** — no action (total silence; the JSON records the decision for internal visibility only).
6. **`warnings`** — log each warning to stderr; include them in the Phase 3 report.
> **Note**: issues with `confidence == "low"` are not in `already_delivered` — they appear in `absorb`/`dormant` with a warning, so step 0.4.1 never sees them.
### 0.5 Incremental re-sync for existing idea files in `absorb`
For each `absorb` entry where `existing_idea_file != null`, the script already updated the file via `resync.mjs`. No additional action needed in this step — but verify with `git status` that only expected idea files were modified.
If the entry has `needs_reclassification: true`, move the file out of `_ideia/viable/need_details/` back to `_ideia/` root for Phase 2 to re-classify.
---
## Phase 1 — Harvest: Collect & Catalog Feature Ideas
> Phases 1.1 and 1.2 are now handled by Phase 0.1 and 0.2.
### 1.3 Process triage results
Instead of re-fetching every open issue, use the `_ideia/_triage.json` produced by Phase 0.3. Iterate only over:
- `buckets.absorb[]` — issues that passed quarantine (age ≥ 14d OR engagement override)
- `buckets.stale_defer[]` — deferred ideas due for re-evaluation
For each `absorb` entry, the JSON already includes `number`, `title`, `author`, `created_at`, `age_days`, `thumbs`, `commenters`, `labels`, `existing_idea_file`, and `last_synced_comment_id`. Fetch the full issue body only if needed for Phase 2 research.
For each `stale_defer` entry, **treat it as a fresh idea**:
- Re-run Phase 2 (Research) from scratch — codebase may have evolved in 90+ days, opening new architectural possibilities
- Re-run Phase 2.5 (Organize & Respond) and let the new verdict decide:
- If still **DEFER** → stay in `_ideia/defer/`, but bump `snapshot.classified_at` so the next check is 90 days from now
- If **VIABLE** → move to `_ideia/viable/`, post the "we're picking this back up" variant of the VIABLE comment
- If **NOT FIT** → move to `_ideia/notfit/`, close issue with NOT FIT template
You may batch `gh issue view` calls in parallel (up to 4 at a time) when fresh fetches are required.
> Old behavior (fetching every open issue with `gh issue list`) is replaced by Phase 0.3.
### 1.4 Create Idea Files (initially in `_ideia/` root)
> **If `existing_idea_file != null` in the triage JSON**, the file was already re-synced in Phase 0.5 — skip the create/update step and proceed to Phase 2 for that issue.
>
> **If `needs_reclassification == true`**, the file was moved back to `_ideia/` root in Phase 0.5 — treat it as a fresh idea for the rest of the run.
For each feature request, create a structured idea file in `<project_root>/_ideia/`:
**Filename convention**: `<NUMBER>-<kebab-case-short-title>.md`
Example: `1046-native-playground.md`, `1041-smart-auto-combos.md`
#### 1.4a — If the idea file does NOT exist yet, create it:
```markdown
---
issue: <NUMBER>
last_synced_at: <ISO_TIMESTAMP_NOW>
last_synced_comment_id: <MAX_COMMENT_ID_OR_0>
snapshot:
thumbs: <THUMBS_COUNT>
commenters: <COMMENTERS_COUNT>
age_days: <AGE_DAYS>
labels: [<LABEL_LIST>]
state: open
classified_at: <ISO_TIMESTAMP_NOW>
---
# Feature: <Title from Issue>
> GitHub Issue: #<NUMBER> — opened by @<author> on <date>
> Status: 📋 Cataloged | Priority: TBD
## 📝 Original Request
<Paste the FULL issue body here, preserving all formatting, images, and code blocks>
## 💬 Community Discussion
<Summarize ALL comments chronologically, noting who said what and any decisions or objections raised>
### Participants
- @<author> — Original requester
- @<commenter1> — <brief role/opinion>
- ...
### Key Points
- <bullet list of the most important discussion points>
- <agreements reached>
- <objections raised>
## 🎯 Refined Feature Description
<YOUR interpretation and enrichment of the feature request. Expand on what was asked, fill in logical gaps, provide concrete examples of how it would work. This section should be MORE detailed and clearer than the original request.>
### What it solves
- <problem 1>
- <problem 2>
### How it should work (high level)
1. <step 1>
2. <step 2>
3. ...
### Affected areas
- <list of codebase areas, modules, files likely affected>
## 📎 Attachments & References
- <any image URLs, mockup links, or external references from the issue>
## 🔗 Related Ideas
- <links to related \_ideia/ files if any overlap found>
```
#### 1.4b — If the idea file ALREADY exists, update it:
- Append new comments from the issue to the **Community Discussion** section.
- Update the **Refined Feature Description** if new information changes the understanding.
- Add any new **Related Ideas** cross-references found.
- **Do NOT overwrite** existing content — append and enrich it.
### 1.5 Cross-Reference & Deduplication
After processing all issues:
- Scan all `_ideia/*.md` files for overlapping features.
- If two features are substantially the same, add `🔗 Related Ideas` cross-references to both.
- If one is a strict subset of another, note it in the smaller file: `> This feature is a subset of #<OTHER_NUMBER>. Consider implementing together.`
---
## Phase 2 — Research: Find Solutions & Build Requirements
For each cataloged idea that is **viable** (aligns with the project's goals):
### 2.1 Viability Pre-Check
Before investing in research, quickly assess:
- [ ] Does this feature align with the project's goals and architecture?
- [ ] Is it technically feasible with the current codebase?
- [ ] Does it duplicate existing functionality?
- [ ] Would it introduce breaking changes or security risks?
- [ ] Is there enough detail to understand what's needed?
**Verdict options:**
| Verdict | When | Action |
| --------------------- | ------------------------------------- | --------------------------- |
| ✅ **VIABLE** | Good idea, enough context | Proceed to Research |
| ❓ **NEEDS DETAIL** | Good idea, insufficient spec | Skip research, ask author |
| ⏭️ **DEFER** | Good idea, too complex for this cycle | Catalog only, skip research |
| ❌ **NOT FIT** | Doesn't fit the project | Explain why |
| 🔁 **ALREADY EXISTS** | Feature already implemented | Point to existing feature |
### 2.2 Internet Research (for VIABLE features)
For each viable feature, perform systematic research:
**Step 1 — Web search for similar implementations:**
```
WebSearch("how to implement <feature description> in <tech stack>")
WebSearch("<feature keyword> implementation nextjs typescript 2025 2026")
WebSearch("<feature keyword> open source library npm")
```
**Step 2 — Find reference Git repositories:**
```
WebSearch("site:github.com <feature keyword> <tech stack> stars:>100")
WebSearch("github <feature keyword> implementation recently updated 2026")
```
- Find **up to 10 relevant repositories**, sorted by most recently updated.
- For each repository:
- Note the repo URL, star count, last commit date
- Read its README and relevant source files via `WebFetch`
- Extract the architectural approach, patterns used, and key code snippets
**Step 3 — Read API docs and standards:**
If the feature involves an external API, protocol, or standard:
- Find and read the official documentation
- Note version requirements, authentication patterns, rate limits
### 2.3 Create Requirements File
For each researched feature, create a requirements file alongside its idea file:
**Filename**: `<NUMBER>-<kebab-case-short-title>.requirements.md`
```markdown
# Requirements: <Feature Title>
> Feature Idea: [#<NUMBER>](./<NUMBER>-<kebab-case-short-title>.md)
> Research Date: <YYYY-MM-DD>
> Verdict: ✅ VIABLE
## 🔍 Research Summary
<Brief summary of what was found during research>
## 📚 Reference Implementations
| # | Repository | Stars | Last Updated | Approach | Relevance |
| --- | ---------------- | ----- | ------------ | -------- | ------------ |
| 1 | [repo/name](url) | ⭐ N | YYYY-MM-DD | <brief> | High/Med/Low |
| 2 | ... | | | | |
### Key Patterns Found
- <pattern 1 with code snippet or link>
- <pattern 2>
## 📐 Proposed Solution Architecture
### Approach
<Describe the chosen approach based on research findings>
### New Files
| File | Purpose |
| --------------------- | ------------- |
| `path/to/new/file.ts` | <description> |
### Modified Files
| File | Changes |
| -------------------------- | -------------- |
| `path/to/existing/file.ts` | <what changes> |
### Database Changes
- <migrations needed, if any>
### API Changes
- <new/modified endpoints, if any>
### UI Changes
- <new/modified pages/components, if any>
## ⚙️ Implementation Effort
- **Estimated complexity**: Low / Medium / High / Very High
- **Estimated files changed**: ~N
- **Dependencies needed**: <new npm packages, if any>
- **Breaking changes**: Yes/No — <details>
- **i18n impact**: <number of new translation keys>
- **Test coverage needed**: <brief description>
## ⚠️ Open Questions
- <question 1>
- <question 2>
## 🔗 External References
- <documentation URLs>
- <API references>
```
---
## Phase 2.5 — Organize & Respond: Sort Files and Post GitHub Comments
### 2.5.1 Create Directory Structure
// turbo
```bash
mkdir -p <project_root>/_ideia/viable
mkdir -p <project_root>/_ideia/viable/need_details
mkdir -p <project_root>/_ideia/defer
mkdir -p <project_root>/_ideia/notfit
```
### 2.5.2 Move Idea Files to Category Subdirectories
After classification, move EVERY idea file to its correct subdirectory:
```bash
# ✅ VIABLE — move idea + requirements files
mv _ideia/<NUMBER>-*.md _ideia/viable/
mv _ideia/<NUMBER>-*.requirements.md _ideia/viable/
# ❓ NEEDS DETAIL — viable but waiting for author response
mv _ideia/<NUMBER>-*.md _ideia/viable/need_details/
# ⏭️ DEFER — move idea files only
mv _ideia/<NUMBER>-*.md _ideia/defer/
# ❌ NOT FIT & 🔁 ALREADY EXISTS — move idea files only
mv _ideia/<NUMBER>-*.md _ideia/notfit/
```
No files should remain in `_ideia/` root after this step (except subdirectories).
### 2.5.3 Post GitHub Comments by Category
**Each category has a specific comment template and action:**
---
#### For 🔁 ALREADY EXISTS — Comment + CLOSE issue
// turbo
The feature already exists in the system. Explain WHERE it is and HOW to use it.
```markdown
Hi @<author>! Thanks for the suggestion! 🙏
Great news — this functionality **already exists** in OmniRoute:
**📍 Where to find it:** <exact dashboard path or settings location>
**🔧 How to use it:**
1. <step 1>
2. <step 2>
3. <step 3>
If you have any trouble finding or using it, feel free to ask in a Discussion. We're always happy to help!
Closing this as the feature is already available. 🎉
```
```bash
gh issue close <NUMBER> --repo <owner>/<repo> --comment "<comment above>"
```
---
#### For ⏭️ DEFER — Comment + CLOSE issue
// turbo
Thank the user, explain the idea was cataloged, and that we'll study it before implementing.
```markdown
Hi @<author>! Thanks for this thoughtful feature request! 🙏
We really appreciate the detailed proposal. We've **cataloged your idea** and it's now part of our improvement backlog.
Due to the **significant architectural impact** of this feature, we'll need to conduct thorough use-case studies and architectural analysis before we start development. This ensures we build it right and don't introduce regressions.
**What happens next:**
- Your idea is saved in our internal feature backlog
- We'll conduct architecture studies when this area is prioritized
- We'll notify you here when development begins
Thank you for contributing to OmniRoute's roadmap! Your input helps shape the product. 🚀
```
```bash
gh issue close <NUMBER> --repo <owner>/<repo> --comment "<comment above>"
```
---
#### For ❌ NOT FIT — Comment + CLOSE issue
// turbo
Politely explain why the feature doesn't fit the project scope.
```markdown
Hi @<author>! Thanks for the suggestion! 🙏
After careful analysis, we've determined that this feature **falls outside OmniRoute's core scope** as a proxy/router.
**Reason:** <explain why — e.g., "Telegram integration belongs in the application/orchestrator layer that consumes OmniRoute's API, not inside the router itself.">
**Alternative:** <suggest an alternative approach if possible>
We appreciate you thinking of ways to improve OmniRoute! If you'd like to discuss this further, feel free to open a Discussion. 🙏
```
```bash
gh issue close <NUMBER> --repo <owner>/<repo> --comment "<comment above>"
```
---
#### For ❓ NEEDS DETAIL — Comment (keep OPEN)
// turbo
Ask for the specific missing details needed.
```markdown
Hi @<author>! Thanks for the feature request — it's an interesting idea and we'd love to explore it further. 🙏
To move forward, we need a few more details:
1. <specific question 1>
2. <specific question 2>
3. <specific question 3>
If you know of any **open-source projects or repositories** that implement something similar, please share links — it would help us design the best solution.
Looking forward to your response! 🚀
```
---
#### For ✅ VIABLE — Comment (keep OPEN)
// turbo
Thank the user, confirm we've cataloged their idea, and explain it may be implemented in future versions.
```markdown
Hi @<author>! Thanks for the great feature suggestion! 🙏
We've analyzed your request and it aligns well with OmniRoute's roadmap. We've **cataloged this feature** and it's in our implementation backlog.
**Status:** 📋 Cataloged for future implementation
This feature may be included in upcoming releases. We'll **respond to this issue and tag you** as soon as implementation begins so you can test it.
Thank you for helping improve OmniRoute! 🚀
```
**⚠️ Do NOT close viable issues — they remain OPEN for tracking.**
---
#### For 🎉 ALREADY DELIVERED — HIGH confidence
// turbo
Used when triage `confidence == "high"` and `version_source == "tag_after_merge"`. Close the issue with a celebratory comment pointing at the shipped version + PR.
```markdown
Hi @<author>! 🎉
Great news — this functionality was already delivered in version **<VERSION>** through PR #<PR_NUMBER> (<PR_TITLE>).
**How to try it:**
\`\`\`bash
git pull origin main && npm install
npm run dev
\`\`\`
If your use case is slightly different from what was shipped, feel free to reopen this issue or open a new one with the specific gap. Thanks for helping shape OmniRoute! 🚀
```
```bash
gh issue close <NUMBER> --repo <owner>/<repo> --comment "<comment above>"
```
---
#### For 🎉 ALREADY DELIVERED — MEDIUM confidence
// turbo
Used when triage `confidence == "medium"`. More cautious — asks the author to verify.
```markdown
Hi @<author>! 🎉
This functionality appears to have been delivered in version **<VERSION>** based on related changes (PR #<PR_NUMBER>, CHANGELOG, commit history).
Could you please verify if the current release covers your request? If yes, feel free to close. If not, comment back with the gap and we'll reopen for further work.
**How to verify:**
\`\`\`bash
git pull origin main && npm install
\`\`\`
Thanks for contributing! 🚀
```
```bash
gh issue close <NUMBER> --repo <owner>/<repo> --comment "<comment above>"
```
---
#### For 🎉 ALREADY DELIVERED — branch_unreleased
// turbo
Used when `version_source == "branch_unreleased"` (regardless of confidence). The fix is on a release branch that hasn't been tagged yet.
```markdown
Hi @<author>! 🎉
This functionality has been implemented in the upcoming release (branch `release/<VERSION>`, PR #<PR_NUMBER>) and will ship in the next release.
You can already try it on the release branch:
\`\`\`bash
git fetch origin && git checkout release/<VERSION>
npm install && npm run dev
\`\`\`
Closing now since the work is done — feel free to reopen if you spot any gaps after testing. 🚀
```
```bash
gh issue close <NUMBER> --repo <owner>/<repo> --comment "<comment above>"
```
---
#### For ⏰ STALE NEED_DETAILS — Close after 30d without author reply
// turbo
Used for entries in `buckets.stale_need_details`. Polite close + invite to reopen + `mv` file to `notfit/stale/`.
```markdown
Hi @<author>! 🙏
Since we haven't heard back from you in about 30 days regarding the details we asked for, we're closing this issue to keep the backlog clean.
**No worries** — please feel free to **reopen** this issue whenever you have the details handy. Just click "Reopen" and reply with the missing information, and we'll pick it back up.
Thanks for thinking of OmniRoute! 🚀
```
```bash
gh issue close <NUMBER> --repo <owner>/<repo> --comment "<comment above>"
mkdir -p _ideia/notfit/stale
mv <FILE_PATH> _ideia/notfit/stale/
```
---
## Phase 3 — Report: Present Findings to User
### 3.1 🛑 MANDATORY STOP — Present Consolidated Report
After completing Phase 1, Phase 2, and Phase 2.5, **STOP and present the following report** in the chat. Do NOT proceed to implementation.
Present a structured report containing:
#### 3.1a — Feature Summary Table
| # | Issue | Title | Verdict | Location | Action |
| --- | ----- | ----- | --------------------- | ----------------------------- | ----------------------------------------- |
| 1 | #N | Title | ✅ VIABLE | `_ideia/viable/` | Issue OPEN, comment posted |
| 2 | #N | Title | ⏭️ DEFER | `_ideia/defer/` | Issue CLOSED with explanation |
| 3 | #N | Title | ❌ NOT FIT | `_ideia/notfit/` | Issue CLOSED with explanation |
| 4 | #N | Title | 🔁 EXISTS | `_ideia/notfit/` | Issue CLOSED with guidance |
| 5 | #N | Title | ❓ NEEDS DETAIL | `_ideia/viable/need_details/` | Issue OPEN, questions posted |
| 6 | #N | Title | 🎉 ALREADY DELIVERED | (closed) | Issue CLOSED, version + PR cited |
| 7 | #N | Title | 💤 DORMANT | (no file) | Silent skip — quarantine not met yet |
| 8 | #N | Title | 👤 SKIP_ASSIGNED | (no file) | Silent skip — has assignee |
| 9 | #N | Title | 🔗 SKIP_HAS_PR | (no file) | Silent skip — has open linked PR |
| 10 | #N | Title | ⏰ STALE NEED_DETAILS | `_ideia/notfit/stale/` | Issue CLOSED politely after 30d |
| 11 | #N | Title | ♻️ STALE DEFER | (re-classified) | Re-ran Phase 2; new verdict applied |
| 12 | #N | Title | 🗑️ CLOSED EXTERNALLY | (file deleted) | Idea file removed; issue closed elsewhere |
#### 3.1b — Viable Features Detail
For each VIABLE feature, provide a brief paragraph:
- What was found during research
- The proposed approach
- Key risks or unknowns
- Which reference repositories were most useful
#### 3.1c — Issues Requiring Author Feedback
For features marked ❓ NEEDS DETAIL, list:
- What specific information is missing
- What examples or repository references would help
#### 3.1d — Ask for User Confirmation
End the report with:
> **Ready to proceed with implementation?**
>
> - Reply **"sim"** or **"yes"** to generate full implementation plans for all VIABLE features.
> - Reply with specific issue numbers to select only certain features.
> - Reply **"não"** or **"no"** to stop here.
---
## Phase 4 — Plan: Generate Implementation Plans (after user says "yes")
> **⚠️ Do NOT enter this phase without explicit user approval from Phase 3.**
### 4.1 Create Task Directory
```bash
mkdir -p <project_root>/_tasks/features-vX.Y.Z/
```
### 4.2 Generate One Implementation Plan Per Feature
For each VIABLE feature approved by the user, create:
**Filename**: `_tasks/features-vX.Y.Z/<NUMBER>-<kebab-case-title>.plan.md`
```markdown
# Implementation Plan: <Feature Title>
> Issue: #<NUMBER>
> Idea: [\_ideia/viable/<NUMBER>-title.md](../../_ideia/viable/<NUMBER>-title.md)
> Requirements: [\_ideia/viable/<NUMBER>-title.requirements.md](../../_ideia/viable/<NUMBER>-title.requirements.md)
> Branch: `release/vX.Y.Z`
## Overview
<Brief description of what will be built>
## Pre-Implementation Checklist
- [ ] Read all related source files listed below
- [ ] Confirm no conflicts with in-flight PRs
- [ ] Verify database migration numbering
## Implementation Steps
### Step 1: <Title>
**Files:**
- `path/to/file.ts` — <what to change>
**Details:**
<Detailed description of the change, including code patterns to follow, function signatures, etc.>
### Step 2: <Title>
...
### Step N: Tests
**New test files:**
- `tests/unit/<test-file>.test.mjs` — <what to test>
**Test cases:**
- [ ] <test case 1>
- [ ] <test case 2>
### Step N+1: i18n
**Translation keys to add:**
- `<namespace>.<key>` — "<English value>"
### Step N+2: Documentation
- [ ] Update CHANGELOG.md
- [ ] Update relevant docs/ files
## Verification Plan
1. Run `npm run build` — must pass
2. Run `npm test` — all tests must pass
3. Run `npm run lint` — no new errors
4. <Manual verification steps>
## Commit Plan
```
feat: <description> (#<NUMBER>)
```
```
### 4.3 Present Plans for Final Approval
Present a summary of all generated plans:
> **Implementation plans generated:**
>
> | # | Feature | Plan File | Steps | Effort |
> | --- | ------- | ---------------------------------------- | ------- | ------ |
> | 1 | <title> | `_tasks/features-vX.Y.Z/N-title.plan.md` | N steps | Medium |
>
> Reply **"sim"** or **"yes"** to begin implementation of all features.
> Reply with specific issue numbers to implement only certain ones.
---
## Phase 5 — Execute: Implement the Plans (after user says "yes")
> **⚠️ Do NOT enter this phase without explicit user approval from Phase 4.**
### 5.1 Implement Each Feature
For each approved plan, execute it step by step:
1. **Follow the plan** — implement exactly as specified in the `.plan.md` file
2. **Build** — Run `npm run build` after each feature to verify compilation
3. **Test** — Run `npm test` to ensure no regressions
4. **Commit** — Commit with: `feat: <description> (#<NUMBER>)`
5. **Update the plan** — Mark completed steps with `[x]` in the plan file
6. **Continue** — Move to the next feature (do NOT switch branches)
### 5.2 Respond to Authors (Update Viable Issues)
For each implemented feature, **close the issue with a final comment**:
````markdown
✅ **Implemented in `release/vX.Y.Z`!**
Hi @<author>! Great news — your feature request has been implemented! 🎉
**What was done:**
- <bullet list of what was built>
**How to try it:**
```bash
git fetch origin && git checkout release/vX.Y.Z
npm install && npm run dev
```
````
This will be included in the upcoming **vX.Y.Z** release. Feel free to reopen if you spot any issues! 🚀
````
```bash
gh issue close <NUMBER> --repo <owner>/<repo> --comment "<comment above>"
````
Then **DELETE the idea file** — it has served its purpose:
```bash
# ✅ Implemented files are DELETED (not moved)
rm _ideia/viable/<NUMBER>-<title>.md
rm _ideia/viable/<NUMBER>-<title>.requirements.md # if exists
```
> **Why delete?** `viable/` only holds features that still NEED to be done. Once implemented, the commit history and CHANGELOG are the source of truth. Keeping the file would be confusing.
### 5.3 Finalize & Push
After implementing all approved features:
1. **Update CHANGELOG.md** on the release branch with all new feature entries
2. Push the release branch: `git push origin release/vX.Y.Z`
3. Run `/generate-release` workflow Phase 1 steps 710 (tests → commit → push → open PR to main → wait for user)
### 5.4 Final Summary Report
Present a final summary report to the user:
| Issue | Title | Verdict | Action | Commit |
| ----- | ----- | --------------- | -------------------------------------------------- | --------- |
| #N | Title | ✅ Implemented | Issue closed, idea file deleted | `abc1234` |
| #N | Title | ⏭️ Deferred | Issue closed + saved in `_ideia/defer/` | — |
| #N | Title | ❌ Not Fit | Issue closed + saved in `_ideia/notfit/` | — |
| #N | Title | 🔁 Exists | Issue closed + saved in `_ideia/notfit/` | — |
| #N | Title | ❓ Needs Detail | Issue OPEN, moved to `_ideia/viable/need_details/` | — |
Include all counters from `_ideia/_triage.json` `counts` field plus:
- Total features harvested (= `counts.total_fetched`)
- Total absorbed and processed (= `counts.absorb`)
- Total dormant (skipped quarantine) (= `counts.dormant`)
- Total already-delivered (closed with version reference) (= `counts.already_delivered`)
- Total skipped (assigned + has PR) (= `counts.skip_assigned + counts.skip_has_pr`)
- Total stale need_details (closed after 30d silence) (= `counts.stale_need_details`)
- Total stale defer (re-classified) (= `counts.stale_defer`)
- Total cleaned up (closed externally) (= `counts.closed_externally`)
- Total ideas cataloged (`viable/need_details/` + `defer/` + `notfit/`)
- Total features implemented (idea files deleted, issues closed)
- Total issues closed
- Total issues left open
- Test results (pass/fail count)
- All `warnings[]` entries from `_triage.json`

6
.gitignore vendored
View File

@@ -157,3 +157,9 @@ _ideia/_triage.json
# i18n audit artifact (generated by scripts/i18n/audit-dashboard-pages.mjs)
scripts/i18n/_audit.json
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/skills/implement-features/
.claude/commands/implement-features-cc.md

View File

@@ -4,6 +4,7 @@
### Added
- **feat(zed):** Zed IDE Docker support — when OmniRoute runs in Docker and Zed is on the host, the Import flow now returns a 422 with `zedDockerEnvironment: true` and the dashboard auto-expands a Manual Token Import panel (new `POST /api/providers/zed/manual-import` endpoint with Zod validation). Includes Docker detection utility (`/.dockerenv` + cgroup heuristics) and a setup guide at [`docs/providers/ZED-DOCKER.md`](docs/providers/ZED-DOCKER.md). ([#2306])
- **feat(workflow):** `/implement-features` gains pre-flight triage script (`scripts/features/feature-triage.mjs`) classifying open feature requests into 8 buckets — fresh issues (<14d) stay dormant to give the community time to react, engagement override (≥5 👍 or ≥3 unique non-bot commenters) absorbs early, already-delivered detection via merged PRs + CHANGELOG + git log closes issues with version + PR reference, stale `need_details/` (>30d) is closed politely, aged `defer/` (>90d) is re-evaluated, and externally-closed issues clean up `_ideia/` automatically. Idea files now carry a YAML frontmatter snapshot enabling incremental comment re-sync. 53 unit tests cover the new logic.
- **feat(providers):** add GitHub Models as a free provider — GPT-5, o-series, DeepSeek-R1, Llama 4, Grok 3 with GitHub PAT auth and dynamic model fetch from `api.github.com`. ([#2344](https://github.com/diegosouzapw/OmniRoute/pull/2344) — thanks @oyi77)
- **feat(providers):** add Hackclub AI as a free provider — 30+ models, no credit card required, optional API key auth with passthrough model support. ([#2339](https://github.com/diegosouzapw/OmniRoute/pull/2339) — thanks @oyi77)
@@ -20,6 +21,7 @@
- **feat(providers):** improve Cohere provider support, expanding models and accurately updating OpenAI context limits. ([#2313](https://github.com/diegosouzapw/OmniRoute/pull/2313) — thanks @backryun)
- **feat(claude-web):** implement session-based Claude Web executor with auto-refresh authentication — enables direct Claude Web API access without an API key. ([#2283](https://github.com/diegosouzapw/OmniRoute/pull/2283) — thanks @oyi77)
- **feat(skills):** add 5 CLI skill manifests + AgentSkills / OmniSkills dashboard pages — enables external AI agents to discover and invoke OmniRoute capabilities. ([#2284](https://github.com/diegosouzapw/OmniRoute/pull/2284))
- **feat(providers):** add llama.cpp as local provider — `llama-cpp` (alias `llamacpp`) added to `LOCAL_PROVIDERS` and `SELF_HOSTED_CHAT_PROVIDER_IDS`; default base URL `http://127.0.0.1:8080/v1`; no API key required; uses the default OpenAI-compatible executor ([#1980](https://github.com/diegosouzapw/OmniRoute/issues/1980))
- **feat(providers):** bulk add API keys with Single/Bulk tabs.
- **feat(provider):** add Gitlawb Opengateway provider (xiaomi-mimo + gmi-cloud) with hasFree flag support. ([#2314](https://github.com/diegosouzapw/OmniRoute/pull/2314) — thanks @oyi77)
- **feat(ui):** comprehensive dashboard UX rework including simple/advanced modes for RTK/Caveman, human-readable error badges, InfoTooltip/PresetSlider shared components, sidebar subtitles, and provider category filters. ([#2315](https://github.com/diegosouzapw/OmniRoute/pull/2315), [#2316](https://github.com/diegosouzapw/OmniRoute/pull/2316) — thanks @dhaern, @oyi77)

View File

@@ -1,4 +1,4 @@
import { apiFetch } from "../api.mjs";
import { apiFetch, isServerUp } from "../api.mjs";
import { emit } from "../output.mjs";
import { printHeading } from "../io.mjs";
import { getAvailableProviderCategories, loadAvailableProviders } from "../provider-catalog.mjs";
@@ -7,8 +7,10 @@ import {
findProviderConnection,
getProviderApiKey,
listProviderConnections,
updateProviderApiKey,
updateProviderTestResult,
} from "../provider-store.mjs";
import { encryptCredential } from "../encryption.mjs";
import { openOmniRouteDb } from "../sqlite.mjs";
import { t } from "../i18n.mjs";
@@ -301,6 +303,198 @@ export async function runValidateCommand(opts = {}) {
}
}
export async function runProvidersRotateCommand(selector, opts = {}) {
if (!selector) {
console.error("Provider connection id or name is required.");
return 2;
}
// --- Resolve connection ---
const { db } = await openOmniRouteDb();
let connection;
try {
connection = findProviderConnection(db, selector);
} finally {
db.close();
}
if (!connection) {
console.error(`Provider connection not found: ${selector}`);
return 2;
}
// --- OAuth short-circuit ---
if (opts.oauth || connection.authType !== "apikey") {
console.log(t("providers.rotate.oauthHint", { provider: connection.provider }));
return 0;
}
// --- Source new key ---
let newKey;
if (opts.fromEnv) {
newKey = process.env[opts.fromEnv];
if (!newKey) {
console.error(t("providers.rotate.envVarEmpty", { var: opts.fromEnv }));
return 2;
}
} else if (opts.newKey) {
newKey = opts.newKey;
} else {
// Interactive prompt (echo-off not strictly needed for a key value, but best practice)
const readline = await import("node:readline");
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
newKey = await new Promise((resolve) =>
rl.question(`New API key for ${connection.name}: `, (a) => {
rl.close();
resolve(a.trim());
})
);
if (!newKey) {
console.error("No key provided.");
return 2;
}
}
// --- Dry-run ---
if (opts.dryRun) {
console.log(
t("providers.rotate.dryRunResult", { name: connection.name, id: connection.id.slice(0, 8) })
);
return 0;
}
// --- Confirm ---
if (!opts.yes) {
const readline = await import("node:readline");
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise((resolve) =>
rl.question(
t("providers.rotate.confirmPrompt", {
name: connection.name,
id: connection.id.slice(0, 8),
}),
resolve
)
);
rl.close();
if (!/^y(es|s)?$/i.test(answer)) {
console.log(t("common.cancelled"));
return 0;
}
}
// --- Write ---
const serverUp = await isServerUp();
if (serverUp) {
try {
const res = await apiFetch(`/api/providers/${encodeURIComponent(connection.id)}`, {
method: "PATCH",
body: {
apiKey: newKey,
testStatus: "unknown",
lastError: null,
rateLimitedUntil: null,
backoffLevel: 0,
},
retry: false,
acceptNotOk: true,
});
if (!res.ok) {
console.error(t("common.error", { message: `HTTP ${res.status}` }));
return 1;
}
} catch {
// Fall through to direct DB write
const { db: db2 } = await openOmniRouteDb();
try {
updateProviderApiKey(db2, connection.id, encryptCredential(newKey));
} finally {
db2.close();
}
}
} else {
const { db: db2 } = await openOmniRouteDb();
try {
updateProviderApiKey(db2, connection.id, encryptCredential(newKey));
} finally {
db2.close();
}
}
console.log(
t("providers.rotate.success", { name: connection.name, id: connection.id.slice(0, 8) })
);
// --- Post-rotation test ---
if (!opts.skipTest) {
const { db: db3 } = await openOmniRouteDb();
try {
const fresh = findProviderConnection(db3, connection.id);
if (fresh) {
const result = await runProviderTest(db3, fresh);
if (result.valid) {
console.log(t("providers.rotate.testPassed"));
} else {
console.error(t("providers.rotate.testFailed", { error: result.error }));
}
}
} finally {
db3.close();
}
}
return 0;
}
export async function runProvidersStatusCommand(opts = {}) {
const serverUp = await isServerUp();
if (!serverUp) {
console.error(t("providers.status.requiresServer"));
return 3;
}
const res = await apiFetch("/api/providers/expiration", { acceptNotOk: true, retry: false });
if (!res.ok) {
console.error(t("common.error", { message: `HTTP ${res.status}` }));
return 1;
}
const data = await res.json();
const list = data.list || [];
// Optional provider filter
const filter = opts.provider ? String(opts.provider).toLowerCase() : null;
const rows = filter ? list.filter((item) => item.provider?.toLowerCase().includes(filter)) : list;
if (opts.json || opts.output === "json") {
console.log(JSON.stringify({ count: rows.length, connections: rows }, null, 2));
return 0;
}
if (rows.length === 0) {
console.log(t("providers.status.noData"));
return 0;
}
console.log(t("providers.status.header"));
for (const item of rows) {
const shortId = (item.connectionId || item.id || "").slice(0, 8);
const expiry = item.expiresAt ? new Date(item.expiresAt).toLocaleDateString() : "-";
const expiryStatus = item.status || "unknown";
const testStatus = item.testStatus || "unknown";
const cooldown = item.rateLimitedUntil ? new Date(item.rateLimitedUntil).toLocaleString() : "-";
const expiryColor = statusColor(expiryStatus);
const testColor = statusColor(testStatus);
console.log(
`${shortId.padEnd(10)} ${String(item.provider || "").padEnd(14)} ${String(item.name || "").padEnd(24)} ` +
`${expiry.padEnd(12)} ${expiryColor}${expiryStatus.padEnd(8)}\x1b[0m ` +
`${testColor}${testStatus.padEnd(12)}\x1b[0m ${cooldown}`
);
}
return 0;
}
export function registerProviders(program) {
const providers = program.command("providers").description(t("providers.title"));
@@ -357,6 +551,35 @@ export function registerProviders(program) {
if (exitCode !== 0) process.exit(exitCode);
});
providers
.command("rotate <idOrName>")
.description(t("providers.rotate.description"))
.option("--new-key <key>", t("providers.rotate.newKeyOpt"))
.option("--from-env <VAR>", t("providers.rotate.fromEnvOpt"))
.option("--oauth", t("providers.rotate.oauthOpt"))
.option("--yes", t("common.yesOpt"))
.option("--skip-test", t("providers.rotate.skipTestOpt"))
.option("--dry-run", t("providers.rotate.dryRunOpt"))
.action(async (idOrName, opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runProvidersRotateCommand(idOrName, {
...opts,
output: globalOpts.output,
});
if (exitCode !== 0) process.exit(exitCode);
});
providers
.command("status")
.description(t("providers.status.description"))
.option("--provider <name>", t("providers.status.providerOpt"))
.option("--json", "Print machine-readable JSON")
.action(async (opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runProvidersStatusCommand({ ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});
extendProvidersMetrics(providers);
}

View File

@@ -6,6 +6,7 @@ import { platform } from "node:os";
import { t } from "../i18n.mjs";
import { writePidFile, cleanupPidFile, waitForServer } from "../utils/pid.mjs";
import { ServerSupervisor, detectMitmCrash } from "../runtime/processSupervisor.mjs";
import { isTermux } from "../../../scripts/build/postinstallSupport.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, "..", "..", "..");
@@ -330,7 +331,7 @@ async function onReady(dashboardPort, apiPort, noOpen) {
\x1b[2m Press Ctrl+C to stop\x1b[0m
`);
if (!noOpen) {
if (!noOpen && !isTermux()) {
try {
const open = await import("open");
await open.default(dashboardUrl);

View File

@@ -60,6 +60,28 @@
},
"metric_single": {
"description": "Get a single metric value for a specific connection"
},
"rotate": {
"description": "Rotate the upstream API key for a provider connection",
"newKeyOpt": "New API key value (avoid: prefer --from-env)",
"fromEnvOpt": "Read new key from environment variable VAR",
"oauthOpt": "Trigger OAuth re-authentication flow instead",
"skipTestOpt": "Skip post-rotation connectivity test",
"dryRunOpt": "Print what would change without writing",
"confirmPrompt": "Replace API key for connection \"{name}\" ({id})? [y/N] ",
"dryRunResult": "[dry-run] Would rotate key for \"{name}\" ({id}). No changes made.",
"oauthHint": "OAuth connection — run: omniroute oauth {provider}",
"envVarEmpty": "Environment variable {var} is not set or is empty.",
"success": "Key rotated for \"{name}\". Run `providers test {id}` to verify.",
"testPassed": "Post-rotation test passed.",
"testFailed": "Post-rotation test failed: {error}"
},
"status": {
"description": "Show key health for all provider connections (age, expiry, cooldown)",
"providerOpt": "Filter by provider name",
"header": "ID Provider Name Expiry Status Test Status Cooldown Until",
"noData": "No provider connection data available.",
"requiresServer": "providers status requires the OmniRoute server to be running."
}
},
"keys": {

View File

@@ -60,6 +60,28 @@
},
"metric_single": {
"description": "Obter um único valor de métrica para uma conexão específica"
},
"rotate": {
"description": "Rotacionar a chave de API upstream de uma conexão de provedor",
"newKeyOpt": "Novo valor de chave de API (evite: prefira --from-env)",
"fromEnvOpt": "Ler nova chave da variável de ambiente VAR",
"oauthOpt": "Iniciar fluxo de reautenticação OAuth",
"skipTestOpt": "Pular teste de conectividade pós-rotação",
"dryRunOpt": "Exibir o que seria alterado sem gravar",
"confirmPrompt": "Substituir a chave de API da conexão \"{name}\" ({id})? [s/N] ",
"dryRunResult": "[dry-run] Rotacionaria a chave para \"{name}\" ({id}). Nenhuma alteração feita.",
"oauthHint": "Conexão OAuth — execute: omniroute oauth {provider}",
"envVarEmpty": "A variável de ambiente {var} não está definida ou está vazia.",
"success": "Chave rotacionada para \"{name}\". Execute `providers test {id}` para verificar.",
"testPassed": "Teste pós-rotação aprovado.",
"testFailed": "Teste pós-rotação falhou: {error}"
},
"status": {
"description": "Exibir saúde das chaves de todas as conexões de provedores (idade, validade, cooldown)",
"providerOpt": "Filtrar por nome do provedor",
"header": "ID Provedor Nome Validade Status Status Teste Cooldown Até",
"noData": "Nenhum dado de conexão de provedor disponível.",
"requiresServer": "providers status requer o servidor OmniRoute em execução."
}
},
"keys": {

View File

@@ -251,6 +251,32 @@ export function removeProviderConnectionByProvider(db, provider) {
return result.changes;
}
/**
* Replace the encrypted API key for a connection and clear any cooldown state.
* `encryptedKey` must already be passed through `encryptCredential()`.
*/
export function updateProviderApiKey(db, connectionId, encryptedKey) {
ensureProviderSchema(db);
const now = new Date().toISOString();
const result = db
.prepare(
`UPDATE provider_connections
SET api_key = @apiKey,
test_status = 'unknown',
last_error = NULL,
last_error_at = NULL,
last_error_type = NULL,
last_error_source = NULL,
error_code = NULL,
rate_limited_until = NULL,
backoff_level = 0,
updated_at = @updatedAt
WHERE id = @id`
)
.run({ id: connectionId, apiKey: encryptedKey, updatedAt: now });
return result.changes;
}
export function updateProviderTestResult(db, connectionId, result) {
ensureProviderSchema(db);
const now = new Date().toISOString();

149
docs/AGENTROUTER.md Normal file
View File

@@ -0,0 +1,149 @@
# AgentRouter Setup Guide
[AgentRouter](https://agentrouter.org) is an Anthropic-compatible relay that resells
Claude and other models, often at lower prices than the direct Anthropic API. It is
designed as a drop-in `ANTHROPIC_BASE_URL` replacement for the official Claude Code
client, so it only accepts traffic that matches the Claude Code wire image (specific
User-Agent, `anthropic-beta` flags, Stainless SDK headers, etc.).
OmniRoute supports AgentRouter through the **Claude Code compatible** provider type
(`anthropic-compatible-cc-*`), which speaks the Anthropic Messages API with the
correct wire image. A generic `openai-compatible-chat` provider pointing at
`https://agentrouter.org` will **not** work — the upstream WAF rejects requests that
do not look like Claude Code.
---
## Prerequisites
- An AgentRouter account and API key. New signups get free credits via the affiliate
link in the project [README](../README.md).
- OmniRoute running with the `ENABLE_CC_COMPATIBLE_PROVIDER` feature flag enabled
(see below).
## 1. Enable the CC-compatible provider type
The Claude Code compatible provider type is gated behind a feature flag because it
sends traffic that closely mirrors the official Claude Code client. Enable it by
setting an environment variable before starting OmniRoute:
```bash
ENABLE_CC_COMPATIBLE_PROVIDER=true
```
Docker example:
```bash
docker run -d --name omniroute \
--restart unless-stopped \
-p 20128:20128 \
-v omniroute-data:/app/data \
-e ENABLE_CC_COMPATIBLE_PROVIDER=true \
diegosouzapw/omniroute:latest
```
After restarting, the dashboard exposes an **Add Claude Code Compatible** option in
addition to the existing OpenAI-compatible and Anthropic-compatible flows.
## 2. Create the provider in the dashboard
1. Open **Dashboard → Providers → Add Provider**.
2. Choose **Add Claude Code Compatible** (only visible when the flag above is set).
3. Fill in the fields:
| Field | Value |
| --------- | -------------------------------------------------------------- |
| Name | `AgentRouter` (or any label) |
| Prefix | `agentrouter` (friendly alias shown in logs and the dashboard) |
| Base URL | `https://agentrouter.org` |
| Chat path | `/v1/messages?beta=true` (default — leave as-is) |
> The canonical model identifier still uses the full provider node ID
> (`anthropic-compatible-cc-{uuid}/{model}`). The **Prefix** is just a display
> alias resolved by `src/lib/usage/callLogs.ts` for friendlier log output.
4. (Optional) Paste your API key in the **Validate** field and click **Check** to
confirm connectivity before saving.
5. Click **Add**.
Once created, open the provider and add a **Connection** with your AgentRouter API
key (`sk-...`). The connection's `test_status` should turn `active`.
## 3. Use it through a combo or directly
Reference the model using your provider's prefix as the namespace:
```bash
curl -X POST http://localhost:20128/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "agentrouter/claude-opus-4-6",
"messages": [{"role": "user", "content": "hello"}],
"max_tokens": 100
}'
```
The canonical model ID `anthropic-compatible-cc-{uuid}/claude-opus-4-6` also works
and is what shows up in the database and combo configuration.
Or add it to a combo for routing, fallback, and quota management like any other
provider.
---
## Wire image details
For reference, the cc-compatible bridge sends the following on each upstream
request (see `open-sse/services/claudeCodeCompatible.ts`):
| Header | Value |
| ------------------------------------------- | ------------------------------------------------------------------------ |
| `Authorization` | `Bearer <api-key>` |
| `User-Agent` | `claude-cli/2.1.137 (external, sdk-cli)` |
| `anthropic-version` | `2023-06-01` |
| `anthropic-beta` | `claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24` |
| `anthropic-dangerous-direct-browser-access` | `true` |
| `x-app` | `cli` |
| `X-Stainless-*` | Various Stainless SDK headers (lang, package version, OS, arch, etc.) |
This is what allows requests to pass the upstream WAF / client whitelist.
---
## Troubleshooting
**`{"error":{"message":"unauthorized client detected, ..."}}`** — Your request did
not match the Claude Code wire image. This happens when the provider is configured
as `openai-compatible-chat` instead of `anthropic-compatible-cc`, or when the
`ENABLE_CC_COMPATIBLE_PROVIDER=true` flag was not set at startup.
**`{"error":{"message":"无效的令牌","type":"new_api_error"}}` (HTTP 401)** —
"Invalid token". The wire image is correct but the API key is rejected. Generate a
new key in the AgentRouter dashboard and update the connection.
**`{"error":{"code":"content-blocked","type":"agent_router_api_error"}}`
(HTTP 400)** — AgentRouter's moderation hook rejected the request content, or the
key's plan does not permit the requested model. Try a different prompt or model;
contact AgentRouter support if a benign prompt is consistently blocked.
**`[400]: content-blocked` only on specific models** — Most AgentRouter plans only
allow a subset of models (e.g. `claude-opus-4-6`). Other model IDs return
`unauthorized_client_error` even though the key is valid. Check which models your
plan covers in the AgentRouter dashboard.
**`Invalid JSON response from provider (reset after Ns)` from the omniroute logs** —
The upstream returned a non-JSON body (typically an HTML error page from the WAF).
This usually means the request never reached the AgentRouter backend — recheck that
the provider ID starts with `anthropic-compatible-cc-` (note the trailing dash —
see `CLAUDE_CODE_COMPATIBLE_PREFIX` in `open-sse/services/claudeCodeCompatible.ts`)
and the feature flag is enabled.
---
## See also
- [`docs/PROVIDERS.md`](./PROVIDERS.md) — Other provider integration notes
- [`docs/reference/FREE_TIERS.md`](./reference/FREE_TIERS.md) — Free-tier provider
catalog
- [`open-sse/services/claudeCodeCompatible.ts`](../open-sse/services/claudeCodeCompatible.ts)
— Wire image implementation

136
docs/guides/KIRO_SETUP.md Normal file
View File

@@ -0,0 +1,136 @@
# Kiro Setup Guide
This guide covers adding Kiro (AWS-hosted AI coding assistant) accounts to OmniRoute,
with a focus on running multiple accounts simultaneously without session conflicts.
---
## Background: Why Kiro Accounts Can Conflict
Kiro's backend uses AWS SSO OIDC client registrations to track active sessions.
The critical constraint: **each OIDC client registration supports only one active
session at a time**. When a second device or user authenticates using the same
registered client, the backend invalidates the first account's refresh token.
This is the same mechanism that causes problems when running `kiro-cli login` on a
machine where another Kiro account is already signed in — the new login revokes the
first account's token.
---
## How OmniRoute Solves This (v3.8.0+)
Starting with v3.8.0, OmniRoute calls `registerClient()` (AWS SSO OIDC) during every
Kiro connection import. This gives each OmniRoute connection its own dedicated OIDC
client registration. Because each client registration is independent, refreshing or
re-authenticating one account does not affect any other account's refresh token.
The isolation applies to all three import methods:
| Import method | Isolation status |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| AWS Builder ID / IDC device-code flow | Isolated since the device-code flow was introduced |
| **Import Token** (manual refresh token paste) | Isolated from v3.8.0 |
| **Google / GitHub social login** | Isolated from v3.8.0 |
| **Auto-Import** (kiro-cli SQLite) | Isolated from v3.8.0 (SQLite path was already isolated; SSO-cache fallback is now also isolated) |
---
## Migration Note for Connections Created Before v3.8.0
Connections imported before v3.8.0 do not have a dedicated OIDC client registration
stored in `providerSpecificData`. These connections continue to work but use the shared
social-auth refresh endpoint, which means two such connections can still invalidate each
other.
**To gain isolation:** delete the old connection from **Dashboard → Providers** and
re-import it using any of the supported import flows. All newly created connections will
receive their own client registration automatically.
---
## Adding Two Kiro Accounts Side by Side
### Prerequisites
- OmniRoute v3.8.0 or later.
- A working Kiro account (email + password, Google, or GitHub login).
- Optionally a second Kiro account.
### Step 1: Import the first account
1. Open **Dashboard → Providers → Add Provider → Kiro**.
2. Choose one of:
- **Import Token** — paste a refresh token starting with `aorAAAAAG`.
- **Google / GitHub login** — complete the OAuth flow in the browser.
- **Auto-Import** — click the button; OmniRoute reads credentials from the
local kiro-cli database or `~/.aws/sso/cache`.
3. The connection is saved. OmniRoute automatically registers a dedicated OIDC client for it.
### Step 2: Import the second account
Repeat step 1 for the second account. Because each import creates a separate OIDC
client registration, the two connections are fully isolated.
### Step 3: Verify both connections are active
1. **Dashboard → Providers** — both Kiro connections should show **Active** status.
2. **Dashboard → Health** — both connections should pass their token health check.
### Step 4: Use a combo to route between accounts
Create a combo with both connections as targets to load-balance or fall back between them:
```
kiro/kiro-dev → kiro/kiro-pro
```
See [FEATURES.md](./FEATURES.md) and the routing documentation for combo configuration.
---
## Enterprise / IDC Users
For AWS IAM Identity Center (IDC) accounts, use the **AWS Builder ID / IDC device-code**
flow from **Dashboard → Providers → Kiro → Device Code**. The device-code flow has
always been fully isolated. No re-import is needed for these connections.
Enterprise users who operate in a non-default AWS region can specify the region when
importing via the Import Token API:
```bash
curl -X POST http://localhost:20128/api/oauth/kiro/import \
-H "Content-Type: application/json" \
-d '{"refreshToken": "aorAAAAAG...", "region": "eu-west-1"}'
```
The `region` field defaults to `us-east-1` when omitted.
---
## OIDC Client Expiry
AWS SSO OIDC public clients typically expire after 90 days
(`clientSecretExpiresAt`). OmniRoute stores this timestamp in `providerSpecificData`
for observability. If a connection stops refreshing after ~90 days, re-import the
connection to obtain a fresh OIDC client registration. Automatic re-registration on
expiry is tracked as a future improvement.
---
## Troubleshooting
### Second account keeps getting logged out
- Check both connections in **Dashboard → Providers** and confirm each shows a non-null
`clientId` in its raw JSON (visible via the info icon). If either connection is missing
`clientId`, it was imported before v3.8.0 — re-import it.
### Import fails with "Token validation failed"
- Ensure the refresh token starts with `aorAAAAAG`.
- Ensure OmniRoute can reach `https://oidc.us-east-1.amazonaws.com` (or the configured
region). If you are behind a corporate proxy, set a provider-level proxy in
**Dashboard → Settings → Proxies**.
For other issues, see the main [TROUBLESHOOTING.md](./TROUBLESHOOTING.md).

View File

@@ -134,6 +134,26 @@ OmniRoute auto-refreshes tokens. If issues persist:
1. Dashboard → Provider → Reconnect
2. Delete and re-add the provider connection
### Kiro multi-account: second account invalidates the first
**Cause:** Kiro's backend enforces a single active session per OIDC client registration.
When two accounts share the same registered client (connections imported before v3.8.0),
refreshing one account's token invalidates the other's refresh token.
**Fix (v3.8.0+):** Re-import affected connections.
Starting with v3.8.0, every new Kiro connection created via **Import Token**,
**Google/GitHub social login**, or **Auto-Import** automatically registers its own
dedicated OIDC client. The connection is therefore fully isolated and refreshing one
account has no effect on any other account.
Connections that were imported _before_ v3.8.0 do not carry a per-connection client
registration. Those connections continue to use the shared social-auth refresh endpoint.
To gain isolation, delete the old connection from Dashboard → Providers and re-add it
via any of the three import flows.
For full details and step-by-step instructions for adding two Kiro accounts side by side,
see [`docs/guides/KIRO_SETUP.md`](./KIRO_SETUP.md).
---
## Cloud Issues

View File

@@ -0,0 +1,122 @@
# Zed IDE Integration in Docker Environments
When OmniRoute runs inside Docker, the standard "Import from Zed Keychain" flow fails
because the container cannot reach the host OS keychain daemon (libsecret on Linux,
Keychain on macOS, Credential Manager on Windows) and the Zed config directories on the
host filesystem are not visible inside the container by default.
## Why Keychain Import Fails in Docker
Two blocking issues occur inside a container:
1. **Filesystem isolation**`isZedInstalled()` looks for `~/.config/zed` (Linux),
`~/Library/Application Support/Zed` (macOS), or the Windows equivalent. These paths
live on the host and are not available unless explicitly volume-mounted.
2. **IPC isolation** — Even when the config directory is mounted, the `keytar` native
module communicates with the OS keychain service over a Unix socket or D-Bus session.
Neither is bridged into the container by default, so credential reads always fail.
OmniRoute detects the Docker environment via two heuristics:
- Presence of `/.dockerenv` (written by the Docker daemon at container start).
- The string `docker` appearing in `/proc/1/cgroup` (Linux cgroup v1).
When either heuristic triggers, the import route returns HTTP 422 with
`zedDockerEnvironment: true` and a message directing you to the Manual Token Import tab.
## Using the Manual Token Import Tab
1. Open **Dashboard → Providers → Zed**.
2. The **Manual Token Import** panel appears below the keychain import card. When
OmniRoute detects Docker, this panel expands automatically after the first failed
keychain import attempt.
3. Select the provider from the dropdown (OpenAI, Anthropic, Google, Mistral, xAI,
OpenRouter, or DeepSeek).
4. Paste the API key in the password field.
5. Click **Import**.
The key is saved as a new provider connection with the name
`Zed Manual Import (<provider>)`.
## Where Zed Stores API Keys on the Host
Zed stores AI provider keys in the OS keychain under service names such as
`zed-openai`, `ai.zed.openai`, `zed-anthropic`, etc. To retrieve them for manual
import, look in:
**Linux**
```
~/.config/zed/settings.json
```
The `language_models` section contains provider configurations. Keys saved to the
keychain via the Zed UI are not in plain text in `settings.json`; retrieve them through
a keychain viewer such as GNOME Keyring / Seahorse, or by running:
```bash
secret-tool lookup service zed-openai account api-key
```
**macOS**
```
~/Library/Application Support/Zed/settings.json
```
Keychain entries can be found in **Keychain Access.app** by searching for `zed`.
## Volume-Mount Option (Advanced)
You can optionally mount the Zed config directory read-only into the container.
This does not fix the keychain issue but may be useful for future features that read
non-secret Zed config values (e.g., model preferences).
```yaml
# docker-compose.yml snippet
services:
omniroute:
image: omniroute:latest
volumes:
# Linux host
- "${HOME}/.config/zed:/host-zed-config:ro"
# macOS host (uncomment instead)
# - "${HOME}/Library/Application Support/Zed:/host-zed-config:ro"
environment:
# Future: ZED_CONFIG_PATH=/host-zed-config
PORT: "20128"
```
Note: a `ZED_CONFIG_PATH` environment variable override is not yet implemented. This
snippet is provided as a reference for when that feature is added.
## Manual Import API
The manual import endpoint can also be called directly:
```
POST /api/providers/zed/manual-import
Content-Type: application/json
Authorization: Bearer <management-token>
{
"provider": "openai",
"token": "sk-...",
"label": "My Zed OpenAI key" // optional
}
```
On success it returns:
```json
{ "success": true, "connectionId": "...", "provider": "openai" }
```
## Troubleshooting
| Symptom | Cause | Fix |
| ------------------------------------ | ---------------------------- | -------------------------------- |
| 422 + `zedDockerEnvironment: true` | Running inside Docker | Use Manual Token Import tab |
| 404 + `zedInstalled: false` | Zed not installed on host | Install Zed or use manual import |
| 403 + keychain access denied | OS denied keychain access | Grant permission in OS prompt |
| 404 + keychain service not available | `libsecret` missing on Linux | Install `libsecret-1-dev` |

View File

@@ -57,16 +57,17 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `kimi-coding` | `kmc` | Kimi Coding | OAuth | — | — |
| `windsurf` | `ws` | Windsurf (Devin CLI) | OAuth | [link](https://windsurf.com) | Sign in at windsurf.com to get your token. Visit windsurf.com/show-auth-token after logging in and paste it here, or use the device-code login flow. |
## Web Cookie Providers (6)
## Web Cookie Providers (7)
| ID | Alias | Name | Tags | Website | Notes |
| ---------------- | ---------- | --------------------------- | ---------- | --------------------------------- | ------------------------------------------------------------------------------------------- |
| `blackbox-web` | `bb-web` | Blackbox Web (Subscription) | Web cookie | [link](https://app.blackbox.ai) | Paste your \_\_Secure-authjs.session-token value or full cookie header from app.blackbox.ai |
| `chatgpt-web` | `cgpt-web` | ChatGPT Web (Plus/Pro) | Web cookie | [link](https://chatgpt.com) | Paste your \_\_Secure-next-auth.session-token cookie value from chatgpt.com |
| `deepseek-web` | `ds-web` | DeepSeek Web | Web cookie | [link](https://chat.deepseek.com) | Paste your ds_session_id cookie from chat.deepseek.com |
| `grok-web` | `gw` | Grok Web (Subscription) | Web cookie | [link](https://grok.com) | Paste your sso= cookie value from grok.com |
| `muse-spark-web` | `ms-web` | Muse Spark Web (Meta AI) | Web cookie | [link](https://www.meta.ai) | Paste your abra_sess value or full cookie header from meta.ai |
| `perplexity-web` | `pplx-web` | Perplexity Web (Pro/Max) | Web cookie | [link](https://www.perplexity.ai) | Paste your \_\_Secure-next-auth.session-token cookie value from perplexity.ai |
| ID | Alias | Name | Tags | Website | Notes |
| ---------------- | ---------- | --------------------------- | ---------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `blackbox-web` | `bb-web` | Blackbox Web (Subscription) | Web cookie | [link](https://app.blackbox.ai) | Paste your \_\_Secure-authjs.session-token value or full cookie header from app.blackbox.ai |
| `chatgpt-web` | `cgpt-web` | ChatGPT Web (Plus/Pro) | Web cookie | [link](https://chatgpt.com) | Paste your \_\_Secure-next-auth.session-token cookie value from chatgpt.com |
| `deepseek-web` | `ds-web` | DeepSeek Web | Web cookie | [link](https://chat.deepseek.com) | Paste your ds_session_id cookie from chat.deepseek.com |
| `grok-web` | `gw` | Grok Web (Subscription) | Web cookie | [link](https://grok.com) | Paste your sso= cookie value from grok.com |
| `muse-spark-web` | `ms-web` | Muse Spark Web (Meta AI) | Web cookie | [link](https://www.meta.ai) | Paste your abra_sess value or full cookie header from meta.ai |
| `perplexity-web` | `pplx-web` | Perplexity Web (Pro/Max) | Web cookie | [link](https://www.perplexity.ai) | Paste your \_\_Secure-next-auth.session-token cookie value from perplexity.ai |
| `t3-web` | `t3chat` | t3.chat (Pro/Free) | Web cookie | [link](https://t3.chat) | Pro: $8/mo, 50+ models. Free tier: limited models. Requires Cookie header + convex-session-id from DevTools. **Skeleton — endpoint URL not yet confirmed (TODO post-devtools-capture).** |
## API Key Providers (paid / paid-with-free-credits) (122)

View File

@@ -87,17 +87,17 @@ The Auto-Combo Engine dynamically selects the best provider/model for each reque
> Source: [diagrams/auto-combo-9factor.mmd](../diagrams/auto-combo-9factor.mmd)
| Factor | Default Weight | Description |
| :----------------- | :------------- | :---------------------------------------------------------------------- |
| `health` | 0.22 | Health score from circuit breaker (CLOSED=1.0, HALF_OPEN=0.5, OPEN=0.0) |
| `quota` | 0.17 | Remaining quota / rate-limit headroom [0..1] |
| `costInv` | 0.17 | Inverse cost normalized to pool — cheaper = higher score |
| `latencyInv` | 0.13 | Inverse p95 latency normalized to pool — faster = higher score |
| `taskFit` | 0.08 | Task-type fitness (coding, review, planning, analysis, debugging, docs) |
| `specificityMatch` | 0.08 | Match between request specificity (manifest hint) and model tier |
| `stability` | 0.05 | Variance-based stability (low latency stdDev / error rate) |
| `tierPriority` | 0.05 | Account-tier priority — Ultra=1.0, Pro=0.67, Standard=0.33, Free=0.0 |
| `tierAffinity` | 0.05 | Affinity between the candidate's tier and the manifest-recommended tier |
| Factor | Default Weight | Description |
| :----------------- | :------------- | :------------------------------------------------------------------------------------------------- |
| `health` | 0.22 | Health score from circuit breaker (CLOSED=1.0, HALF_OPEN=0.5, OPEN=0.0) |
| `quota` | 0.17 | Remaining quota / rate-limit headroom [0..1] |
| `costInv` | 0.17 | Inverse **blended** cost (60% input + 40% output token price, normalized) — cheaper = higher score |
| `latencyInv` | 0.13 | Inverse p95 latency normalized to pool — faster = higher score |
| `taskFit` | 0.08 | Task-type fitness (coding, review, planning, analysis, debugging, docs) |
| `specificityMatch` | 0.08 | Match between request specificity (manifest hint) and model tier |
| `stability` | 0.05 | Variance-based stability (low latency stdDev / error rate) |
| `tierPriority` | 0.05 | Account-tier priority — Ultra=1.0, Pro=0.67, Standard=0.33, Free=0.0 |
| `tierAffinity` | 0.05 | Affinity between the candidate's tier and the manifest-recommended tier |
**Sum:** `0.22 + 0.17 + 0.17 + 0.13 + 0.08 + 0.08 + 0.05 + 0.05 + 0.05 = 1.0` (validated by `validateWeights()`).
@@ -210,16 +210,16 @@ Including the bare `auto` (default) plus the 6 `AutoVariant` values declared in
The 9-factor scoring function (`open-sse/services/autoCombo/scoring.ts`) treats tier
membership as one signal via the `tierPriority` weight. Default weights (from `DEFAULT_WEIGHTS`):
| Factor | Default weight | Notes |
| ------------------------ | -------------- | --------------------------------- |
| Tier priority | 0.05 | Tier 1 premium → higher score |
| Latency (p50 inverse) | 0.35 | Fastest wins |
| Cost ($/1M inverse) | 0.20 | Cheapest wins |
| Recent health/error rate | 0.15 | Unhealthy deprioritized |
| Quota remaining | 0.10 | Near-exhausted deprioritized |
| Context window match | 0.08 | Penalizes short windows |
| Task fitness | 0.10 | Coding → coding-specialist models |
| Stability | 0.00 | Disabled by default |
| Factor | Default weight | Notes |
| ------------------------ | -------------- | -------------------------------------------------------------- |
| Tier priority | 0.05 | Tier 1 premium → higher score |
| Latency (p50 inverse) | 0.35 | Fastest wins |
| Cost ($/1M inverse) | 0.20 | Cheapest **blended** price wins (60% input + 40% output ratio) |
| Recent health/error rate | 0.15 | Unhealthy deprioritized |
| Quota remaining | 0.10 | Near-exhausted deprioritized |
| Context window match | 0.08 | Penalizes short windows |
| Task fitness | 0.10 | Coding → coding-specialist models |
| Stability | 0.00 | Disabled by default |
Tier alone does **not** force Tier 1 first — if Tier 1 latency is bad or
cost-vs-quality is suboptimal, Tier 2 wins. To force tier ordering, use combo

View File

@@ -132,6 +132,28 @@ When adding a new route or executor, copy the assertion pattern from this file.
- The `pino` redaction config (`src/lib/log/redaction.ts` — if present) handles structured log redaction separately. This doc covers only the response-message surface.
- Upstream-header denylist (`src/shared/constants/upstreamHeaders.ts`) covers header leakage — keep both files aligned when adding a new exfiltration concern.
## Upstream details passthrough
`buildErrorBody` accepts an optional third argument `upstreamDetails` (raw
parsed body from the upstream provider). When provided, it is sanitized by
`sanitizeUpstreamDetails` before inclusion in the response as `upstream_details`.
Sanitization rules applied to `upstreamDetails`:
1. String leaves: run through `sanitizeErrorMessage` (strips stacks + absolute paths).
2. Key blocklist: keys matching `/stack|trace|path|file|cwd|dir|password|secret|token|key/i`
are removed.
3. Depth cap: nesting beyond 4 levels is replaced with the string `"[truncated]"`.
4. Arrays are capped at 32 elements.
Only the seven upstream-error `createErrorResult` call sites in `chatCore.ts` pass
`upstreamErrorBody`. Internal OmniRoute errors (SSE parse failures, empty content,
guardrail blocks) do not include `upstream_details`.
Do NOT pass raw `err.stack`, `err.message`, or any string from a runtime exception to
`upstreamDetails`. Those must still go through `errorResponse` / `buildErrorBody(code, msg)`
without an upstream body.
## Known CodeQL limitation: custom sanitizers not recognized
The CodeQL query [`js/stack-trace-exposure`](https://codeql.github.com/codeql-query-help/javascript/js-stack-trace-exposure/) uses a fixed allowlist of sanitizer patterns (e.g. inline `.split("\n")[0]`, `String#replace` with specific regex shapes, access to `.message` on `Error`). It does **not** recognize indirection through a custom helper like our `sanitizeErrorMessage()`.

View File

@@ -2286,6 +2286,54 @@ export const REGISTRY: Record<string, RegistryEntry> = {
],
},
// TODO(post-devtools-capture): Confirm baseUrl after Step 0 DevTools capture.
// Current guess: "https://t3.chat/api/chat". May be a Convex deployment URL.
// TODO(post-devtools-capture): Trim duplicate model entries and update model IDs
// to match exact values seen in the DevTools request body (model field).
"t3-web": {
id: "t3-web",
alias: "t3chat",
format: "openai",
executor: "t3-web",
baseUrl: "https://t3.chat/api/chat",
authType: "apikey",
authHeader: "cookie",
models: [
// Claude
{ id: "claude-opus-4", name: "Claude Opus 4 (via t3.chat)" },
{ id: "claude-sonnet-4", name: "Claude Sonnet 4 (via t3.chat)" },
{ id: "claude-haiku-4", name: "Claude Haiku 4 (via t3.chat)" },
{ id: "claude-3.7", name: "Claude 3.7 Sonnet (via t3.chat)" },
// GPT / OpenAI
{ id: "gpt-5", name: "GPT-5 (via t3.chat)" },
{ id: "gpt-4o", name: "GPT-4o (via t3.chat)" },
{ id: "gpt-4.1", name: "GPT-4.1 (via t3.chat)" },
{ id: "o3", name: "o3 (via t3.chat)" },
{ id: "o4-mini", name: "o4-mini (via t3.chat)" },
// Gemini
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro (via t3.chat)" },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash (via t3.chat)" },
// DeepSeek
{ id: "deepseek-r1", name: "DeepSeek R1 (via t3.chat)", supportsReasoning: true },
{ id: "deepseek-v3", name: "DeepSeek V3 (via t3.chat)" },
// Grok
{ id: "grok-3", name: "Grok 3 (via t3.chat)" },
{ id: "grok-3-mini", name: "Grok 3 Mini (via t3.chat)" },
// Llama / Meta
{ id: "llama-4-maverick", name: "Llama 4 Maverick (via t3.chat)" },
{ id: "llama-4-scout", name: "Llama 4 Scout (via t3.chat)" },
{ id: "llama-3.3-70b", name: "Llama 3.3 70B (via t3.chat)" },
// Mistral
{ id: "devstral", name: "Devstral (via t3.chat)" },
{ id: "mistral-large", name: "Mistral Large (via t3.chat)" },
// Qwen
{ id: "qwen3-235b", name: "Qwen3 235B (via t3.chat)", supportsReasoning: true },
{ id: "qwen3-32b", name: "Qwen3 32B (via t3.chat)", supportsReasoning: true },
// Kimi
{ id: "kimi-k2", name: "Kimi K2 (via t3.chat)" },
],
},
together: {
id: "together",
alias: "together",

View File

@@ -105,6 +105,8 @@ export type ExecuteInput = {
clientHeaders?: Record<string, string> | null;
/** Callback to persist tokens that are proactively refreshed during execution. */
onCredentialsRefreshed?: (newCredentials: ProviderCredentials) => Promise<void> | void;
/** When true, skip the intra-URL 429 retry in execute() so the caller handles fallback. */
skipUpstreamRetry?: boolean;
};
export type CountTokensInput = {
@@ -526,6 +528,7 @@ export class BaseExecutor {
extendedContext,
upstreamExtraHeaders,
clientHeaders,
skipUpstreamRetry = false,
}: ExecuteInput) {
const fallbackCount = this.getFallbackCount();
let lastError: unknown = null;
@@ -939,6 +942,7 @@ export class BaseExecutor {
// Intra-URL retry: if 429 and we haven't exhausted per-URL retries, wait and retry the same URL
if (
!skipUpstreamRetry &&
response.status === HTTP_STATUS.RATE_LIMITED &&
(retryAttemptsByUrl[urlIndex] ?? 0) < BaseExecutor.RETRY_CONFIG.maxAttempts
) {
@@ -958,7 +962,7 @@ export class BaseExecutor {
log?.warn?.("AUTH", `401 on ${url} - API key may be invalid`);
}
if (this.shouldRetry(response.status, urlIndex)) {
if (!skipUpstreamRetry && this.shouldRetry(response.status, urlIndex)) {
log?.debug?.("RETRY", `${response.status} on ${url}, trying fallback ${urlIndex + 1}`);
lastStatus = response.status;
continue;
@@ -972,7 +976,7 @@ export class BaseExecutor {
log?.warn?.("TIMEOUT", `Fetch timeout after ${this.getTimeoutMs()}ms on ${url}`);
}
lastError = err;
if (urlIndex + 1 < fallbackCount) {
if (!skipUpstreamRetry && urlIndex + 1 < fallbackCount) {
log?.debug?.("RETRY", `Error on ${url}, trying fallback ${urlIndex + 1}`);
continue;
}

View File

@@ -30,6 +30,7 @@ import { DeepSeekWebExecutor } from "./deepseek-web.ts";
import { DeepSeekWebWithAutoRefreshExecutor } from "./deepseek-web-with-auto-refresh.ts";
import { CopilotWebExecutor } from "./copilot-web.ts";
import { VeoAIFreeWebExecutor } from "./veoaifree-web.ts";
import { T3ChatWebExecutor } from "./t3-chat-web.ts";
const executors = {
antigravity: new AntigravityExecutor(),
@@ -84,6 +85,8 @@ const executors = {
copilot: new CopilotWebExecutor(), // Alias
"veoaifree-web": new VeoAIFreeWebExecutor(),
"veo-free": new VeoAIFreeWebExecutor(), // Alias
"t3-web": new T3ChatWebExecutor(),
t3chat: new T3ChatWebExecutor(), // Alias
};
const defaultCache = new Map();
@@ -132,3 +135,4 @@ export { CopilotWebExecutor } from "./copilot-web.ts";
export { VeoAIFreeWebExecutor } from "./veoaifree-web.ts";
export { DeepSeekWebExecutor } from "./deepseek-web.ts";
export { DeepSeekWebWithAutoRefreshExecutor } from "./deepseek-web-with-auto-refresh.ts";
export { T3ChatWebExecutor } from "./t3-chat-web.ts";

View File

@@ -0,0 +1,472 @@
// ## Known TODOs — Requires Manual DevTools Capture (Step 0 from plan #1909)
//
// Before this skeleton can serve live traffic, a human must open https://t3.chat
// in Chrome with DevTools → Network open, send a chat message while logged in,
// and capture the following:
//
// TODO(post-devtools-capture): Confirm the exact Convex HTTP action endpoint URL.
// Current guess based on Convex pattern: "https://t3.chat/api/chat"
// Alternative guesses: "https://t3.chat/api/sync/streamRoom",
// "https://api.t3.chat/api/chat", or a convex.cloud deployment URL.
// Reference: T3Router Rust source (github.com/vibheksoni/t3router) BASE_URL const.
//
// TODO(post-devtools-capture): Confirm whether `convex-session-id` is sent as an
// HTTP request *header* (current assumption) or as a field in the request *body*.
// Also confirm the exact header/field name (e.g. "convex-session-id",
// "x-convex-session-id", or "sessionId").
//
// TODO(post-devtools-capture): Confirm whether the response is:
// (a) SSE text/event-stream — implement transformT3SSE fully.
// (b) Chunked newline-delimited JSON — adapt decoder.
// (c) Full JSON (non-streaming) — use collectContent path only.
//
// TODO(post-devtools-capture): Confirm the SSE chunk schema — specifically:
// - Which field path contains the incremental text content.
// - What the end-of-stream marker looks like ("[DONE]", a `status` field, etc.).
//
// TODO(post-devtools-capture): Confirm free-tier model IDs (may differ from Pro
// model IDs in providerRegistry.ts). Update registry entries accordingly.
//
// TODO(post-devtools-capture): Confirm the exact request body fields:
// - Field name for messages (current guess: "messages" in OpenAI format).
// - Field name for model (current guess: "model").
// - Whether a conversation/thread ID is required.
// - Whether "stream" is a supported field.
import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
export const T3_CHAT_BASE = "https://t3.chat";
// TODO(post-devtools-capture): Replace with confirmed endpoint URL.
// Guesses based on Convex HTTP action pattern and reference implementations:
// - https://t3.chat/api/chat
// - https://t3.chat/api/sync/streamRoom
// Check T3Router Rust source for the BASE_URL constant before going live.
const COMPLETION_URL = `${T3_CHAT_BASE}/api/chat`;
const USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
// ── Types ────────────────────────────────────────────────────────────────
export interface T3ChatCredentials {
cookies: string;
convexSessionId: string;
}
// ── Helpers ──────────────────────────────────────────────────────────────
function validateCredentials(creds: unknown): creds is T3ChatCredentials {
const raw = typeof creds === "object" && creds !== null ? (creds as Record<string, unknown>) : {};
return (
typeof raw.cookies === "string" &&
raw.cookies.length > 0 &&
typeof raw.convexSessionId === "string" &&
raw.convexSessionId.length > 0
);
}
function buildErrorResponse(status: number, message: string): Response {
return new Response(
JSON.stringify({
error: {
message: sanitizeErrorMessage(message),
type: "upstream_error",
code: `HTTP_${status}`,
},
}),
{ status, headers: { "Content-Type": "application/json" } }
);
}
// ── SSE Transform (t3.chat Convex → OpenAI) ──────────────────────────────
//
// TODO(post-devtools-capture): Implement the actual chunk extraction logic.
// The field paths below are best guesses based on the Convex streaming protocol.
// Common Convex patterns: { type: "text", text: "..." } or { delta: "..." }.
// Replace `chunk.text ?? chunk.delta ?? chunk.content` with the real field path.
function transformT3SSE(t3Stream: ReadableStream, model: string): ReadableStream {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const id = `chatcmpl-t3-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const created = Math.floor(Date.now() / 1000);
let emittedRole = false;
return new ReadableStream({
async start(controller) {
const reader = t3Stream.getReader();
let buffer = "";
const emit = (obj: object) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}\n\n`));
};
const chunk = (delta: object, finish?: string) => {
emit({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta, finish_reason: finish ?? null }],
});
};
const close = () => {
if (!emittedRole) {
emittedRole = true;
chunk({ role: "assistant", content: "" });
}
chunk({}, "stop");
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const payload = line.slice(6).trim();
if (payload === "[DONE]") {
close();
return;
}
let data: Record<string, unknown>;
try {
data = JSON.parse(payload);
} catch {
continue;
}
// TODO(post-devtools-capture): Replace this extraction with the real
// field path from the captured Convex SSE chunk structure.
// Current guess covers common Convex streaming patterns.
const textContent =
(data as any)?.text ??
(data as any)?.delta ??
(data as any)?.content ??
(data as any)?.v?.text ??
null;
if (typeof textContent === "string" && textContent.length > 0) {
if (!emittedRole) {
emittedRole = true;
chunk({ role: "assistant", content: "" });
}
chunk({ content: textContent });
}
// TODO(post-devtools-capture): Replace with real end-of-stream detection.
// Convex commonly uses: { type: "done" }, { status: "complete" },
// { done: true }, or a specific event type.
const isDone =
(data as any)?.type === "done" ||
(data as any)?.done === true ||
(data as any)?.status === "complete" ||
(data as any)?.finish_reason === "stop";
if (isDone) {
close();
return;
}
}
}
} catch {
// Stream error — fall through to close
}
close();
},
});
}
async function collectSSEContent(t3Stream: ReadableStream): Promise<string> {
const decoder = new TextDecoder();
const reader = t3Stream.getReader();
let buffer = "";
const parts: string[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const payload = line.slice(6).trim();
if (payload === "[DONE]") break;
try {
const data = JSON.parse(payload);
// TODO(post-devtools-capture): Use real field path.
const textContent =
(data as any)?.text ??
(data as any)?.delta ??
(data as any)?.content ??
(data as any)?.v?.text ??
null;
if (typeof textContent === "string") parts.push(textContent);
} catch {
// skip
}
}
}
return parts.join("");
}
// ── Executor ─────────────────────────────────────────────────────────────
export class T3ChatWebExecutor extends BaseExecutor {
constructor() {
super("t3-web", { baseUrl: T3_CHAT_BASE });
}
async testConnection(
credentials: Record<string, unknown>,
signal?: AbortSignal
): Promise<boolean> {
try {
if (!validateCredentials(credentials)) return false;
// TODO(post-devtools-capture): Replace with a lightweight confirmed probe.
// Current guess: HEAD or GET to T3_CHAT_BASE checks reachability.
// A better probe might be a lightweight OPTIONS or an auth-gated endpoint.
const resp = await fetch(T3_CHAT_BASE, {
method: "HEAD",
headers: {
"User-Agent": USER_AGENT,
Cookie: credentials.cookies,
},
signal,
});
// A 200/302/404 all indicate the site is reachable and the cookie was accepted
// without a hard 401. This is a best-effort probe until a proper endpoint is confirmed.
return resp.status < 500;
} catch {
return false;
}
}
async execute({ model, body, stream, credentials, signal, log }: ExecuteInput) {
const bodyObj = (body || {}) as Record<string, unknown>;
const messages = (Array.isArray(bodyObj.messages) ? bodyObj.messages : []) as Array<{
role: string;
content: string | unknown;
}>;
const rawCreds = credentials as unknown as Record<string, unknown>;
// 1. Validate credentials
if (!validateCredentials(rawCreds)) {
const missing = !rawCreds.cookies
? "cookies"
: !rawCreds.convexSessionId
? "convexSessionId"
: "both fields";
return {
response: buildErrorResponse(
400,
`t3.chat credentials invalid: missing or empty ${missing}. Both 'cookies' and 'convexSessionId' are required.`
),
url: COMPLETION_URL,
headers: {},
transformedBody: body,
};
}
const { cookies, convexSessionId } = rawCreds as T3ChatCredentials;
try {
// 2. Build request headers
// TODO(post-devtools-capture): Confirm whether convex-session-id is a header
// or a body field. Current assumption: HTTP header.
const headers: Record<string, string> = {
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
Accept: "text/event-stream, application/json",
Cookie: cookies,
// TODO(post-devtools-capture): Confirm header name — may be "x-convex-session-id"
// or sent as a body field instead.
"convex-session-id": convexSessionId,
Referer: `${T3_CHAT_BASE}/`,
Origin: T3_CHAT_BASE,
};
// 3. Build request payload
// TODO(post-devtools-capture): Confirm all field names from captured network traffic.
// Current guess: OpenAI-compatible messages array + model passthrough.
const requestPayload: Record<string, unknown> = {
model,
messages,
stream: stream !== false,
};
log?.info?.("T3-CHAT-WEB", `POST ${COMPLETION_URL} model=${model}`);
const resp = await fetch(COMPLETION_URL, {
method: "POST",
headers,
body: JSON.stringify(requestPayload),
signal,
});
// 4. Handle HTTP errors
if (!resp.ok) {
const status = resp.status;
let errMsg = `t3.chat API error (${status})`;
if (status === 401 || status === 403) {
errMsg =
"t3.chat session expired or unauthorized — re-paste your cookies and convex-session-id.";
} else if (status === 429) {
errMsg = "t3.chat rate limited. Wait and retry.";
}
log?.warn?.("T3-CHAT-WEB", errMsg);
return {
response: buildErrorResponse(status, errMsg),
url: COMPLETION_URL,
headers,
transformedBody: requestPayload,
};
}
const ct = resp.headers.get("content-type") || "";
// 5. Non-streaming full JSON response path
if (ct.includes("application/json")) {
const json = await resp.json();
// Check for error in JSON body
if (json?.error) {
const errMsg = `t3.chat error: ${json.error?.message ?? JSON.stringify(json.error)}`;
log?.warn?.("T3-CHAT-WEB", errMsg);
return {
response: buildErrorResponse(502, errMsg),
url: COMPLETION_URL,
headers,
transformedBody: requestPayload,
};
}
// If the JSON already looks like an OpenAI response, return it directly.
// Otherwise wrap it.
if (json?.choices) {
return {
response: new Response(JSON.stringify(json), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
url: COMPLETION_URL,
headers,
transformedBody: requestPayload,
};
}
// TODO(post-devtools-capture): Map the actual t3.chat non-streaming response
// shape to OpenAI format once the real field names are confirmed.
const content =
(json as any)?.content ?? (json as any)?.text ?? (json as any)?.message?.content ?? "";
const openaiResponse = {
id: `chatcmpl-t3-${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: model || "unknown",
choices: [
{
index: 0,
message: { role: "assistant", content: String(content) },
finish_reason: "stop",
},
],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
};
return {
response: new Response(JSON.stringify(openaiResponse), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
url: COMPLETION_URL,
headers,
transformedBody: requestPayload,
};
}
// 6. Streaming SSE path
if (!resp.body) {
return {
response: buildErrorResponse(502, "t3.chat returned an empty response body"),
url: COMPLETION_URL,
headers,
transformedBody: requestPayload,
};
}
if (stream !== false) {
const openaiStream = transformT3SSE(resp.body, model || "unknown");
return {
response: new Response(openaiStream, {
status: 200,
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" },
}),
url: COMPLETION_URL,
headers,
transformedBody: requestPayload,
};
}
// Non-streaming: collect SSE content and return OpenAI JSON
const content = await collectSSEContent(resp.body);
const openaiResponse = {
id: `chatcmpl-t3-${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: model || "unknown",
choices: [
{
index: 0,
message: { role: "assistant", content },
finish_reason: "stop",
},
],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
};
return {
response: new Response(JSON.stringify(openaiResponse), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
url: COMPLETION_URL,
headers,
transformedBody: requestPayload,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log?.error?.("T3-CHAT-WEB", `Execute failed: ${msg}`);
if (err instanceof DOMException && err.name === "AbortError") {
return {
response: buildErrorResponse(499, "Request cancelled"),
url: COMPLETION_URL,
headers: {},
transformedBody: body,
};
}
return {
response: buildErrorResponse(502, `t3.chat connection error: ${msg}`),
url: COMPLETION_URL,
headers: {},
transformedBody: body,
};
}
}
}
export const t3ChatWebExecutor = new T3ChatWebExecutor();

View File

@@ -1259,6 +1259,7 @@ export async function handleChatCore({
comboExecutionKey = null,
disableEmergencyFallback = false,
cachedSettings = null,
skipUpstreamRetry = false,
}) {
let { provider, model, extendedContext } = modelInfo;
// apiFormat is an optional custom-model marker injected by getModelInfo for
@@ -3170,6 +3171,7 @@ export async function handleChatCore({
upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall),
clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent),
onCredentialsRefreshed,
skipUpstreamRetry,
});
trace("post_executor", { status: res?.response?.status });
@@ -3661,6 +3663,7 @@ export async function handleChatCore({
upstreamExtraHeaders: buildUpstreamHeadersForExecute(retryModelId),
clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent),
onCredentialsRefreshed,
skipUpstreamRetry: isCombo,
});
if (retryResult.response.ok) {
@@ -3915,7 +3918,8 @@ export async function handleChatCore({
errMsg,
retryAfterMs,
upstreamErrorCode,
upstreamErrorType
upstreamErrorType,
upstreamErrorBody
);
}
} catch {
@@ -3933,7 +3937,8 @@ export async function handleChatCore({
errMsg,
retryAfterMs,
upstreamErrorCode,
upstreamErrorType
upstreamErrorType,
upstreamErrorBody
);
}
} else {
@@ -3951,7 +3956,8 @@ export async function handleChatCore({
errMsg,
retryAfterMs,
upstreamErrorCode,
upstreamErrorType
upstreamErrorType,
upstreamErrorBody
);
}
} else if (isContextOverflowError(statusCode, message)) {
@@ -3993,7 +3999,8 @@ export async function handleChatCore({
errMsg,
retryAfterMs,
upstreamErrorCode,
upstreamErrorType
upstreamErrorType,
upstreamErrorBody
);
}
} catch {
@@ -4011,7 +4018,8 @@ export async function handleChatCore({
errMsg,
retryAfterMs,
upstreamErrorCode,
upstreamErrorType
upstreamErrorType,
upstreamErrorBody
);
}
} else {
@@ -4029,7 +4037,8 @@ export async function handleChatCore({
errMsg,
retryAfterMs,
upstreamErrorCode,
upstreamErrorType
upstreamErrorType,
upstreamErrorBody
);
}
} else {
@@ -4118,7 +4127,8 @@ export async function handleChatCore({
errMsg,
retryAfterMs,
upstreamErrorCode,
upstreamErrorType
upstreamErrorType,
upstreamErrorBody
);
}
}

View File

@@ -488,6 +488,14 @@ export function shouldMarkAccountExhaustedFrom429(
);
}
/**
* Clear all in-memory model lockouts and failure state (for tests / full reset).
*/
export function clearAllModelLockouts(): void {
modelLockouts.clear();
modelFailureState.clear();
}
/**
* Check if a specific model on a specific account is locked
* @returns {boolean}
@@ -698,6 +706,25 @@ export function isProviderFailureCode(status: number): boolean {
return PROVIDER_FAILURE_ERROR_CODES.has(status);
}
/**
* Returns true when a checkFallbackError result signals that the entire provider
* quota is exhausted for this request, so the combo router can skip remaining
* targets from the same provider (#1731).
*
* Covers:
* - reason === "quota_exhausted" (subscription, daily, credits)
* - creditsExhausted flag
* - dailyQuotaExhausted flag
*/
export function isProviderExhaustedReason(result: {
reason?: string;
creditsExhausted?: boolean;
dailyQuotaExhausted?: boolean;
}): boolean {
if (result.creditsExhausted || result.dailyQuotaExhausted) return true;
return result.reason === RateLimitReason.QUOTA_EXHAUSTED;
}
// ─── Retry-After Parsing ────────────────────────────────────────────────────
/**

View File

@@ -11,6 +11,7 @@ import {
getRuntimeProviderProfile,
recordProviderFailure,
isProviderFailureCode,
isProviderExhaustedReason,
} from "./accountFallback.ts";
import { errorResponse, unavailableResponse } from "../utils/error.ts";
import { recordComboIntent, recordComboRequest, getComboMetrics } from "./comboMetrics.ts";
@@ -35,6 +36,7 @@ import {
type ScoringWeights,
} from "./autoCombo/scoring.ts";
import { supportsToolCalling } from "./modelCapabilities.ts";
import { estimateTokens } from "./contextManager.ts";
import { getSessionConnection } from "./sessionManager.ts";
import { generateRoutingHints } from "./manifestAdapter";
import type { RoutingHint } from "./manifestAdapter";
@@ -96,6 +98,10 @@ const DEFAULT_MODEL_P95_MS = {
"deepseek-chat": 2000,
};
const MIN_HISTORY_SAMPLES = 10;
// Assumed fraction of tokens that are output when blending input+output prices
// for auto-combo cost scoring. 0.4 = 40% output, 60% input.
// Matches the example in GitHub issue #1812 (e.g. o3-like model: $3 input/$15 output).
const OUTPUT_TOKEN_RATIO = 0.4;
const RESET_AWARE_SESSION_WINDOW_MS = 5 * 60 * 60 * 1000;
const RESET_AWARE_WEEKLY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
const RESET_AWARE_REMAINING_WEIGHT = 0.55;
@@ -1219,8 +1225,14 @@ async function buildAutoCandidates(targets, comboName) {
try {
const pricing = await getPricingForModel(provider, model);
const inputPrice = Number(pricing?.input);
const outputPrice = Number(pricing?.output);
if (Number.isFinite(inputPrice) && inputPrice >= 0) {
costPer1MTokens = inputPrice;
if (Number.isFinite(outputPrice) && outputPrice >= 0) {
costPer1MTokens =
inputPrice * (1 - OUTPUT_TOKEN_RATIO) + outputPrice * OUTPUT_TOKEN_RATIO;
} else {
costPer1MTokens = inputPrice;
}
}
} catch {
// keep default cost
@@ -1678,6 +1690,8 @@ export async function handleComboChat({
const maxRetries = config.maxRetries ?? 1;
const retryDelayMs = resolveDelayMs(config.retryDelayMs, 2000);
const fallbackDelayMs = resolveDelayMs(config.fallbackDelayMs, 0);
const maxSetRetries = config.maxSetRetries ?? 0;
const setRetryDelayMs = resolveDelayMs(config.setRetryDelayMs, 2000);
let orderedTargets =
strategy === "weighted"
@@ -1711,6 +1725,32 @@ export async function handleComboChat({
}
}
// Context-window pre-filter (#1808)
// Estimate input tokens once; exclude candidates whose known context limit is too small.
// Uses the same 4-chars-per-token heuristic as contextManager.ts::compressContext().
// Null/unknown limits are treated as "include" to avoid incorrectly dropping valid targets.
const estimatedInputTokens = estimateTokens(body?.messages ?? []);
if (estimatedInputTokens > 0) {
const filteredByContext = eligibleTargets.filter((target) => {
const limit = getModelContextLimitForModelString(target.modelStr);
if (limit === null || limit === undefined) return true; // unknown — include to be safe
return limit >= estimatedInputTokens;
});
if (filteredByContext.length > 0) {
log.debug(
"COMBO",
`Auto strategy: context-window filter kept ${filteredByContext.length}/${eligibleTargets.length} candidates (est. ${estimatedInputTokens} tokens)`
);
eligibleTargets = filteredByContext;
} else {
log.warn(
"COMBO",
`Auto strategy: all candidates filtered by context-window policy (est. ${estimatedInputTokens} tokens), falling back to full pool`
);
// eligibleTargets intentionally unchanged — same fallback contract as tool-calling filter
}
}
const prompt = extractPromptForIntent(body);
const systemPrompt =
typeof combo?.system_message === "string" ? combo.system_message : undefined;
@@ -1765,7 +1805,7 @@ export async function handleComboChat({
try {
const decision = selectWithStrategy(
candidates,
{ taskType, requestHasTools, lastKnownGoodProvider },
{ taskType, requestHasTools, lastKnownGoodProvider, estimatedInputTokens },
routingStrategy
);
selectedProvider = decision.provider;
@@ -1954,82 +1994,255 @@ export async function handleComboChat({
return comboModelNotFoundResponse("Combo has no executable targets");
}
let lastError = null;
let earliestRetryAfter = null;
let lastStatus = null;
const startTime = Date.now();
// #1731: Per-request in-memory set of providers whose quota is fully exhausted.
// When a target returns a quota-exhausted 429, remaining same-provider targets are skipped.
const exhaustedProviders = new Set<string>();
let globalAttempts = 0;
let fallbackCount = 0;
let recordedAttempts = 0;
for (let i = 0; i < orderedTargets.length; i++) {
const target = orderedTargets[i];
const modelStr = target.modelStr;
const provider = target.provider;
const profile = await getRuntimeProviderProfile(provider);
// Pre-check: skip models where all accounts are in cooldown
if (isModelAvailable) {
const available = await isModelAvailable(modelStr, target);
if (!available) {
log.info("COMBO", `Skipping ${modelStr} (all accounts in cooldown)`);
if (i > 0) fallbackCount++;
continue;
for (let setTry = 0; setTry <= maxSetRetries; setTry++) {
if (setTry > 0) {
log.info("COMBO", `All targets failed — retrying set (${setTry}/${maxSetRetries})`);
await new Promise((resolve) => {
const timer = setTimeout(resolve, setRetryDelayMs);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
resolve(undefined);
},
{ once: true }
);
});
if (signal?.aborted) {
log.info("COMBO", "Client disconnected during set retry delay — aborting");
return errorResponse(499, "Client disconnected");
}
}
// Retry loop for transient errors
for (let retry = 0; retry <= maxRetries; retry++) {
// Fix #1681: Bail out immediately if the client has disconnected
if (signal?.aborted) {
log.info("COMBO", `Client disconnected — aborting combo loop before model ${modelStr}`);
return errorResponse(499, "Client disconnected");
}
globalAttempts++;
if (globalAttempts > MAX_GLOBAL_ATTEMPTS) {
log.warn(
"COMBO",
`Maximum combo attempts (${MAX_GLOBAL_ATTEMPTS}) exceeded across all targets and fallbacks. Terminating loop to prevent runaway background requests.`
);
return errorResponse(503, "Maximum combo retry limit reached");
}
if (retry > 0) {
let lastError = null;
let earliestRetryAfter = null;
let lastStatus = null;
const startTime = Date.now();
let fallbackCount = 0;
let recordedAttempts = 0;
for (let i = 0; i < orderedTargets.length; i++) {
const target = orderedTargets[i];
const modelStr = target.modelStr;
const provider = target.provider;
const profile = await getRuntimeProviderProfile(provider);
// #1731: Skip targets from a provider that already signaled full quota exhaustion this request.
if (provider && exhaustedProviders.has(provider)) {
log.info(
"COMBO",
`Retrying ${modelStr} in ${retryDelayMs}ms (attempt ${retry + 1}/${maxRetries + 1})`
`Skipping ${modelStr} — provider ${provider} marked exhausted this request (#1731)`
);
await new Promise((resolve) => {
const timer = setTimeout(resolve, retryDelayMs);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
resolve(undefined);
},
{ once: true }
);
});
if (signal?.aborted) {
log.info("COMBO", `Client disconnected during retry delay — aborting`);
return errorResponse(499, "Client disconnected");
if (i > 0) fallbackCount++;
continue;
}
// Pre-check: skip models where all accounts are in cooldown
if (isModelAvailable) {
const available = await isModelAvailable(modelStr, target);
if (!available) {
log.info("COMBO", `Skipping ${modelStr} (all accounts in cooldown)`);
if (i > 0) fallbackCount++;
continue;
}
}
log.info(
"COMBO",
`Trying model ${i + 1}/${orderedTargets.length}: ${modelStr}${retry > 0 ? ` (retry ${retry})` : ""}`
);
const result = await handleSingleModelWrapped(body, modelStr, target);
// Success — validate response quality before returning
if (result.ok) {
const quality = await validateResponseQuality(result, clientRequestedStream, log);
if (!quality.valid) {
// Retry loop for transient errors
for (let retry = 0; retry <= maxRetries; retry++) {
// Fix #1681: Bail out immediately if the client has disconnected
if (signal?.aborted) {
log.info("COMBO", `Client disconnected — aborting combo loop before model ${modelStr}`);
return errorResponse(499, "Client disconnected");
}
globalAttempts++;
if (globalAttempts > MAX_GLOBAL_ATTEMPTS) {
log.warn(
"COMBO",
`Model ${modelStr} returned 200 but failed quality check: ${quality.reason}`
`Maximum combo attempts (${MAX_GLOBAL_ATTEMPTS}) exceeded across all targets and fallbacks. Terminating loop to prevent runaway background requests.`
);
return errorResponse(503, "Maximum combo retry limit reached");
}
if (retry > 0) {
log.info(
"COMBO",
`Retrying ${modelStr} in ${retryDelayMs}ms (attempt ${retry + 1}/${maxRetries + 1})`
);
await new Promise((resolve) => {
const timer = setTimeout(resolve, retryDelayMs);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
resolve(undefined);
},
{ once: true }
);
});
if (signal?.aborted) {
log.info("COMBO", `Client disconnected during retry delay — aborting`);
return errorResponse(499, "Client disconnected");
}
}
log.info(
"COMBO",
`Trying model ${i + 1}/${orderedTargets.length}: ${modelStr}${retry > 0 ? ` (retry ${retry})` : ""}`
);
const result = await handleSingleModelWrapped(body, modelStr, {
...target,
failoverBeforeRetry: config.failoverBeforeRetry,
});
// Success — validate response quality before returning
if (result.ok) {
const quality = await validateResponseQuality(result, clientRequestedStream, log);
if (!quality.valid) {
log.warn(
"COMBO",
`Model ${modelStr} returned 200 but failed quality check: ${quality.reason}`
);
recordComboRequest(combo.name, modelStr, {
success: false,
latencyMs: Date.now() - startTime,
fallbackCount,
strategy,
target: toRecordedTarget(target),
});
recordedAttempts++;
// Fix #1707: Set terminal state so the fallback doesn't emit
// misleading ALL_ACCOUNTS_INACTIVE when the real issue is quality.
lastError = `Upstream response failed quality validation: ${quality.reason}`;
if (!lastStatus) lastStatus = 502;
if (i > 0) fallbackCount++;
break; // move to next model
}
const latencyMs = Date.now() - startTime;
log.info(
"COMBO",
`Model ${modelStr} succeeded (${latencyMs}ms, ${fallbackCount} fallbacks)`
);
recordComboRequest(combo.name, modelStr, {
success: true,
latencyMs,
fallbackCount,
strategy,
target: toRecordedTarget(target),
});
recordedAttempts++;
// Context-relay intentionally splits responsibilities:
// combo.ts decides whether a successful turn should generate a handoff,
// while chat.ts injects the handoff after the real connectionId is resolved.
if (
strategy === "context-relay" &&
relayOptions?.sessionId &&
relayConfig &&
relayConfig.handoffProviders.includes(provider) &&
provider === "codex"
) {
const connectionId = getSessionConnection(relayOptions.sessionId);
if (connectionId) {
const quotaInfo = await fetchCodexQuota(connectionId).catch(() => null);
if (quotaInfo) {
const resetCandidates = [quotaInfo.window5h?.resetAt, quotaInfo.window7d?.resetAt]
.filter((value): value is string => typeof value === "string" && value.length > 0)
.sort((a, b) => a.localeCompare(b));
const handoffSourceMessages =
Array.isArray(body?.messages) && body.messages.length > 0
? body.messages
: Array.isArray(body?.input)
? body.input
: [];
maybeGenerateHandoff({
sessionId: relayOptions.sessionId,
comboName: combo.name,
connectionId,
percentUsed: quotaInfo.percentUsed,
messages: handoffSourceMessages,
model: modelStr,
expiresAt: resetCandidates[0] || null,
config: relayConfig,
handleSingleModel: handleSingleModelWrapped,
});
}
}
}
// Record last known good provider (LKGP) for this combo/model (#919)
if (provider) {
const connId = target.connectionId || undefined;
void (async () => {
try {
const { setLKGP } = await import("../../src/lib/localDb");
await Promise.all([
setLKGP(combo.name, target.executionKey, provider, connId),
setLKGP(combo.name, combo.id || combo.name, provider, connId),
]);
} catch (err) {
log.warn("COMBO", "Failed to record Last Known Good Provider. This is non-fatal.", {
err,
});
}
})();
}
return quality.clonedResponse ?? result;
}
// Extract error info from response
let errorText = result.statusText || "";
let errorBody = null;
let retryAfter = null;
try {
const cloned = result.clone();
try {
const text = await cloned.text();
if (text) {
errorText = text.substring(0, 500);
errorBody = JSON.parse(text);
errorText =
errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText;
retryAfter = errorBody?.retryAfter || null;
}
} catch {
/* Clone parse failed */
}
} catch {
/* Clone failed */
}
// Track earliest retryAfter
if (
retryAfter &&
(!earliestRetryAfter || new Date(retryAfter) < new Date(earliestRetryAfter))
) {
earliestRetryAfter = retryAfter;
}
// Normalize error text
if (typeof errorText !== "string") {
try {
errorText = JSON.stringify(errorText);
} catch {
errorText = String(errorText);
}
}
const isStreamReadinessFailure =
(result.status === 502 || result.status === 504) &&
isStreamReadinessFailureErrorBody(errorBody);
// Fix #1681: Status 499 means client disconnected — stop combo loop immediately.
// There is no point trying fallback models when nobody is listening.
if (result.status === 499) {
log.info("COMBO", `Client disconnected (499) during ${modelStr} — stopping combo loop`);
recordComboRequest(combo.name, modelStr, {
success: false,
latencyMs: Date.now() - startTime,
@@ -2038,134 +2251,56 @@ export async function handleComboChat({
target: toRecordedTarget(target),
});
recordedAttempts++;
// Fix #1707: Set terminal state so the fallback doesn't emit
// misleading ALL_ACCOUNTS_INACTIVE when the real issue is quality.
lastError = `Upstream response failed quality validation: ${quality.reason}`;
if (!lastStatus) lastStatus = 502;
if (i > 0) fallbackCount++;
break; // move to next model
return result;
}
const latencyMs = Date.now() - startTime;
log.info(
"COMBO",
`Model ${modelStr} succeeded (${latencyMs}ms, ${fallbackCount} fallbacks)`
// Combo fallback is target-level orchestration: a non-ok target response is
// treated as local to that target and the combo continues to the next target.
// Error classification is retained only for retry/cooldown pacing; it must
// not decide whether fallback happens, including for generic 400 responses.
const fallbackResult = checkFallbackError(
result.status,
errorText,
0,
null,
provider,
result.headers,
profile
);
recordComboRequest(combo.name, modelStr, {
success: true,
latencyMs,
fallbackCount,
strategy,
target: toRecordedTarget(target),
});
recordedAttempts++;
const { cooldownMs } = fallbackResult;
// Context-relay intentionally splits responsibilities:
// combo.ts decides whether a successful turn should generate a handoff,
// while chat.ts injects the handoff after the real connectionId is resolved.
// #1731: If the entire provider quota is exhausted, mark it so subsequent
// same-provider targets are skipped immediately.
if (provider && isProviderExhaustedReason(fallbackResult)) {
exhaustedProviders.add(provider);
log.info(
"COMBO",
`Provider ${provider} quota exhausted — marking for skip on remaining targets (#1731)`
);
}
// Trigger shared provider circuit breaker for 5xx errors and connection failures.
// If the next target in the combo is on the same provider, don't mark the provider
// as failed — different models on the same provider may still succeed.
const nextTarget = orderedTargets[i + 1];
const sameProviderNext =
typeof nextTarget?.provider === "string" && nextTarget.provider === provider;
if (
strategy === "context-relay" &&
relayOptions?.sessionId &&
relayConfig &&
relayConfig.handoffProviders.includes(provider) &&
provider === "codex"
!isStreamReadinessFailure &&
isProviderFailureCode(result.status) &&
!sameProviderNext
) {
const connectionId = getSessionConnection(relayOptions.sessionId);
if (connectionId) {
const quotaInfo = await fetchCodexQuota(connectionId).catch(() => null);
if (quotaInfo) {
const resetCandidates = [quotaInfo.window5h?.resetAt, quotaInfo.window7d?.resetAt]
.filter((value): value is string => typeof value === "string" && value.length > 0)
.sort((a, b) => a.localeCompare(b));
const handoffSourceMessages =
Array.isArray(body?.messages) && body.messages.length > 0
? body.messages
: Array.isArray(body?.input)
? body.input
: [];
maybeGenerateHandoff({
sessionId: relayOptions.sessionId,
comboName: combo.name,
connectionId,
percentUsed: quotaInfo.percentUsed,
messages: handoffSourceMessages,
model: modelStr,
expiresAt: resetCandidates[0] || null,
config: relayConfig,
handleSingleModel: handleSingleModelWrapped,
});
}
}
recordProviderFailure(provider, log, target.connectionId, profile);
}
// Record last known good provider (LKGP) for this combo/model (#919)
if (provider) {
const connId = target.connectionId || undefined;
void (async () => {
try {
const { setLKGP } = await import("../../src/lib/localDb");
await Promise.all([
setLKGP(combo.name, target.executionKey, provider, connId),
setLKGP(combo.name, combo.id || combo.name, provider, connId),
]);
} catch (err) {
log.warn("COMBO", "Failed to record Last Known Good Provider. This is non-fatal.", {
err,
});
}
})();
// Check if this is a transient error worth retrying on same model
const isTransient =
!isStreamReadinessFailure && [408, 429, 500, 502, 503, 504].includes(result.status);
if (retry < maxRetries && isTransient) {
continue; // Retry same model
}
return quality.clonedResponse ?? result;
}
// Extract error info from response
let errorText = result.statusText || "";
let errorBody = null;
let retryAfter = null;
try {
const cloned = result.clone();
try {
const text = await cloned.text();
if (text) {
errorText = text.substring(0, 500);
errorBody = JSON.parse(text);
errorText =
errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText;
retryAfter = errorBody?.retryAfter || null;
}
} catch {
/* Clone parse failed */
}
} catch {
/* Clone failed */
}
// Track earliest retryAfter
if (
retryAfter &&
(!earliestRetryAfter || new Date(retryAfter) < new Date(earliestRetryAfter))
) {
earliestRetryAfter = retryAfter;
}
// Normalize error text
if (typeof errorText !== "string") {
try {
errorText = JSON.stringify(errorText);
} catch {
errorText = String(errorText);
}
}
const isStreamReadinessFailure =
(result.status === 502 || result.status === 504) &&
isStreamReadinessFailureErrorBody(errorBody);
// Fix #1681: Status 499 means client disconnected — stop combo loop immediately.
// There is no point trying fallback models when nobody is listening.
if (result.status === 499) {
log.info("COMBO", `Client disconnected (499) during ${modelStr} — stopping combo loop`);
// Done retrying this model
recordComboRequest(combo.name, modelStr, {
success: false,
latencyMs: Date.now() - startTime,
@@ -2174,109 +2309,76 @@ export async function handleComboChat({
target: toRecordedTarget(target),
});
recordedAttempts++;
return result;
}
lastError = errorText || String(result.status);
if (!lastStatus) lastStatus = result.status;
if (i > 0) fallbackCount++;
log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status });
// Combo fallback is target-level orchestration: a non-ok target response is
// treated as local to that target and the combo continues to the next target.
// Error classification is retained only for retry/cooldown pacing; it must
// not decide whether fallback happens, including for generic 400 responses.
const { cooldownMs } = checkFallbackError(
result.status,
errorText,
0,
null,
provider,
result.headers,
profile
);
// Trigger shared provider circuit breaker for 5xx errors and connection failures
if (!isStreamReadinessFailure && isProviderFailureCode(result.status)) {
recordProviderFailure(provider, log, target.connectionId, profile);
}
// Check if this is a transient error worth retrying on same model
const isTransient =
!isStreamReadinessFailure && [408, 429, 500, 502, 503, 504].includes(result.status);
if (retry < maxRetries && isTransient) {
continue; // Retry same model
}
// Done retrying this model
recordComboRequest(combo.name, modelStr, {
success: false,
latencyMs: Date.now() - startTime,
fallbackCount,
strategy,
target: toRecordedTarget(target),
});
recordedAttempts++;
lastError = errorText || String(result.status);
if (!lastStatus) lastStatus = result.status;
if (i > 0) fallbackCount++;
log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status });
const fallbackWaitMs =
fallbackDelayMs > 0 && cooldownMs > 0 && cooldownMs <= MAX_FALLBACK_WAIT_MS
? Math.min(cooldownMs, fallbackDelayMs)
: 0;
if ([502, 503, 504].includes(result.status) && fallbackWaitMs > 0) {
log.info("COMBO", `Waiting ${fallbackWaitMs}ms before fallback to next model`);
await new Promise((resolve) => {
const timer = setTimeout(resolve, fallbackWaitMs);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
resolve(undefined);
},
{ once: true }
);
});
if (signal?.aborted) {
log.info("COMBO", `Client disconnected during fallback wait — aborting`);
return errorResponse(499, "Client disconnected");
const fallbackWaitMs =
fallbackDelayMs > 0 && cooldownMs > 0 && cooldownMs <= MAX_FALLBACK_WAIT_MS
? Math.min(cooldownMs, fallbackDelayMs)
: 0;
if ([502, 503, 504].includes(result.status) && fallbackWaitMs > 0) {
log.info("COMBO", `Waiting ${fallbackWaitMs}ms before fallback to next model`);
await new Promise((resolve) => {
const timer = setTimeout(resolve, fallbackWaitMs);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
resolve(undefined);
},
{ once: true }
);
});
if (signal?.aborted) {
log.info("COMBO", `Client disconnected during fallback wait — aborting`);
return errorResponse(499, "Client disconnected");
}
}
break; // Move to next model
}
break; // Move to next model
}
// All models failed in this set try
const latencyMs = Date.now() - startTime;
if (recordedAttempts === 0) {
recordComboRequest(combo.name, null, { success: false, latencyMs, fallbackCount, strategy });
}
// Retry the entire set if more attempts remain
if (setTry < maxSetRetries) continue;
// All set retries exhausted — return the final error
if (!lastStatus) {
return new Response(
JSON.stringify({
error: {
message: "Service temporarily unavailable: all upstream accounts are inactive",
type: "service_unavailable",
code: "ALL_ACCOUNTS_INACTIVE",
},
}),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
const status = lastStatus;
const msg = lastError || "All combo models unavailable";
if (earliestRetryAfter) {
const retryHuman = formatRetryAfter(earliestRetryAfter);
log.warn("COMBO", `All models failed | ${msg} (${retryHuman})`);
return unavailableResponse(status, msg, earliestRetryAfter, retryHuman);
}
log.warn("COMBO", `All models failed | ${msg}`);
return new Response(JSON.stringify({ error: { message: msg } }), {
status,
headers: { "Content-Type": "application/json" },
});
}
// All models failed
const latencyMs = Date.now() - startTime;
if (recordedAttempts === 0) {
recordComboRequest(combo.name, null, { success: false, latencyMs, fallbackCount, strategy });
}
if (!lastStatus) {
return new Response(
JSON.stringify({
error: {
message: "Service temporarily unavailable: all upstream accounts are inactive",
type: "service_unavailable",
code: "ALL_ACCOUNTS_INACTIVE",
},
}),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
const status = lastStatus;
const msg = lastError || "All combo models unavailable";
if (earliestRetryAfter) {
const retryHuman = formatRetryAfter(earliestRetryAfter);
log.warn("COMBO", `All models failed | ${msg} (${retryHuman})`);
return unavailableResponse(status, msg, earliestRetryAfter, retryHuman);
}
log.warn("COMBO", `All models failed | ${msg}`);
return new Response(JSON.stringify({ error: { message: msg } }), {
status,
headers: { "Content-Type": "application/json" },
});
}
/**
@@ -2330,6 +2432,11 @@ async function handleRoundRobinCombo({
let fallbackCount = 0;
let recordedAttempts = 0;
// #1731: Per-request in-memory set of providers whose quota is fully exhausted.
// When a target returns a quota-exhausted 429, remaining targets from the same
// provider are skipped to avoid the cascade through N same-provider targets.
const exhaustedProviders = new Set<string>();
// Try each model starting from the round-robin target
for (let offset = 0; offset < modelCount; offset++) {
const modelIndex = (startIndex + offset) % modelCount;
@@ -2349,6 +2456,17 @@ async function handleRoundRobinCombo({
}
}
// #1731: Skip targets from a provider that already signaled full quota exhaustion
// this request.
if (provider && exhaustedProviders.has(provider)) {
log.info(
"COMBO-RR",
`Skipping ${modelStr} — provider ${provider} marked exhausted this request (#1731)`
);
if (offset > 0) fallbackCount++;
continue;
}
// Acquire semaphore slot (may wait in queue)
let release;
try {
@@ -2392,7 +2510,10 @@ async function handleRoundRobinCombo({
`[RR #${counter}] → ${modelStr}${offset > 0 ? ` (fallback +${offset})` : ""}${retry > 0 ? ` (retry ${retry})` : ""}`
);
const result = await handleSingleModel(body, modelStr, target);
const result = await handleSingleModel(body, modelStr, {
...target,
failoverBeforeRetry: config.failoverBeforeRetry,
});
// Success — validate response quality before returning
if (result.ok) {
@@ -2522,7 +2643,7 @@ async function handleRoundRobinCombo({
// strategies: non-ok target responses fall through to the next target.
// Classification stays here only to support cooldown/semaphore pacing,
// not to decide whether fallback is allowed.
const { cooldownMs } = checkFallbackError(
const fallbackResult = checkFallbackError(
result.status,
errorText,
0,
@@ -2531,6 +2652,14 @@ async function handleRoundRobinCombo({
result.headers,
profile
);
const { cooldownMs } = fallbackResult;
// #1731: If the entire provider quota is exhausted, mark it so subsequent
// same-provider targets are skipped immediately.
if (provider && isProviderExhaustedReason(fallbackResult)) {
exhaustedProviders.add(provider);
log.info("COMBO-RR", `Provider ${provider} quota exhausted — marking for skip (#1731)`);
}
const isAllAccountsRateLimited = isAllAccountsRateLimitedResponse(
result.status,
@@ -2553,6 +2682,10 @@ async function handleRoundRobinCombo({
"COMBO-RR",
`All accounts rate-limited for ${modelStr}, falling back to next model`
);
// #1731: All-accounts-rate-limited 503 also counts as provider exhaustion
if (provider) {
exhaustedProviders.add(provider);
}
}
// Transient error → retry same model

View File

@@ -23,6 +23,9 @@ const DEFAULT_COMBO_CONFIG = {
resetAwareWeeklyWeight: 0.65,
resetAwareTieBandPercent: 5,
resetAwareExhaustionGuardPercent: 10,
failoverBeforeRetry: false,
maxSetRetries: 0,
setRetryDelayMs: 2000,
};
const LEGACY_COMBO_RESILIENCE_KEYS = new Set([

View File

@@ -14,6 +14,7 @@ interface ErrorResponseBody {
type?: string;
code?: string;
};
upstream_details?: Record<string, unknown> | null; // sanitized upstream provider body
}
// Length cap protects against pathological inputs even before tokenization.
@@ -56,21 +57,66 @@ export function sanitizeErrorMessage(message: unknown): string {
return parts.join("");
}
const BLOCKED_KEYS = /stack|trace|path|file|cwd|dir|password|secret|token|key/i;
const MAX_DEPTH = 4;
/**
* Recursively sanitize an arbitrary JSON value from an upstream provider body.
* - Strings: run through sanitizeErrorMessage (strips stacks + absolute paths).
* - Keys matching BLOCKED_KEYS are dropped (credential/path guards).
* - Depth capped at MAX_DEPTH to prevent pathological nesting.
* - Arrays capped at 32 elements.
* - Returns null for null/undefined/non-JSON-serializable values.
*/
export function sanitizeUpstreamDetails(value: unknown, depth = 0): unknown {
if (depth > MAX_DEPTH) return "[truncated]";
if (value === null || value === undefined) return null;
if (typeof value === "string") return sanitizeErrorMessage(value);
if (typeof value === "number" || typeof value === "boolean") return value;
if (Array.isArray(value)) {
return value.slice(0, 32).map((v) => sanitizeUpstreamDetails(v, depth + 1));
}
if (typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (BLOCKED_KEYS.test(k)) continue;
out[k] = sanitizeUpstreamDetails(v, depth + 1);
}
return out;
}
return null;
}
/**
* Build OpenAI-compatible error response body. Message is always sanitized
* so callers do not need to remember to strip stack traces themselves.
* Optional third argument `upstreamDetails` (raw parsed provider body) is
* sanitized by sanitizeUpstreamDetails before inclusion as `upstream_details`.
*/
export function buildErrorBody(statusCode: number, message: string): ErrorResponseBody {
export function buildErrorBody(
statusCode: number,
message: string,
upstreamDetails?: unknown
): ErrorResponseBody {
const errorInfo = getErrorInfo(statusCode);
const safeMessage = sanitizeErrorMessage(message) || getDefaultErrorMessage(statusCode);
return {
const body: ErrorResponseBody = {
error: {
message: safeMessage,
type: errorInfo.type,
code: errorInfo.code,
},
};
if (upstreamDetails !== undefined && upstreamDetails !== null) {
const sanitized = sanitizeUpstreamDetails(upstreamDetails);
if (sanitized !== null && typeof sanitized === "object" && !Array.isArray(sanitized)) {
body.upstream_details = sanitized as Record<string, unknown>;
}
}
return body;
}
/**
@@ -255,9 +301,10 @@ export function createErrorResult(
message: string,
retryAfterMs: number | null = null,
errorCode?: string,
errorType?: string
errorType?: string,
upstreamDetails?: unknown
) {
const body = buildErrorBody(statusCode, message);
const body = buildErrorBody(statusCode, message, upstreamDetails);
if (errorCode) {
body.error.code = errorCode;
}

View File

@@ -113,6 +113,7 @@
"test:protocols:e2e": "node scripts/dev/run-protocol-clients-tests.mjs",
"test:vitest": "vitest run --config vitest.mcp.config.ts",
"test:ecosystem": "node scripts/dev/run-ecosystem-tests.mjs",
"test:system": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --test --test-force-exit --test-concurrency=1 tests/e2e/system-failover.test.ts",
"test:coverage": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true c8 --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 75 --lines 75 --functions 75 --branches 70 node --import tsx --test --test-concurrency=1 tests/unit/*.test.ts",
"test:coverage:legacy": "c8 --output-dir coverage --exclude=open-sse --check-coverage --lines 50 --functions 50 --branches 50 node --import tsx --test tests/unit/*.test.ts",
"coverage:report": "c8 report --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov",

View File

@@ -27,7 +27,7 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { PUBLISHED_BUILD_ARCH, PUBLISHED_BUILD_PLATFORM } from "./native-binary-compat.mjs";
import { hasStandaloneAppBundle } from "./postinstallSupport.mjs";
import { hasStandaloneAppBundle, isTermux } from "./postinstallSupport.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -136,7 +136,7 @@ async function fixBetterSqliteBinary() {
const { execSync } = await import("node:child_process");
// On Android/Termux, rebuild from source with --build-from-source flag
const isAndroid = process.platform === "android";
const isAndroid = process.platform === "android" || isTermux();
const rebuildCmd = isAndroid
? "npm install better-sqlite3 --build-from-source --force"
: "npm rebuild better-sqlite3";
@@ -196,8 +196,13 @@ async function fixBetterSqliteBinary() {
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/1634
*/
async function fixWreqJsBinary() {
if (process.platform === "android") {
console.log(" [postinstall] wreq-js: skipped on android (unsupported platform)");
// wreq-js native module is not loadable in Termux (libgcc path mismatch).
// The runtime already falls back gracefully when wreq-js is unavailable.
if (process.platform === "android" || isTermux()) {
console.log(
" [postinstall] wreq-js: skipped on Termux/Android " +
"(libgcc not available — OAuth TLS fingerprinting will use the fallback path)"
);
return;
}

View File

@@ -14,3 +14,25 @@ import { join } from "node:path";
export function hasStandaloneAppBundle(rootDir) {
return existsSync(join(rootDir, "app", "server.js"));
}
/**
* Returns true when running inside a Termux environment on Android.
*
* Node.js on Termux reports process.platform === "linux" (not "android"),
* so OS-level platform checks are insufficient. Use Termux-specific signals:
* 1. TERMUX_VERSION env var (set by Termux bootstrap, most reliable)
* 2. PREFIX env var containing "com.termux"
* 3. Filesystem probe at /data/data/com.termux (last resort, no env needed)
*
* @param {object} [env] Override process.env for testing.
* @returns {boolean}
*/
export function isTermux(env = process.env) {
if (env.TERMUX_VERSION) return true;
if (typeof env.PREFIX === "string" && env.PREFIX.includes("com.termux")) return true;
try {
return existsSync("/data/data/com.termux");
} catch {
return false;
}
}

View File

@@ -156,6 +156,12 @@ const ADVANCED_FIELD_HELP_FALLBACK = {
"Round-robin combo/model limit: max simultaneous requests sent to each model target. This is separate from any provider account-only cap.",
queueTimeout:
"How long a request can wait for a round-robin model slot before timing out. This queue is separate from any account-only concurrency cap.",
failoverBeforeRetry:
"When enabled, a 429 from the upstream triggers immediate target failover instead of retrying the same URL first.",
maxSetRetries:
"Number of times to retry the full target set when every target fails. 0 = no set-level retry.",
setRetryDelayMs:
"Delay between set-level retry attempts, giving transient issues time to resolve.",
};
const LEGACY_COMBO_RESILIENCE_KEYS = new Set([
@@ -1427,7 +1433,7 @@ function FieldLabelWithHelp({ label, help, showHelp = true }) {
<div className="flex items-center gap-1 mb-0.5">
<label className="text-[10px] text-text-muted">{label}</label>
{showHelp && (
<Tooltip content={help}>
<Tooltip position="bottom" content={help}>
<span className="material-symbols-outlined text-[12px] text-text-muted cursor-help">
help
</span>
@@ -3540,6 +3546,94 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
/>
</div>
</div>
{/* failoverBeforeRetry + maxSetRetries + setRetryDelayMs */}
<div className="grid grid-cols-2 gap-2 pt-2 border-t border-black/5 dark:border-white/5">
<div className="col-span-2">
<div className="flex items-center gap-2 py-1">
<input
type="checkbox"
id="failoverBeforeRetry"
checked={!!config.failoverBeforeRetry}
onChange={(e) =>
setConfig({
...config,
failoverBeforeRetry: e.target.checked || undefined,
})
}
className="w-3.5 h-3.5 rounded border border-black/20 dark:border-white/20 accent-primary cursor-pointer"
/>
<label
htmlFor="failoverBeforeRetry"
className="text-xs text-text-muted cursor-pointer select-none"
>
{t("failoverBeforeRetry")}
</label>
<Tooltip
position="bottom"
content={getI18nOrFallback(
t,
"advancedHelp.failoverBeforeRetry",
ADVANCED_FIELD_HELP_FALLBACK.failoverBeforeRetry
)}
>
<span className="material-symbols-outlined text-[12px] text-text-muted cursor-help">
help
</span>
</Tooltip>
</div>
</div>
<div>
<FieldLabelWithHelp
label={t("maxSetRetries")}
help={getI18nOrFallback(
t,
"advancedHelp.maxSetRetries",
ADVANCED_FIELD_HELP_FALLBACK.maxSetRetries
)}
showHelp={!isExpertMode}
/>
<input
type="number"
min="0"
max="10"
value={config.maxSetRetries ?? ""}
placeholder="0"
onChange={(e) =>
setConfig({
...config,
maxSetRetries: e.target.value ? Number(e.target.value) : undefined,
})
}
className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none"
/>
</div>
<div>
<FieldLabelWithHelp
label={t("setRetryDelayMs")}
help={getI18nOrFallback(
t,
"advancedHelp.setRetryDelayMs",
ADVANCED_FIELD_HELP_FALLBACK.setRetryDelayMs
)}
showHelp={!isExpertMode}
/>
<input
type="number"
min="0"
max="60000"
step="500"
value={config.setRetryDelayMs ?? ""}
placeholder="2000"
onChange={(e) =>
setConfig({
...config,
setRetryDelayMs: e.target.value ? Number(e.target.value) : undefined,
})
}
className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none"
/>
</div>
</div>
{strategy === "round-robin" && (
<div className="grid grid-cols-2 gap-2 pt-2 border-t border-black/5 dark:border-white/5">
<div>

View File

@@ -1060,6 +1060,10 @@ export default function ProviderDetailPage() {
>({});
const [importingModels, setImportingModels] = useState(false);
const [importingZed, setImportingZed] = useState(false);
const [showZedManual, setShowZedManual] = useState(false);
const [zedManualProvider, setZedManualProvider] = useState("openai");
const [zedManualToken, setZedManualToken] = useState("");
const [importingZedManual, setImportingZedManual] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const [importProgress, setImportProgress] = useState({
current: 0,
@@ -1340,6 +1344,9 @@ export default function ProviderDetailPage() {
const res = await fetch("/api/providers/zed/import", { method: "POST" });
const data = await res.json();
if (!res.ok || !data.success) {
if (data.zedDockerEnvironment) {
setShowZedManual(true);
}
notify.error(data.error || "Zed import failed");
} else if (!data.count) {
const found = data.credentials?.length ?? 0;
@@ -1363,6 +1370,30 @@ export default function ProviderDetailPage() {
}
}, [importingZed, notify, fetchConnections]);
const handleZedManualImport = useCallback(async () => {
if (importingZedManual || !zedManualToken.trim()) return;
setImportingZedManual(true);
try {
const res = await fetch("/api/providers/zed/manual-import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: zedManualProvider, token: zedManualToken.trim() }),
});
const data = await res.json();
if (!res.ok || !data.success) {
notify.error(data.error?.message ?? data.error ?? "Manual import failed");
} else {
notify.success(`Imported ${zedManualProvider} token from Zed`);
setZedManualToken("");
await fetchConnections();
}
} catch (e: any) {
notify.error(e?.message || "Manual import failed");
} finally {
setImportingZedManual(false);
}
}, [importingZedManual, zedManualProvider, zedManualToken, notify, fetchConnections]);
useEffect(() => {
if (providerId !== "codex") return;
fetch("/api/settings", { cache: "no-store" })
@@ -3333,30 +3364,89 @@ export default function ProviderDetailPage() {
</div>
{providerId === "zed" && (
<Card>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold flex items-center gap-2">
<span className="material-symbols-outlined text-[20px]">download</span>
Import from Zed Keychain
</h2>
<p className="text-sm text-text-muted mt-1">
Discover AI provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) that Zed
IDE stored in the OS keychain and import them as connections. Requires Zed IDE
installed on this machine.
</p>
<>
<Card>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold flex items-center gap-2">
<span className="material-symbols-outlined text-[20px]">download</span>
Import from Zed Keychain
</h2>
<p className="text-sm text-text-muted mt-1">
Discover AI provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) that
Zed IDE stored in the OS keychain and import them as connections. Requires Zed IDE
installed on this machine.
</p>
</div>
<Button
size="sm"
variant="secondary"
icon={importingZed ? "sync" : "download"}
onClick={handleZedImport}
disabled={importingZed}
>
{importingZed ? "Importing…" : "Import from Zed"}
</Button>
</div>
<Button
size="sm"
variant="secondary"
icon={importingZed ? "sync" : "download"}
onClick={handleZedImport}
disabled={importingZed}
>
{importingZed ? "Importing…" : "Import from Zed"}
</Button>
</div>
</Card>
</Card>
<Card>
<div className="flex flex-col gap-3">
<button
className="flex items-center justify-between w-full text-left"
onClick={() => setShowZedManual((v) => !v)}
>
<h2 className="text-lg font-semibold flex items-center gap-2">
<span className="material-symbols-outlined text-[20px]">edit</span>
Manual Token Import
</h2>
<span className="material-symbols-outlined text-[18px] text-text-muted">
{showZedManual ? "expand_less" : "expand_more"}
</span>
</button>
{showZedManual && (
<div className="flex flex-col gap-3 mt-1">
<p className="text-sm text-text-muted">
Use this when OmniRoute runs in Docker or the keychain is unavailable. Paste the
API key that Zed stored under{" "}
<code className="font-mono text-xs">~/.config/zed/settings.json</code> or copy
it from the Zed AI settings panel.
</p>
<div className="flex gap-2 flex-col sm:flex-row">
<select
className="input input-sm"
value={zedManualProvider}
onChange={(e) => setZedManualProvider(e.target.value)}
>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="google">Google</option>
<option value="mistral">Mistral</option>
<option value="xai">xAI</option>
<option value="openrouter">OpenRouter</option>
<option value="deepseek">DeepSeek</option>
</select>
<input
type="password"
className="input input-sm flex-1"
placeholder="Paste API key…"
value={zedManualToken}
onChange={(e) => setZedManualToken(e.target.value)}
/>
<Button
size="sm"
variant="secondary"
icon={importingZedManual ? "sync" : "upload"}
onClick={handleZedManualImport}
disabled={importingZedManual || !zedManualToken.trim()}
>
{importingZedManual ? "Saving…" : "Import"}
</Button>
</div>
</div>
)}
</div>
</Card>
</>
)}
{isCompatible && providerNode && (

View File

@@ -8,8 +8,35 @@ export async function OPTIONS() {
/**
* GET /api/gamification/federation/leaderboard — Serve leaderboard for federation
*
* Requires bearer token authentication against community_servers.
*/
export async function GET(request: NextRequest) {
// Authenticate: validate bearer token against community_servers
const authHeader = request.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
return NextResponse.json(
{ error: "Missing authorization" },
{ status: 401, headers: CORS_HEADERS }
);
}
const token = authHeader.slice(7);
const crypto = await import("crypto");
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
const { getDbInstance } = await import("@/lib/db/core");
const db = getDbInstance();
const server = db
.prepare("SELECT id FROM community_servers WHERE api_key_hash = ? AND status = 'connected'")
.get(tokenHash) as { id: string } | undefined;
if (!server) {
return NextResponse.json(
{ error: "Invalid or unauthorized token" },
{ status: 403, headers: CORS_HEADERS }
);
}
const url = new URL(request.url);
const scope: LeaderboardScope = (url.searchParams.get("scope") || "global") as LeaderboardScope;
const limit = Number(url.searchParams.get("limit") || 100);

View File

@@ -222,6 +222,26 @@ async function saveAndRespond(
if (result.region) providerSpecificData.region = result.region;
if (profileArn) providerSpecificData.profileArn = profileArn;
// For the SSO-cache fallback path the token came from ~/.aws/sso/cache and has no
// per-connection OIDC client. Register one now so this connection gets an isolated
// refresh session (#2328). The SQLite path already sets result.clientId.
if (!result.clientId) {
try {
const reg = await runWithProxyContext(proxy, () => kiroService.registerClient());
providerSpecificData.clientId = reg.clientId;
providerSpecificData.clientSecret = reg.clientSecret;
providerSpecificData.region = "us-east-1";
if (reg.clientSecretExpiresAt) {
providerSpecificData.clientSecretExpiresAt = reg.clientSecretExpiresAt;
}
} catch (err) {
console.warn(
"[kiro auto-import] registerClient failed, continuing without isolated client:",
err
);
}
}
// Refresh token to get a fresh access token and confirm it works
const refreshed = await runWithProxyContext(proxy, () =>
kiroService.refreshToken(refreshToken, providerSpecificData)

View File

@@ -44,16 +44,18 @@ export async function POST(request: Request) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { refreshToken } = validation.data;
const { refreshToken, region } = validation.data;
const kiroService = new KiroService();
// Resolve proxy for this provider (provider-level → global → direct)
const proxy = await resolveProxyForProvider(targetProvider);
// Validate and refresh token (through proxy if configured)
// Validate and refresh token (through proxy if configured).
// validateImportToken also calls registerClient() to obtain a per-connection OIDC
// client pair so multiple Kiro accounts do not share a single backend session (#2328).
const tokenData = await runWithProxyContext(proxy, () =>
kiroService.validateImportToken(refreshToken.trim())
kiroService.validateImportToken(refreshToken.trim(), region)
);
// Extract email from JWT if available
@@ -71,6 +73,16 @@ export async function POST(request: Request) {
profileArn: tokenData.profileArn,
authMethod: "imported",
provider: "Imported",
...(tokenData.clientId
? {
clientId: tokenData.clientId,
clientSecret: tokenData.clientSecret,
region,
...(tokenData.clientSecretExpiresAt
? { clientSecretExpiresAt: tokenData.clientSecretExpiresAt }
: {}),
}
: {}),
},
testStatus: "active",
});

View File

@@ -44,6 +44,20 @@ export async function POST(request: Request) {
// Exchange code for tokens (redirect_uri handled internally)
const tokenData = await kiroService.exchangeSocialCode(code, codeVerifier);
// Register an independent OIDC client for this connection so multiple Kiro accounts
// do not share a single backend session (#2328). Failure is non-fatal; the
// connection will degrade to the shared social-auth refresh path.
let oidcRegistration: {
clientId?: string;
clientSecret?: string;
clientSecretExpiresAt?: number;
} = {};
try {
oidcRegistration = await kiroService.registerClient();
} catch (err) {
console.warn("[kiro social-exchange] registerClient failed, continuing without it:", err);
}
// Extract email from JWT if available
const email = kiroService.extractEmailFromJWT(tokenData.accessToken);
@@ -59,6 +73,16 @@ export async function POST(request: Request) {
profileArn: tokenData.profileArn,
authMethod: provider, // "google" or "github"
provider: provider.charAt(0).toUpperCase() + provider.slice(1),
...(oidcRegistration.clientId
? {
clientId: oidcRegistration.clientId,
clientSecret: oidcRegistration.clientSecret,
region: "us-east-1",
...(oidcRegistration.clientSecretExpiresAt
? { clientSecretExpiresAt: oidcRegistration.clientSecretExpiresAt }
: {}),
}
: {}),
},
testStatus: "active",
});

View File

@@ -14,6 +14,7 @@ import { discoverZedCredentials, isZedInstalled } from "@/lib/zed-oauth/keychain
import { partitionZedCredentials } from "@/lib/zed-oauth/importUtils";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { createProviderConnection } from "@/lib/db/providers";
import { isRunningInDocker } from "@/lib/zed-oauth/dockerDetect";
interface ImportResponse {
success: boolean;
@@ -27,6 +28,7 @@ interface ImportResponse {
}>;
error?: string;
zedInstalled?: boolean;
zedDockerEnvironment?: boolean;
}
export async function POST(request: Request): Promise<NextResponse<ImportResponse> | Response> {
@@ -35,7 +37,24 @@ export async function POST(request: Request): Promise<NextResponse<ImportRespons
if (authError) return authError;
try {
// Check if Zed is installed
// Docker environments cannot access the host OS keychain or filesystem.
// Surface a clear error directing users to the manual import tab.
const inDocker = isRunningInDocker();
if (inDocker) {
return NextResponse.json(
{
success: false,
error:
"OmniRoute is running inside Docker and cannot access the host keychain. " +
"Use the Manual Token Import tab to paste your API key directly.",
zedInstalled: false,
zedDockerEnvironment: true,
},
{ status: 422 }
);
}
// Check if Zed is installed (non-Docker path)
const zedInstalled = await isZedInstalled();
if (!zedInstalled) {
@@ -44,6 +63,7 @@ export async function POST(request: Request): Promise<NextResponse<ImportRespons
success: false,
error: "Zed IDE does not appear to be installed on this system.",
zedInstalled: false,
zedDockerEnvironment: false,
},
{ status: 404 }
);
@@ -82,7 +102,7 @@ export async function POST(request: Request): Promise<NextResponse<ImportRespons
});
savedCount++;
} catch (err) {
console.error(`[Zed Import] Failed to save credential for ${cred.provider}:`, err);
console.error("[Zed Import] Failed to save credential for %s:", cred.provider, err);
}
}

View File

@@ -0,0 +1,60 @@
/**
* POST /api/providers/zed/manual-import
*
* Accepts a manually-pasted Zed API token for a specific provider.
* Intended for Docker/headless deployments where keychain access is unavailable.
*
* Security: protected by requireManagementAuth.
*/
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { createProviderConnection } from "@/lib/db/providers";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
const manualImportSchema = z.object({
provider: z.string().min(1).max(64),
token: z.string().min(1).max(512),
label: z.string().max(128).optional(),
});
export async function POST(request: Request): Promise<NextResponse> {
const authError = await requireManagementAuth(request);
if (authError) return authError as NextResponse;
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(buildErrorBody(400, "Invalid JSON body"), { status: 400 });
}
const parsed = manualImportSchema.safeParse(rawBody);
if (!parsed.success) {
return NextResponse.json(
buildErrorBody(
400,
"Validation failed: " + parsed.error.issues.map((i) => i.message).join(", ")
),
{ status: 400 }
);
}
const { provider, token, label } = parsed.data;
try {
const connection = await createProviderConnection({
provider,
authType: "apikey",
apiKey: token,
name: label ?? `Zed Manual Import (${provider})`,
isActive: true,
});
return NextResponse.json({ success: true, connectionId: connection.id, provider });
} catch (err: unknown) {
console.error("[Zed Manual Import] Failed to save credential:", err);
return NextResponse.json(buildErrorBody(500, "Failed to save credential"), { status: 500 });
}
}

View File

@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
/**
* POST /api/resilience/reset — Reset all provider circuit breakers
* POST /api/resilience/reset — Reset all provider circuit breakers and model lockouts
*/
export async function POST() {
try {
@@ -17,10 +17,15 @@ export async function POST() {
resetCount++;
}
// Also clear in-memory model lockouts (per-model quota cooldowns)
const { clearAllModelLockouts } =
await import("@omniroute/open-sse/services/accountFallback.ts");
clearAllModelLockouts();
return NextResponse.json({
ok: true,
resetCount,
message: `Reset ${resetCount} circuit breaker(s)`,
message: `Reset ${resetCount} circuit breaker(s) and model lockouts`,
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to reset resilience state";

View File

@@ -1859,6 +1859,9 @@
"contextRelaySummaryModelHelp": "Optional override model used only for generating the handoff summary. Leave empty to reuse the active combo model.",
"contextRelayProviderNote": "Context Relay currently generates handoffs for Codex account rotation. Pair it with multiple accounts of the same provider for the best continuity.",
"advancedHint": "Leave empty to use global defaults. These override per-provider settings.",
"failoverBeforeRetry": "Failover Before Retry",
"maxSetRetries": "Max Set Retries",
"setRetryDelayMs": "Set Retry Delay (ms)",
"moveUp": "Move up",
"moveDown": "Move down",
"removeModel": "Remove",
@@ -1948,7 +1951,10 @@
"timeout": "Maximum request duration before aborting.",
"healthcheck": "Skips unhealthy models/providers from routing decisions.",
"concurrencyPerModel": "Max simultaneous requests allowed per model in round-robin.",
"queueTimeout": "How long a request can wait in queue before timing out."
"queueTimeout": "How long a request can wait in queue before timing out.",
"failoverBeforeRetry": "When enabled, any upstream error triggers immediate failover to the next combo target, skipping all retries and fallback URLs.",
"maxSetRetries": "Number of times to retry the full target set when every target fails. 0 = no set-level retry.",
"setRetryDelayMs": "Delay between set-level retry attempts, giving transient issues time to resolve."
},
"templatesTitle": "Quick templates",
"templatesDescription": "Apply a starting profile, then adjust models and config.",
@@ -3525,6 +3531,8 @@
"bailianBaseUrlHint": "Bailian Base Url Hint",
"blackboxWebCookieHint": "Blackbox Web Cookie Hint",
"blackboxWebCookiePlaceholder": "Blackbox Web Cookie Placeholder",
"t3ChatWebCookieHint": "Open t3.chat → DevTools → Application → Local Storage → https://t3.chat, copy 'convex-session-id'. Then open DevTools → Network, copy the full Cookie header from any chat request. Paste both values in the fields below.",
"t3ChatWebCookiePlaceholder": "convex-session-id=abc123...",
"blockClaudeExtraUsageDescription": "Hide extra Claude usage rows reported by some providers when they duplicate primary token accounting.",
"blockClaudeExtraUsageLabel": "Block duplicate Claude usage rows",
"bulkPasteAdded": "{count, plural, one {1 key added} other {# keys added}}",

View File

@@ -6,6 +6,7 @@
*/
import { getDbInstance } from "./core";
import { calculateLevel } from "../gamification/xp";
// ──────────────── Types ────────────────
@@ -130,13 +131,13 @@ export function getRank(apiKeyId: string, scope: string): number {
return rankRow.rank;
}
export function getTopN(scope: string, limit: number): LeaderboardRow[] {
export function getTopN(scope: string, limit: number, offset: number = 0): LeaderboardRow[] {
const rows = db()
.prepare(
`SELECT api_key_id, scope, score, updated_at FROM leaderboard
WHERE scope = ? ORDER BY score DESC LIMIT ?`
WHERE scope = ? ORDER BY score DESC LIMIT ? OFFSET ?`
)
.all(scope, limit) as Array<{
.all(scope, limit, offset) as Array<{
api_key_id: string;
scope: string;
score: number;
@@ -163,11 +164,11 @@ export function addXp(apiKeyId: string, action: string, amount: number, metadata
db()
.prepare(
`INSERT INTO user_levels (api_key_id, total_xp, current_level, updated_at)
VALUES (?, ?, 1, datetime('now'))
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(api_key_id)
DO UPDATE SET total_xp = total_xp + excluded.total_xp, updated_at = datetime('now')`
)
.run(apiKeyId, amount);
.run(apiKeyId, amount, calculateLevel(amount));
}
export function getXp(apiKeyId: string): UserLevelRow | null {

View File

@@ -85,15 +85,56 @@ export async function getAnomalies(): Promise<AnomalyFlag[]> {
)
.all() as Array<{ api_key_id: string; hourly_total: number }>;
return rows.map((r) => ({
apiKeyId: r.api_key_id,
xpLastHour: r.hourly_total,
zScore: 0, // Simplified for now
}));
const results: AnomalyFlag[] = [];
for (const r of rows) {
const z = await computeZScore(r.api_key_id);
results.push({
apiKeyId: r.api_key_id,
xpLastHour: r.hourly_total,
zScore: z ?? 0,
});
}
return results;
}
// ─── Internal Helpers ────────────────────────────────────────────────────────
/**
* Compute the z-score for a user's hourly XP against the global distribution.
* Returns null if insufficient data.
*/
async function computeZScore(apiKeyId: string): Promise<number | null> {
const d = db();
const userRow = d
.prepare(
`SELECT COALESCE(SUM(xp_earned), 0) AS total
FROM xp_audit_log
WHERE api_key_id = ? AND created_at > datetime('now', '-1 hour')`
)
.get(apiKeyId) as { total: number };
const statsRow = d
.prepare(
`SELECT AVG(hourly_total) AS mean,
CASE WHEN AVG(hourly_total) = 0 THEN 1
ELSE AVG(hourly_total * hourly_total) - AVG(hourly_total) * AVG(hourly_total)
END AS variance
FROM (
SELECT api_key_id, SUM(xp_earned) AS hourly_total
FROM xp_audit_log
WHERE created_at > datetime('now', '-1 hour')
GROUP BY api_key_id
)`
)
.get() as { mean: number; variance: number } | undefined;
if (!statsRow || statsRow.variance <= 0) return null;
const stdDev = Math.sqrt(statsRow.variance);
return (userRow.total - statsRow.mean) / stdDev;
}
/**
* Get total XP earned in the last N milliseconds.
*/
@@ -114,37 +155,6 @@ async function getRecentXp(apiKeyId: string, windowMs: number): Promise<number>
* Detect anomalous XP velocity using z-score.
*/
async function detectAnomaly(apiKeyId: string): Promise<boolean> {
const d = db();
// Get user's XP in last hour
const userRow = d
.prepare(
`SELECT COALESCE(SUM(xp_earned), 0) AS total
FROM xp_audit_log
WHERE api_key_id = ? AND created_at > datetime('now', '-1 hour')`
)
.get(apiKeyId) as { total: number };
// Get global stats
const statsRow = d
.prepare(
`SELECT AVG(hourly_total) AS mean,
CASE WHEN AVG(hourly_total) = 0 THEN 1
ELSE AVG(hourly_total * hourly_total) - AVG(hourly_total) * AVG(hourly_total)
END AS variance
FROM (
SELECT api_key_id, SUM(xp_earned) AS hourly_total
FROM xp_audit_log
WHERE created_at > datetime('now', '-1 hour')
GROUP BY api_key_id
)`
)
.get() as { mean: number; variance: number } | undefined;
if (!statsRow || statsRow.variance <= 0) return false;
const stdDev = Math.sqrt(statsRow.variance);
const zScore = (userRow.total - statsRow.mean) / stdDev;
return zScore > ANOMALY_Z_THRESHOLD;
const z = await computeZScore(apiKeyId);
return z !== null && z > ANOMALY_Z_THRESHOLD;
}

View File

@@ -151,7 +151,9 @@ async function checkActionCountBadges(apiKeyId: string, action: string): Promise
// Count total actions of this type
const row = db
.prepare("COALESCE(COUNT(*), 0) AS count FROM xp_audit_log WHERE api_key_id = ? AND action = ?")
.prepare(
"SELECT COALESCE(COUNT(*), 0) AS count FROM xp_audit_log WHERE api_key_id = ? AND action = ?"
)
.get(apiKeyId, action) as { count: number };
const count = row.count;

View File

@@ -0,0 +1,37 @@
/**
* Gamification module — barrel export.
*
* @module lib/gamification
*/
export { emitGamificationEvent } from "./events";
export {
updateScore,
getRank,
getTopN,
getNeighbors,
rotateScope,
type LeaderboardScope,
type LeaderboardEntry,
} from "./leaderboard";
export { validateScoreChange, getAnomalies } from "./antiCheat";
export { BUILTIN_BADGES } from "./badges";
export {
xpForLevel,
cumulativeXpForLevel,
calculateLevel,
xpToNextLevel,
getLevelTitle,
getLevelTier,
XP_REWARDS,
type XpAction,
} from "./xp";
export { updateStreak } from "./streaks";
export {
recordBadgeUnlock,
consumeBadgeUnlocks,
createBadgeNotificationStream,
} from "./notifications";
export { transferTokens, getBalance, getHistory } from "./sharing";
export { createInvite, redeemInvite as redeemInviteCode } from "./invites";
export { connectServer, disconnectServer, listServers } from "./servers";

View File

@@ -38,9 +38,9 @@ export async function getRank(apiKeyId: string, scope: LeaderboardScope): Promis
/**
* Get top N entries for a scope.
*/
export async function getTopN(scope: LeaderboardScope, limit: number = 50, _offset: number = 0) {
export async function getTopN(scope: LeaderboardScope, limit: number = 50, offset: number = 0) {
const { getTopN: dbGetTopN } = await import("../db/gamification");
return dbGetTopN(scope, limit);
return dbGetTopN(scope, limit, offset);
}
/**

View File

@@ -240,27 +240,47 @@ export class KiroService {
}
/**
* Validate and import refresh token
* Validate and import refresh token.
* Registers a dedicated OIDC client so this connection has an isolated refresh session.
* If registerClient() fails (network issue, OIDC service down), the import still
* succeeds and the connection falls back to the shared social-auth refresh path.
*/
async validateImportToken(refreshToken: string) {
async validateImportToken(refreshToken: string, region: string = "us-east-1") {
// Validate token format
if (!refreshToken.startsWith("aorAAAAAG")) {
throw new Error("Invalid token format. Token should start with aorAAAAAG...");
}
// Try to refresh to validate
let result: Awaited<ReturnType<typeof this.refreshToken>>;
try {
const result = await this.refreshToken(refreshToken);
return {
accessToken: result.accessToken,
refreshToken: result.refreshToken || refreshToken,
profileArn: result.profileArn,
expiresIn: result.expiresIn,
authMethod: "imported",
};
result = await this.refreshToken(refreshToken);
} catch (error: any) {
throw new Error(`Token validation failed: ${error.message}`);
}
// Register an independent OIDC client for this connection so multiple accounts
// do not share a single Kiro backend session (issue #2328).
let clientId: string | undefined;
let clientSecret: string | undefined;
let clientSecretExpiresAt: number | undefined;
try {
const registration = await this.registerClient(region);
clientId = registration.clientId;
clientSecret = registration.clientSecret;
clientSecretExpiresAt = registration.clientSecretExpiresAt;
} catch (err: any) {
console.warn("[kiro import] registerClient failed, continuing without isolated client:", err);
}
return {
accessToken: result.accessToken,
refreshToken: result.refreshToken || refreshToken,
profileArn: result.profileArn,
expiresIn: result.expiresIn,
authMethod: "imported",
...(clientId ? { clientId, clientSecret, clientSecretExpiresAt } : {}),
};
}
/**

View File

@@ -0,0 +1,38 @@
import fs from "fs";
export interface DockerDetectDeps {
existsSync: (path: string) => boolean;
readFileSync: (path: string, encoding: string) => string;
}
const defaultDeps: DockerDetectDeps = {
existsSync: fs.existsSync,
readFileSync: (path, encoding) => fs.readFileSync(path, encoding as BufferEncoding) as string,
};
/**
* Returns true when OmniRoute appears to be running inside a Docker container.
* Uses two complementary heuristics that work on Linux-based Docker images:
* 1. Presence of /.dockerenv (written by Docker at container startup).
* 2. The string "docker" appearing in /proc/1/cgroup (Linux only).
*
* This is intentionally a best-effort check; false negatives on exotic runtimes
* (e.g. podman without Docker compatibility) are acceptable — the caller degrades
* gracefully and still surfaces the manual-import option.
*
* @param deps Optional dependency injection for testing.
*/
export function isRunningInDocker(deps: DockerDetectDeps = defaultDeps): boolean {
try {
if (deps.existsSync("/.dockerenv")) return true;
} catch {
// ignore — not Linux or permission denied
}
try {
const cgroup = deps.readFileSync("/proc/1/cgroup", "utf8");
if (cgroup.includes("docker")) return true;
} catch {
// ignore — not Linux or /proc not mounted
}
return false;
}

View File

@@ -13,6 +13,7 @@
import fs from "fs";
import os from "os";
import path from "path";
import { isRunningInDocker } from "./dockerDetect";
/** Minimal keytar surface (CJS/native; typings may not expose `default`). */
type KeytarModule = {
@@ -132,7 +133,7 @@ export async function discoverZedCredentials(): Promise<ZedCredential[]> {
});
}
} catch (error: any) {
console.debug(`No credentials found for ${pattern}:`, error?.message || error);
console.debug("No credentials found for %s:", pattern, error?.message || error);
// Continue to next pattern
}
}
@@ -186,7 +187,7 @@ export async function getZedCredential(provider: string): Promise<ZedCredential
}
}
} catch (error: any) {
console.debug(`Failed to get credential for ${pattern}:`, error?.message || error);
console.debug("Failed to get credential for %s:", pattern, error?.message || error);
}
}

View File

@@ -249,6 +249,21 @@ export const WEB_COOKIE_PROVIDERS = {
freeNote: "Free video generation — VEO 3.1, Seedance. 6 requests/hour.",
authHint: "No auth required. Rate limited to 6 requests/hour per IP.",
},
"t3-web": {
id: "t3-web",
alias: "t3chat",
name: "t3.chat (Pro/Free)",
icon: "auto_awesome",
color: "#7C3AED",
textIcon: "T3",
website: "https://t3.chat",
hasFree: true,
freeNote: "Free tier gives limited model access. Pro ($8/month) unlocks 50+ models.",
authHint:
"Open t3.chat in your browser, log in, then open DevTools → Application → Local Storage → https://t3.chat. " +
"Copy the value of 'convex-session-id'. Also open DevTools → Network, copy the Cookie header from any request. " +
"Paste both values here. See provider setup docs for a step-by-step guide.",
},
};
// API Key Providers
@@ -1815,6 +1830,19 @@ export const LOCAL_PROVIDERS = {
localDefault: "http://127.0.0.1:8080/v1",
passthroughModels: true,
},
"llama-cpp": {
id: "llama-cpp",
alias: "llamacpp",
name: "llama.cpp",
icon: "memory",
color: "#795548",
textIcon: "LC",
website: "https://github.com/ggml-org/llama.cpp",
authHint:
"API key optional (use any value, e.g. sk-no-key-required). Configure the llama-server OpenAI-compatible base URL (default: http://127.0.0.1:8080/v1). Note: if Llamafile is also installed, both default to port 8080 — run only one at a time or override the port.",
localDefault: "http://127.0.0.1:8080/v1",
passthroughModels: true,
},
triton: {
id: "triton",
alias: "triton",
@@ -2156,6 +2184,7 @@ export const SELF_HOSTED_CHAT_PROVIDER_IDS = new Set([
"vllm",
"lemonade",
"llamafile",
"llama-cpp",
"triton",
"docker-model-runner",
"xinference",

View File

@@ -517,6 +517,9 @@ const comboRuntimeConfigSchema = z
maxComboDepth: z.coerce.number().int().min(1).max(10).optional(),
trackMetrics: z.boolean().optional(),
compressionMode: compressionModeSchema.optional(),
failoverBeforeRetry: z.boolean().optional(),
maxSetRetries: z.coerce.number().int().min(0).max(10).optional(),
setRetryDelayMs: z.coerce.number().int().min(0).max(60000).optional(),
// Auto-Combo / LKGP Extensions
candidatePool: z.array(z.string().min(1)).optional(),
weights: scoringWeightsSchema.optional(),
@@ -1416,6 +1419,7 @@ export const cursorImportSchema = z.object({
export const kiroImportSchema = z.object({
refreshToken: z.string().trim().min(1, "Refresh token is required"),
region: z.string().trim().default("us-east-1"),
});
export const kiroSocialExchangeSchema = z.object({

View File

@@ -463,6 +463,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
executionKey?: string | null;
stepId?: string | null;
allowedConnectionIds?: string[] | null;
failoverBeforeRetry?: boolean;
}
) =>
handleSingleModelChat(
@@ -481,6 +482,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
allowedConnectionIds: target?.allowedConnectionIds ?? null,
comboStepId: target?.stepId || null,
comboExecutionKey: target?.executionKey || target?.stepId || null,
skipUpstreamRetry: target?.failoverBeforeRetry ?? false,
preselectedCredentials: comboPreselectedCredentials.get(
getComboCredentialCacheKey(m, target)
),
@@ -611,6 +613,7 @@ async function handleSingleModelChat(
allowedConnectionIds?: string[] | null;
comboStepId?: string | null;
comboExecutionKey?: string | null;
skipUpstreamRetry?: boolean;
preselectedCredentials?: any;
cachedSettings?: any;
} = {},
@@ -643,6 +646,7 @@ async function handleSingleModelChat(
connectionId?: string | null;
executionKey?: string | null;
stepId?: string | null;
failoverBeforeRetry?: boolean;
}
) =>
handleSingleModelChat(
@@ -660,6 +664,7 @@ async function handleSingleModelChat(
allowedConnectionIds: null,
comboStepId: null,
comboExecutionKey: null,
skipUpstreamRetry: target?.failoverBeforeRetry ?? false,
},
redirectCombo.strategy ?? "priority",
false
@@ -916,6 +921,7 @@ async function handleSingleModelChat(
modelApiFormat: apiFormat,
providerProfile,
cachedSettings: runtimeOptions.cachedSettings,
skipUpstreamRetry: runtimeOptions.skipUpstreamRetry ?? false,
});
if (telemetry) telemetry.endPhase();
@@ -1129,7 +1135,11 @@ async function handleSingleModelChat(
continue;
}
if (!forceLiveComboTest && PROVIDER_BREAKER_FAILURE_STATUSES.has(Number(result.status))) {
if (
!forceLiveComboTest &&
!isCombo &&
PROVIDER_BREAKER_FAILURE_STATUSES.has(Number(result.status))
) {
breaker._onFailure();
}

View File

@@ -303,6 +303,7 @@ export async function executeChatWithBreaker({
modelApiFormat,
providerProfile,
cachedSettings,
skipUpstreamRetry = false,
}: any): Promise<{ result: any; tlsFingerprintUsed: boolean }> {
let tlsFingerprintUsed = false;
@@ -324,6 +325,7 @@ export async function executeChatWithBreaker({
comboStepId,
comboExecutionKey,
cachedSettings,
skipUpstreamRetry,
onCredentialsRefreshed: async (newCreds: any) => {
await updateProviderCredentials(credentials.connectionId, {
accessToken: newCreds.accessToken,

View File

@@ -1589,8 +1589,14 @@ export async function markAccountUnavailable(
| undefined;
const isPerModelQuotaProvider = hasPerModelQuota(provider, model, connectionPassthroughModels);
if (isPerModelQuotaProvider && provider && model && (status === 404 || status === 429)) {
const reason = status === 404 ? "not_found" : "rate_limited";
if (
isPerModelQuotaProvider &&
provider &&
model &&
(status === 404 || status === 429 || status >= 500)
) {
const reason =
status === 404 ? "not_found" : status === 429 ? "rate_limited" : "server_error";
const lockout = recordModelLockoutFailure(
provider,
connectionId,

View File

@@ -456,6 +456,13 @@ test.describe("Combos flow", () => {
await comboDialog.locator('[data-testid="combo-manual-model-add"]').click();
await expect(comboDialog.locator('[data-testid="combo-readiness-panel"]')).toHaveCount(0);
// New advanced settings: failoverBeforeRetry, maxSetRetries, setRetryDelayMs
await comboDialog.locator("#failoverBeforeRetry").check();
// maxSetRetries: placeholder "0" is unique (maxRetries uses "1")
await comboDialog.locator('input[placeholder="0"]').fill("2");
// setRetryDelayMs: placeholder "2000" — last occurrence is the new field
await comboDialog.locator('input[placeholder="2000"]').last().fill("1500");
await comboDialog
.getByRole("button", { name: /create combo|criar combo/i })
.last()
@@ -476,6 +483,9 @@ test.describe("Combos flow", () => {
weight: 0,
},
]);
expect(state.lastPayload?.config?.failoverBeforeRetry).toBe(true);
expect(state.lastPayload?.config?.maxSetRetries).toBe(2);
expect(state.lastPayload?.config?.setRetryDelayMs).toBe(1500);
});
test("allows dragging combo cards to persist manual order", async ({ page }) => {

View File

@@ -0,0 +1,175 @@
import http from "node:http";
import net from "node:net";
export interface PlannedResponse {
status: number;
body: Record<string, unknown>;
headers?: Record<string, string>;
delayMs?: number;
}
export interface TokenState {
defaultResponse: PlannedResponse;
queue: PlannedResponse[];
hits: number;
startedAt: number[];
bodies: Array<Record<string, unknown>>;
}
function getFreePort(): Promise<number> {
return new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close();
reject(new Error("Failed to allocate a free port"));
return;
}
const { port } = address;
server.close((err) => {
if (err) reject(err);
else resolve(port);
});
});
});
}
export function buildCompletion(
text: string,
overrides: Partial<PlannedResponse> & { model?: string } = {}
) {
return {
status: overrides.status ?? 200,
delayMs: overrides.delayMs,
headers: overrides.headers,
body: overrides.body ?? {
id: `chatcmpl_${Math.random().toString(16).slice(2, 8)}`,
object: "chat.completion",
model: overrides.model ?? "test-model",
choices: [{ index: 0, message: { role: "assistant", content: text }, finish_reason: "stop" }],
usage: { prompt_tokens: 4, completion_tokens: 2, total_tokens: 6 },
},
};
}
export function buildError(status: number, message: string, headers: Record<string, string> = {}) {
return {
status,
headers,
body: { error: { message } },
};
}
export class MockUpstreamServer {
private behaviors = new Map<string, TokenState>();
private server: http.Server | null = null;
private _baseUrl = "";
get baseUrl(): string {
if (!this._baseUrl) throw new Error("Server not started yet");
return this._baseUrl;
}
get isRunning(): boolean {
return this.server !== null;
}
async start(): Promise<string> {
const port = await getFreePort();
this.server = http.createServer((req, res) => {
void this.handleRequest(req, res);
});
await new Promise<void>((resolve, reject) => {
this.server!.once("error", reject);
this.server!.listen(port, "127.0.0.1", () => resolve());
});
this._baseUrl = `http://127.0.0.1:${port}/v1`;
return this._baseUrl;
}
configureToken(
token: string,
config: { defaultResponse: PlannedResponse; queue?: PlannedResponse[] }
): void {
this.behaviors.set(token, {
defaultResponse: config.defaultResponse,
queue: [...(config.queue || [])],
hits: 0,
startedAt: [],
bodies: [],
});
}
getState(token: string): TokenState {
const state = this.behaviors.get(token);
if (!state) throw new Error(`Unknown token: ${token}`);
return state;
}
resetState(token: string, queue?: PlannedResponse[]): void {
const state = this.behaviors.get(token);
if (!state) throw new Error(`Unknown token: ${token}`);
state.hits = 0;
state.startedAt = [];
state.bodies = [];
state.queue = [...(queue || [])];
}
async stop(): Promise<void> {
if (!this.server) return;
await new Promise<void>((resolve) => this.server?.close(() => resolve()));
this.server = null;
}
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const authHeader = String(req.headers.authorization || "");
const token = authHeader.replace(/^Bearer\s+/i, "").trim();
const rawBody = Buffer.concat(chunks).toString("utf8");
const parsedBody = rawBody ? JSON.parse(rawBody) : {};
if (req.method === "GET" && req.url?.startsWith("/v1/models")) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ data: [{ id: "test-model", object: "model" }] }));
return;
}
if (req.method !== "POST" || !req.url?.startsWith("/v1/chat/completions")) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: { message: `Unhandled: ${req.method} ${req.url}` } }));
return;
}
const behavior = this.behaviors.get(token);
if (!behavior) {
res.writeHead(401, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: { message: `Unknown token: ${token || "missing"}` } }));
return;
}
behavior.hits += 1;
behavior.startedAt.push(Date.now());
behavior.bodies.push(parsedBody as Record<string, unknown>);
const planned = behavior.queue.shift() || behavior.defaultResponse;
process.stderr.write(
`[MOCK] ${token.slice(0, 8)} hit=${behavior.hits} status=${planned.status}\n`
);
if (planned.delayMs && planned.delayMs > 0) {
await new Promise((r) => setTimeout(r, planned.delayMs));
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(planned.headers || {}),
};
res.writeHead(planned.status, headers);
res.end(JSON.stringify(planned.body));
}
}

View File

@@ -0,0 +1,699 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import net from "node:net";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { MockUpstreamServer, buildCompletion, buildError } from "./helpers/mockUpstreamServer.ts";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-system-failover-"));
const DASHBOARD_PORT = await getFreePort();
const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "system-failover-secret-123456";
process.env.REQUIRE_API_KEY = "false";
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const combosDb = await import("../../src/lib/db/combos.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
const accountFallback = await import("../../open-sse/services/accountFallback.ts");
function resetConnectionCooldowns() {
accountFallback.clearAllModelLockouts();
const db = core.getDbInstance() as any;
db.prepare(
`UPDATE provider_connections
SET rate_limited_until = NULL,
test_status = 'active',
backoff_level = 0,
last_error = NULL,
last_error_type = NULL,
last_error_source = NULL,
error_code = NULL,
last_error_at = NULL
WHERE rate_limited_until IS NOT NULL
OR test_status != 'active'`
).run();
db.pragma("wal_checkpoint(TRUNCATE)");
}
function getFreePort() {
return new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close();
reject(new Error("Failed to allocate a free port"));
return;
}
const { port } = address;
server.close((closeError) => {
if (closeError) reject(closeError);
else resolve(port);
});
});
});
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function seedProvider(label: string, apiKey: string, baseUrl: string) {
const providerId = `openai-compatible-sys-${label}`;
await providersDb.createProviderNode({
id: providerId,
type: "openai-compatible",
name: `System ${label}`,
prefix: label,
apiType: "chat",
baseUrl,
});
await providersDb.createProviderConnection({
provider: providerId,
authType: "apikey",
name: `conn-${label}`,
apiKey,
isActive: true,
testStatus: "active",
providerSpecificData: { baseUrl, apiType: "chat" },
});
return { providerId, model: `${label}/test-model`, apiKey };
}
function createServerProcess(dataDir: string, port: number) {
const stdoutLines: string[] = [];
const stderrLines: string[] = [];
let exitInfo: { code: number | null; signal: NodeJS.Signals | null } | null = null;
const child = spawn(process.execPath, ["scripts/dev/run-next-playwright.mjs", "dev"], {
cwd: REPO_ROOT,
env: {
...process.env,
DATA_DIR: dataDir,
PORT: String(port),
DASHBOARD_PORT: String(port),
API_PORT: String(port),
HOST: "127.0.0.1",
REQUIRE_API_KEY: "false",
API_KEY_SECRET: process.env.API_KEY_SECRET || "system-failover-secret-123456",
DISABLE_SQLITE_AUTO_BACKUP: "true",
INITIAL_PASSWORD: "",
NEXT_TELEMETRY_DISABLED: "1",
OMNIROUTE_DISABLE_BACKGROUND_SERVICES: "true",
OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK: "true",
OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK: "true",
OMNIROUTE_HIDE_HEALTHCHECK_LOGS: "true",
OMNIROUTE_E2E_BOOTSTRAP_MODE: "open",
},
stdio: ["ignore", "pipe", "pipe"],
});
child.once("exit", (code, signal) => {
exitInfo = { code, signal };
});
child.stdout.on("data", (chunk) => {
const lines = String(chunk).split(/\r?\n/).filter(Boolean);
stdoutLines.push(...lines);
if (stdoutLines.length > 200) stdoutLines.splice(0, stdoutLines.length - 200);
});
child.stderr.on("data", (chunk) => {
const lines = String(chunk).split(/\r?\n/).filter(Boolean);
stderrLines.push(...lines);
if (stderrLines.length > 200) stderrLines.splice(0, stderrLines.length - 200);
});
return {
child,
stdoutLines,
stderrLines,
baseUrl: `http://127.0.0.1:${port}`,
get exitInfo() {
return exitInfo;
},
};
}
async function waitForServer(
baseUrl: string,
logs: {
stdoutLines: string[];
stderrLines: string[];
exitInfo?: { code: number | null; signal: NodeJS.Signals | null } | null;
}
) {
const startedAt = Date.now();
let lastError = "";
while (Date.now() - startedAt < 120_000) {
if (logs.exitInfo) {
throw new Error(
[
`OmniRoute exited before it became ready (code=${logs.exitInfo.code}, signal=${logs.exitInfo.signal})`,
"--- stdout ---",
...logs.stdoutLines.slice(-40),
"--- stderr ---",
...logs.stderrLines.slice(-40),
].join("\n")
);
}
try {
const response = await fetch(`${baseUrl}/api/monitoring/health`, {
signal: AbortSignal.timeout(5_000),
});
if (response.ok) return;
lastError = `HTTP ${response.status}`;
} catch (error: any) {
lastError = error instanceof Error ? error.message : String(error);
}
await sleep(500);
}
throw new Error(
[
`Timed out waiting for OmniRoute to start: ${lastError}`,
"--- stdout ---",
...logs.stdoutLines.slice(-40),
"--- stderr ---",
...logs.stderrLines.slice(-40),
].join("\n")
);
}
async function stopProcess(child: ReturnType<typeof spawn>) {
if (child.killed) return;
child.kill("SIGTERM");
const exited = await Promise.race([
new Promise<boolean>((resolve) => child.once("exit", () => resolve(true))),
sleep(5_000).then(() => false),
]);
if (!exited && !child.killed) {
child.kill("SIGKILL");
await new Promise<void>((resolve) => child.once("exit", () => resolve()));
}
}
async function postChat(
baseUrl: string,
model: string,
content: string,
extraHeaders?: Record<string, string>
) {
const response = await fetch(`${baseUrl}/api/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", ...extraHeaders },
body: JSON.stringify({
model,
stream: false,
messages: [{ role: "user", content }],
}),
signal: AbortSignal.timeout(30_000),
});
const text = await response.text();
const json = text ? JSON.parse(text) : {};
return { response, json };
}
async function resetBreakers(url: string) {
await fetch(`${url}/api/resilience/reset`, {
method: "POST",
signal: AbortSignal.timeout(5_000),
});
}
const serverA = new MockUpstreamServer();
const serverB = new MockUpstreamServer();
let app:
| {
child: ReturnType<typeof spawn>;
stdoutLines: string[];
stderrLines: string[];
baseUrl: string;
}
| undefined;
const TOKEN_A = "sk-sys-a";
const TOKEN_B = "sk-sys-b";
const TOKEN_A2 = "sk-sys-a2";
const TOKEN_B2 = "sk-sys-b2";
test.before(async () => {
const baseUrlA = await serverA.start();
const baseUrlB = await serverB.start();
serverA.configureToken(TOKEN_A, {
defaultResponse: buildCompletion("server A ok", { model: "sys-a/test-model" }),
});
serverA.configureToken(TOKEN_A2, {
defaultResponse: buildCompletion("server A2 ok", { model: "sys-a2/test-model" }),
});
serverB.configureToken(TOKEN_B, {
defaultResponse: buildCompletion("server B ok", { model: "sys-b/test-model" }),
});
serverB.configureToken(TOKEN_B2, {
defaultResponse: buildCompletion("server B2 ok", { model: "sys-b2/test-model" }),
});
const provA = await seedProvider("sys-a", TOKEN_A, baseUrlA);
const provB = await seedProvider("sys-b", TOKEN_B, baseUrlB);
const provA2 = await seedProvider("sys-a2", TOKEN_A2, baseUrlA);
const provB2 = await seedProvider("sys-b2", TOKEN_B2, baseUrlB);
await combosDb.createCombo({
name: "sys-priority",
strategy: "priority",
config: { maxRetries: 0, retryDelayMs: 0 },
models: [provA.model, provB.model],
});
await combosDb.createCombo({
name: "sys-priority-v2",
strategy: "priority",
config: { maxRetries: 0, retryDelayMs: 0 },
models: [provA2.model, provB2.model],
});
await combosDb.createCombo({
name: "sys-priority-fobr",
strategy: "priority",
config: { maxRetries: 0, retryDelayMs: 0, failoverBeforeRetry: true },
models: [provA.model, provB.model],
});
await combosDb.createCombo({
name: "sys-priority-setretry",
strategy: "priority",
config: {
maxRetries: 0,
retryDelayMs: 0,
failoverBeforeRetry: true,
maxSetRetries: 1,
setRetryDelayMs: 500,
},
models: [provA.model, provB.model],
});
await combosDb.createCombo({
name: "sys-same-server",
strategy: "priority",
config: { maxRetries: 0, retryDelayMs: 0 },
models: [provA.model, provA2.model],
});
await combosDb.createCombo({
name: "sys-same-server-fobr",
strategy: "priority",
config: { maxRetries: 0, retryDelayMs: 0, failoverBeforeRetry: true },
models: [provA.model, provA2.model],
});
await combosDb.createCombo({
name: "sys-single-provider",
strategy: "priority",
config: { maxRetries: 0, retryDelayMs: 0 },
models: ["sys-a/modelA", "sys-a/modelB"],
});
await combosDb.createCombo({
name: "sys-single-provider-fobr",
strategy: "priority",
config: { maxRetries: 0, retryDelayMs: 0, failoverBeforeRetry: true },
models: ["sys-a/modelA", "sys-a/modelB"],
});
await settingsDb.updateSettings({
resilienceSettings: {
requestQueue: {
autoEnableApiKeyProviders: true,
requestsPerMinute: 120,
minTimeBetweenRequestsMs: 0,
concurrentRequests: 4,
maxWaitMs: 2_000,
},
connectionCooldown: {
oauth: { baseCooldownMs: 500, useUpstreamRetryHints: true, maxBackoffSteps: 3 },
apikey: { baseCooldownMs: 200, useUpstreamRetryHints: false, maxBackoffSteps: 0 },
},
providerBreaker: {
oauth: { failureThreshold: 3, resetTimeoutMs: 2_000 },
apikey: { failureThreshold: 2, resetTimeoutMs: 1_500 },
},
waitForCooldown: {
enabled: false,
maxRetries: 0,
maxRetryWaitSec: 0,
},
},
requestRetry: 0,
maxRetryIntervalSec: 0,
requireLogin: false,
setupComplete: true,
});
core.closeDbInstance();
app = createServerProcess(TEST_DATA_DIR, DASHBOARD_PORT);
await waitForServer(app.baseUrl, app);
const warmup = await postChat(app.baseUrl, "sys-b/test-model", "warm up");
assert.equal(warmup.response.status, 200, JSON.stringify(warmup.json));
serverB.resetState(TOKEN_B);
});
test.after(async () => {
if (app) await stopProcess(app.child);
await serverA.stop();
await serverB.stop();
core.closeDbInstance();
await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true });
});
test("primary healthy: request routes to Server A only", async () => {
assert.ok(app);
serverA.resetState(TOKEN_A);
serverB.resetState(TOKEN_B);
const result = await postChat(app.baseUrl, "sys-priority", "healthy primary");
assert.equal(result.response.status, 200, JSON.stringify(result.json));
assert.equal(result.json.choices[0].message.content, "server A ok");
assert.equal(result.json.model, "sys-a/test-model");
assert.equal(serverA.getState(TOKEN_A).hits, 1);
assert.equal(serverB.getState(TOKEN_B).hits, 0);
});
test("500 Internal Server Error: combo falls back to Server B", async () => {
assert.ok(app);
serverA.resetState(TOKEN_A, [buildError(500, "Internal Server Error")]);
serverB.resetState(TOKEN_B);
const result = await postChat(app.baseUrl, "sys-priority", "500 fallback");
assert.equal(result.response.status, 200, JSON.stringify(result.json));
assert.equal(result.json.choices[0].message.content, "server B ok");
assert.equal(result.json.model, "sys-b/test-model");
assert.equal(serverA.getState(TOKEN_A).hits, 1);
assert.equal(serverB.getState(TOKEN_B).hits, 1);
});
test("503 Service Unavailable: combo falls back to Server B", async () => {
assert.ok(app);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
serverA.resetState(TOKEN_A, [buildError(503, "Service Unavailable")]);
serverB.resetState(TOKEN_B);
const result = await postChat(app.baseUrl, "sys-priority", "503 fallback");
assert.equal(result.response.status, 200, JSON.stringify(result.json));
assert.equal(result.json.choices[0].message.content, "server B ok");
assert.equal(result.json.model, "sys-b/test-model");
assert.equal(serverA.getState(TOKEN_A).hits, 1);
assert.equal(serverB.getState(TOKEN_B).hits, 1);
});
test("both servers fail (500): request returns a 5xx error to the client", async () => {
assert.ok(app);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
serverA.resetState(TOKEN_A, [buildError(500, "A is down")]);
serverB.resetState(TOKEN_B, [buildError(500, "B is down")]);
const result = await postChat(app.baseUrl, "sys-priority", "both down");
assert.ok(result.response.status >= 500, `expected 5xx, got ${result.response.status}`);
});
test("combo fallback to Server B survives sequential 503 failures from Server A", async () => {
assert.ok(app);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
serverA.resetState(TOKEN_A, [buildError(503, "transient blip"), buildError(503, "second blip")]);
serverB.resetState(TOKEN_B);
const first = await postChat(app.baseUrl, "sys-priority", "seq 503 attempt 1");
assert.equal(first.response.status, 200, JSON.stringify(first.json));
assert.equal(first.json.choices[0].message.content, "server B ok");
assert.equal(first.json.model, "sys-b/test-model");
// Wait for the 200ms apikey cooldown to expire so the second request also
// goes through the full A→B fallback path rather than skipping A entirely.
await sleep(250);
const second = await postChat(app.baseUrl, "sys-priority", "seq 503 attempt 2");
assert.equal(second.response.status, 200, JSON.stringify(second.json));
assert.equal(second.json.choices[0].message.content, "server B ok");
assert.equal(second.json.model, "sys-b/test-model");
assert.equal(serverA.getState(TOKEN_A).hits, 2);
assert.equal(serverB.getState(TOKEN_B).hits, 2);
});
test("429 with Retry-After and wait-for-cooldown: primary retries then falls back to B", async () => {
assert.ok(app);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
serverA.resetState(TOKEN_A, [
buildError(429, "rate limited, retry after 1s", { "Retry-After": "1" }),
]);
serverB.resetState(TOKEN_B);
const patchRes = await fetch(`${app.baseUrl}/api/resilience`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
connectionCooldown: {
apikey: { useUpstreamRetryHints: true, baseCooldownMs: 200 },
},
waitForCooldown: { enabled: true, maxRetries: 1, maxRetryWaitSec: 2 },
}),
signal: AbortSignal.timeout(10_000),
});
});
test("failoverBeforeRetry enabled: upstream error triggers immediate failover to next target", async () => {
assert.ok(app);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
serverA.resetState(TOKEN_A, [buildError(429, "rate limited")]);
serverB.resetState(TOKEN_B);
const result = await postChat(app.baseUrl, "sys-priority-fobr", "test failover before retry");
assert.equal(result.response.status, 200, JSON.stringify(result.json));
assert.equal(result.json.choices[0].message.content, "server B ok");
assert.equal(result.json.model, "sys-b/test-model");
// With failoverBeforeRetry=true, A should be hit exactly ONCE (no intra-URL retry)
assert.equal(serverA.getState(TOKEN_A).hits, 1);
assert.equal(serverB.getState(TOKEN_B).hits, 1);
});
test("failoverBeforeRetry disabled: 429 triggers executor intra-URL retry, succeeds on retry", async () => {
assert.ok(app);
// Full resilience reset so A isn't blocked by residual breaker/cooldown state
const patchRes = await fetch(`${app.baseUrl}/api/resilience`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
connectionCooldown: {
apikey: { useUpstreamRetryHints: false, baseCooldownMs: 0, maxBackoffSteps: 0 },
oauth: { useUpstreamRetryHints: false, baseCooldownMs: 0, maxBackoffSteps: 0 },
},
waitForCooldown: { enabled: false, maxRetries: 0, maxRetryWaitSec: 0 },
}),
signal: AbortSignal.timeout(10_000),
});
assert.equal(patchRes.status, 200);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
// Wait for any residual cooldowns to expire
await sleep(300);
// One 429, then the default (200) on retry
serverA.resetState(TOKEN_A, [buildError(429, "rate limited")]);
serverB.resetState(TOKEN_B);
const result = await postChat(app.baseUrl, "sys-priority", "test failover before retry disabled");
assert.equal(result.response.status, 200, JSON.stringify(result.json));
// With maxRetries=0 the combo does not retry A — it fails over to B immediately.
assert.equal(result.json.model, "sys-b/test-model");
// With failoverBeforeRetry=false, A should be hit TWICE (initial + 1 intra-URL retry).
// This contrasts with failoverBeforeRetry=true where A is hit exactly ONCE.
assert.equal(serverA.getState(TOKEN_A).hits, 2);
});
test("maxSetRetries: both A and B fail first pass, A 429 again, B 200 on retry", async () => {
assert.ok(app);
// Reset to a clean resilience slate: disable cooldowns, disable waitForCooldown,
// reset breakers, and clear connection rate_limited_until from previous tests.
const patchRes = await fetch(`${app.baseUrl}/api/resilience`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
connectionCooldown: {
apikey: { useUpstreamRetryHints: false, baseCooldownMs: 0, maxBackoffSteps: 0 },
oauth: { useUpstreamRetryHints: false, baseCooldownMs: 0, maxBackoffSteps: 0 },
},
waitForCooldown: { enabled: false, maxRetries: 0, maxRetryWaitSec: 0 },
}),
signal: AbortSignal.timeout(10_000),
});
assert.equal(patchRes.status, 200);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
// Set try 0: A 429, B 500 — both fail
// Set try 1: A 429, B 200 — B succeeds
serverA.resetState(TOKEN_A, [buildError(429, "rate limited"), buildError(429, "rate limited")]);
serverB.resetState(TOKEN_B, [
buildError(500, "server error"),
buildCompletion("server B ok on retry", { model: "sys-b/test-model" }),
]);
const result = await postChat(app.baseUrl, "sys-priority-setretry", "test max set retries", {
"x-internal-test": "combo-health-check",
});
// Set try 0: A 429, B 500 → both fail
// Set try 1: A 429, B 200 → B succeeds
assert.equal(result.response.status, 200, JSON.stringify(result.json));
assert.equal(result.json.choices[0].message.content, "server B ok on retry");
assert.equal(result.json.model, "sys-b/test-model");
assert.equal(serverA.getState(TOKEN_A).hits, 2);
assert.equal(serverB.getState(TOKEN_B).hits, 2);
// Restore defaults so other tests are not affected
await fetch(`${app.baseUrl}/api/resilience`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
connectionCooldown: {
apikey: { useUpstreamRetryHints: false, baseCooldownMs: 200, maxBackoffSteps: 0 },
oauth: { useUpstreamRetryHints: true, baseCooldownMs: 500, maxBackoffSteps: 3 },
},
}),
signal: AbortSignal.timeout(10_000),
});
});
test("same server failoverBeforeRetry disabled: first model 429 retried before trying second", async () => {
assert.ok(app);
// Full resilience reset — previous test restored defaults and may have left A cooldown
const patchRes = await fetch(`${app.baseUrl}/api/resilience`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
connectionCooldown: {
apikey: { useUpstreamRetryHints: false, baseCooldownMs: 0, maxBackoffSteps: 0 },
oauth: { useUpstreamRetryHints: false, baseCooldownMs: 0, maxBackoffSteps: 0 },
},
waitForCooldown: { enabled: false, maxRetries: 0, maxRetryWaitSec: 0 },
}),
signal: AbortSignal.timeout(10_000),
});
assert.equal(patchRes.status, 200);
await sleep(300);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
serverA.resetState(TOKEN_A, [buildError(429, "rate limited")]);
serverA.resetState(TOKEN_A2);
const result = await postChat(
app.baseUrl,
"sys-same-server",
"test same server failover disabled"
);
assert.equal(result.response.status, 200, JSON.stringify(result.json));
// With maxRetries=0 the combo fails over to A2 rather than retrying A.
assert.equal(result.json.model, "sys-a2/test-model");
assert.equal(serverA.getState(TOKEN_A).hits, 2);
});
test("same server failoverBeforeRetry enabled: first model 429 skipped to second immediately", async () => {
assert.ok(app);
await sleep(300);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
serverA.resetState(TOKEN_A, [buildError(429, "rate limited")]);
serverA.resetState(TOKEN_A2);
const result = await postChat(
app.baseUrl,
"sys-same-server-fobr",
"test same server failover enabled"
);
assert.equal(result.response.status, 200, JSON.stringify(result.json));
assert.equal(result.json.model, "sys-a2/test-model");
assert.equal(serverA.getState(TOKEN_A).hits, 1);
assert.equal(serverA.getState(TOKEN_A2).hits, 1);
});
test("single provider, modelA 500: combo fails over to modelB", async () => {
assert.ok(app);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
serverA.resetState(TOKEN_A, [buildError(500, "model A error")]);
const result = await postChat(app.baseUrl, "sys-single-provider", "test modelA 500");
assert.equal(result.response.status, 200, JSON.stringify(result.json));
assert.equal(result.json.model, "sys-a/test-model");
assert.equal(serverA.getState(TOKEN_A).hits, 2);
});
test("single provider, modelA 503: combo fails over to modelB", async () => {
assert.ok(app);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
serverA.resetState(TOKEN_A, [buildError(503, "Service Unavailable")]);
const result = await postChat(app.baseUrl, "sys-single-provider", "test modelA 503");
assert.equal(result.response.status, 200, JSON.stringify(result.json));
assert.equal(result.json.model, "sys-a/test-model");
assert.equal(serverA.getState(TOKEN_A).hits, 2);
});
test("single provider, modelA 429 with fobr: immediate failover to modelB", async () => {
assert.ok(app);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
serverA.resetState(TOKEN_A, [buildError(429, "rate limited")]);
const result = await postChat(app.baseUrl, "sys-single-provider-fobr", "test fobr");
assert.equal(result.response.status, 200, JSON.stringify(result.json));
assert.equal(result.json.model, "sys-a/test-model");
assert.equal(serverA.getState(TOKEN_A).hits, 2);
});
test("single provider, modelA 500 with fobr: modelA retry", async () => {
assert.ok(app);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
serverA.resetState(TOKEN_A, [buildError(500, "Oops!")]);
const result = await postChat(app.baseUrl, "sys-single-provider-fobr", "test fobr");
assert.equal(result.response.status, 200, JSON.stringify(result.json));
assert.equal(result.json.model, "sys-a/test-model");
assert.equal(serverA.getState(TOKEN_A).hits, 2);
});
test("single provider, both models fail: request returns 5xx to client", async () => {
assert.ok(app);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
serverA.resetState(TOKEN_A, [buildError(500, "modelA down"), buildError(500, "modelB down")]);
const result = await postChat(app.baseUrl, "sys-single-provider", "both down");
assert.ok(result.response.status >= 500, `expected 5xx, got ${result.response.status}`);
assert.equal(serverA.getState(TOKEN_A).hits, 2);
});

View File

@@ -0,0 +1,497 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createChatPipelineHarness } from "../integration/_chatPipelineHarness.ts";
const harness = await createChatPipelineHarness("combo-provider-exhaustion");
const {
buildClaudeResponse,
buildRequest,
combosDb,
handleChat,
resetStorage,
seedConnection,
settingsDb,
} = harness;
function toPlainHeaders(headers: any): Record<string, string> {
if (!headers) return {};
if (headers instanceof Headers) return Object.fromEntries(headers.entries());
return Object.fromEntries(
Object.entries(headers).map(([key, value]) => [key, value == null ? "" : String(value)])
);
}
test.beforeEach(async () => {
await resetStorage();
});
test.afterEach(async () => {
await resetStorage();
});
test.after(async () => {
await harness.cleanup();
});
test("fast-skip on quota-exhausted 429: first same-provider target causes remaining same-provider targets to be skipped (#1731)", async () => {
await seedConnection("openai", {
apiKey: "sk-openai-quota-exhausted",
});
await seedConnection("anthropic", {
apiKey: "sk-anthropic-quota-exhausted",
});
await settingsDb.updateSettings({
requestRetry: 0,
maxRetryIntervalSec: 0,
});
// Combo with two openai targets and one anthropic
await combosDb.createCombo({
name: "quota-exhausted-combo",
strategy: "priority",
config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 },
models: [
"openai/gpt-4o-mini",
"openai/gpt-3.5-turbo", // Same provider as first target
"anthropic/claude-3-5-sonnet-20241022",
],
});
let openaiCalls = 0;
let anthropicCalls = 0;
let callSequence: string[] = [];
globalThis.fetch = async (_url: string, init: any = {}) => {
const headers = toPlainHeaders(init.headers);
const authHeader = headers.authorization ?? headers.Authorization;
const apiKeyHeader = headers["x-api-key"] ?? headers["X-Api-Key"];
if (authHeader === "Bearer sk-openai-quota-exhausted") {
openaiCalls += 1;
callSequence.push("openai");
// Return quota exhausted on all openai calls
return new Response(JSON.stringify({ error: { message: "Subscription quota exceeded" } }), {
status: 429,
headers: { "Content-Type": "application/json" },
});
}
if (
apiKeyHeader === "sk-anthropic-quota-exhausted" ||
authHeader === "Bearer sk-anthropic-quota-exhausted"
) {
anthropicCalls += 1;
callSequence.push("anthropic");
return buildClaudeResponse("anthropic fallback success");
}
throw new Error(`unexpected upstream headers: ${JSON.stringify(headers)}`);
};
const response = await handleChat(
buildRequest({
body: {
model: "quota-exhausted-combo",
stream: false,
messages: [{ role: "user", content: "test quota exhaustion skip" }],
},
})
);
const body = (await response.json()) as any;
assert.equal(response.status, 200, "should return 200");
assert.equal(
body.choices[0].message.content,
"anthropic fallback success",
"should fallback to anthropic"
);
// The key assertion: openai should only be called once (not twice for both gpt-4o-mini and gpt-3.5-turbo)
assert.equal(
openaiCalls,
1,
`openai should be called only once, but was called ${openaiCalls} times. Call sequence: ${callSequence.join(" -> ")}`
);
assert.equal(anthropicCalls, 1, "anthropic should be called once");
});
test("fast-skip on credits-exhausted 429: same-provider targets are skipped (#1731)", async () => {
await seedConnection("openai", {
apiKey: "sk-openai-credits-exhausted",
});
await seedConnection("anthropic", {
apiKey: "sk-anthropic-credits-exhausted",
});
await settingsDb.updateSettings({
requestRetry: 0,
maxRetryIntervalSec: 0,
});
await combosDb.createCombo({
name: "credits-exhausted-combo",
strategy: "priority",
config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 },
models: ["openai/gpt-4o-mini", "openai/gpt-3.5-turbo", "anthropic/claude-3-5-sonnet-20241022"],
});
let openaiCalls = 0;
let anthropicCalls = 0;
globalThis.fetch = async (_url: string, init: any = {}) => {
const headers = toPlainHeaders(init.headers);
const authHeader = headers.authorization ?? headers.Authorization;
const apiKeyHeader = headers["x-api-key"] ?? headers["X-Api-Key"];
if (authHeader === "Bearer sk-openai-credits-exhausted") {
openaiCalls += 1;
if (openaiCalls === 1) {
return new Response(
JSON.stringify({ error: { message: "You exceeded your current usage quota" } }),
{
status: 429,
headers: { "Content-Type": "application/json" },
}
);
}
throw new Error("Second openai call should have been skipped!");
}
if (
apiKeyHeader === "sk-anthropic-credits-exhausted" ||
authHeader === "Bearer sk-anthropic-credits-exhausted"
) {
anthropicCalls += 1;
return buildClaudeResponse("anthropic handled credits exhaustion");
}
throw new Error(`unexpected upstream headers: ${JSON.stringify(headers)}`);
};
const response = await handleChat(
buildRequest({
body: {
model: "credits-exhausted-combo",
stream: false,
messages: [{ role: "user", content: "test credits exhaustion" }],
},
})
);
const body = (await response.json()) as any;
assert.equal(response.status, 200);
assert.equal(body.choices[0].message.content, "anthropic handled credits exhaustion");
assert.equal(openaiCalls, 1, "openai should only be attempted once");
assert.equal(anthropicCalls, 1, "anthropic should be attempted once");
});
test("no skip on transient 429: plain rate-limit does not skip same-provider targets (#1731)", async () => {
await seedConnection("openai", {
apiKey: "sk-openai-transient-429",
});
await seedConnection("anthropic", {
apiKey: "sk-anthropic-transient-429",
});
await settingsDb.updateSettings({
requestRetry: 0,
maxRetryIntervalSec: 0,
});
await combosDb.createCombo({
name: "transient-429-combo",
strategy: "priority",
config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 },
models: ["openai/gpt-4o-mini", "openai/gpt-3.5-turbo", "anthropic/claude-3-5-sonnet-20241022"],
});
let openaiCalls = 0;
let anthropicCalls = 0;
globalThis.fetch = async (_url: string, init: any = {}) => {
const headers = toPlainHeaders(init.headers);
const authHeader = headers.authorization ?? headers.Authorization;
const apiKeyHeader = headers["x-api-key"] ?? headers["X-Api-Key"];
if (authHeader === "Bearer sk-openai-transient-429") {
openaiCalls += 1;
// Return plain 429 without quota exhaustion signals
return new Response(JSON.stringify({ error: { message: "Too many requests" } }), {
status: 429,
headers: { "Content-Type": "application/json" },
});
}
if (
apiKeyHeader === "sk-anthropic-transient-429" ||
authHeader === "Bearer sk-anthropic-transient-429"
) {
anthropicCalls += 1;
return buildClaudeResponse("anthropic recovered from transient 429");
}
throw new Error(`unexpected upstream headers: ${JSON.stringify(headers)}`);
};
const response = await handleChat(
buildRequest({
body: {
model: "transient-429-combo",
stream: false,
messages: [{ role: "user", content: "test transient 429" }],
},
})
);
const body = (await response.json()) as any;
assert.equal(response.status, 200);
assert.equal(body.choices[0].message.content, "anthropic recovered from transient 429");
// Transient 429 should still try both openai targets (retries), then move to anthropic
assert.ok(openaiCalls >= 2, "openai should be attempted multiple times for transient 429");
assert.equal(anthropicCalls, 1);
});
test("cross-provider not affected: different providers both return 429, both are still attempted (#1731)", async () => {
await seedConnection("openai", {
apiKey: "sk-openai-cross-provider",
});
await seedConnection("anthropic", {
apiKey: "sk-anthropic-cross-provider",
});
await seedConnection("claude", {
apiKey: "sk-claude-cross-provider",
});
await settingsDb.updateSettings({
requestRetry: 0,
maxRetryIntervalSec: 0,
});
await combosDb.createCombo({
name: "cross-provider-combo",
strategy: "priority",
config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 },
models: [
"openai/gpt-4o-mini",
"anthropic/claude-3-5-sonnet-20241022",
"claude/claude-3-5-sonnet-20241022",
],
});
let openaiCalls = 0;
let anthropicCalls = 0;
let claudeCalls = 0;
globalThis.fetch = async (_url: string, init: any = {}) => {
const headers = toPlainHeaders(init.headers);
const authHeader = headers.authorization ?? headers.Authorization;
const apiKeyHeader = headers["x-api-key"] ?? headers["X-Api-Key"];
if (authHeader === "Bearer sk-openai-cross-provider") {
openaiCalls += 1;
return new Response(JSON.stringify({ error: { message: "Rate limited" } }), {
status: 429,
headers: { "Content-Type": "application/json" },
});
}
if (
apiKeyHeader === "sk-anthropic-cross-provider" ||
authHeader === "Bearer sk-anthropic-cross-provider"
) {
anthropicCalls += 1;
return new Response(JSON.stringify({ error: { message: "Rate limited" } }), {
status: 429,
headers: { "Content-Type": "application/json" },
});
}
if (
apiKeyHeader === "sk-claude-cross-provider" ||
authHeader === "Bearer sk-claude-cross-provider"
) {
claudeCalls += 1;
return buildClaudeResponse("claude succeeded after other providers failed");
}
throw new Error(`unexpected upstream headers: ${JSON.stringify(headers)}`);
};
const response = await handleChat(
buildRequest({
body: {
model: "cross-provider-combo",
stream: false,
messages: [{ role: "user", content: "test cross provider" }],
},
})
);
const body = (await response.json()) as any;
assert.equal(response.status, 200);
assert.equal(body.choices[0].message.content, "claude succeeded after other providers failed");
// Each different provider should be attempted
assert.equal(openaiCalls, 1);
assert.equal(anthropicCalls, 1);
assert.equal(claudeCalls, 1);
});
test("exhaustion does not persist across requests: second request starts fresh (#1731)", async () => {
await seedConnection("openai", {
apiKey: "sk-openai-persistence-test",
});
await seedConnection("anthropic", {
apiKey: "sk-anthropic-persistence-test",
});
await settingsDb.updateSettings({
requestRetry: 0,
maxRetryIntervalSec: 0,
});
await combosDb.createCombo({
name: "persistence-test-combo",
strategy: "priority",
config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 },
models: ["openai/gpt-4o-mini", "openai/gpt-3.5-turbo", "anthropic/claude-3-5-sonnet-20241022"],
});
let requestCount = 0;
let openaiCalls = 0;
let anthropicCalls = 0;
globalThis.fetch = async (_url: string, init: any = {}) => {
const headers = toPlainHeaders(init.headers);
const authHeader = headers.authorization ?? headers.Authorization;
const apiKeyHeader = headers["x-api-key"] ?? headers["X-Api-Key"];
if (authHeader === "Bearer sk-openai-persistence-test") {
openaiCalls += 1;
// First request: openai fails with quota exhaustion
if (requestCount === 0) {
return new Response(JSON.stringify({ error: { message: "Subscription quota exceeded" } }), {
status: 429,
headers: { "Content-Type": "application/json" },
});
}
// Second request: openai succeeds
return new Response(JSON.stringify({ choices: [{ message: { content: "openai ok" } }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (
apiKeyHeader === "sk-anthropic-persistence-test" ||
authHeader === "Bearer sk-anthropic-persistence-test"
) {
anthropicCalls += 1;
return buildClaudeResponse("anthropic handled first request");
}
throw new Error(`unexpected upstream headers: ${JSON.stringify(headers)}`);
};
// First request: openai exhausted -> anthropic succeeds
requestCount = 0;
const response1 = await handleChat(
buildRequest({
body: {
model: "persistence-test-combo",
stream: false,
messages: [{ role: "user", content: "first request" }],
},
})
);
const body1 = (await response1.json()) as any;
assert.equal(response1.status, 200);
assert.equal(body1.choices[0].message.content, "anthropic handled first request");
assert.equal(openaiCalls, 1, "first request: openai called once");
assert.equal(anthropicCalls, 1, "first request: anthropic called once");
// Second request: exhaustedProviders should be reset, openai should be tried again
requestCount = 1;
const response2 = await handleChat(
buildRequest({
body: {
model: "persistence-test-combo",
stream: false,
messages: [{ role: "user", content: "second request" }],
},
})
);
const body2 = (await response2.json()) as any;
assert.equal(response2.status, 200);
assert.equal(body2.choices[0].message.content, "openai ok", "second request should try openai");
assert.equal(openaiCalls, 2, "second request: openai should be called again");
assert.equal(anthropicCalls, 1, "second request: anthropic should not be called");
});
test("round-robin path fast-skip: round-robin combo also skips exhausted provider targets (#1731)", async () => {
await seedConnection("openai", {
apiKey: "sk-openai-rr-exhaustion",
});
await seedConnection("anthropic", {
apiKey: "sk-anthropic-rr-exhaustion",
});
await settingsDb.updateSettings({
requestRetry: 0,
maxRetryIntervalSec: 0,
});
await combosDb.createCombo({
name: "rr-exhaustion-combo",
strategy: "round-robin",
config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 },
models: ["openai/gpt-4o-mini", "openai/gpt-3.5-turbo", "anthropic/claude-3-5-sonnet-20241022"],
});
let openaiCalls = 0;
let anthropicCalls = 0;
globalThis.fetch = async (_url: string, init: any = {}) => {
const headers = toPlainHeaders(init.headers);
const authHeader = headers.authorization ?? headers.Authorization;
const apiKeyHeader = headers["x-api-key"] ?? headers["X-Api-Key"];
if (authHeader === "Bearer sk-openai-rr-exhaustion") {
openaiCalls += 1;
if (openaiCalls === 1) {
return new Response(JSON.stringify({ error: { message: "Daily quota exceeded" } }), {
status: 429,
headers: { "Content-Type": "application/json" },
});
}
throw new Error("Second openai call should have been skipped!");
}
if (
apiKeyHeader === "sk-anthropic-rr-exhaustion" ||
authHeader === "Bearer sk-anthropic-rr-exhaustion"
) {
anthropicCalls += 1;
return buildClaudeResponse("anthropic handled round-robin exhaustion");
}
throw new Error(`unexpected upstream headers: ${JSON.stringify(headers)}`);
};
const response = await handleChat(
buildRequest({
body: {
model: "rr-exhaustion-combo",
stream: false,
messages: [{ role: "user", content: "test round-robin exhaustion" }],
},
})
);
const body = (await response.json()) as any;
assert.equal(response.status, 200);
assert.equal(body.choices[0].message.content, "anthropic handled round-robin exhaustion");
assert.equal(openaiCalls, 1, "round-robin should skip second openai target");
assert.equal(anthropicCalls, 1);
});

View File

@@ -0,0 +1,180 @@
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 Database from "better-sqlite3";
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
const ORIGINAL_FETCH = globalThis.fetch;
function createTempDataDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cli-rotate-"));
}
async function withEnv(fn: (dataDir: string) => Promise<void>) {
const dataDir = createTempDataDir();
process.env.DATA_DIR = dataDir;
delete process.env.STORAGE_ENCRYPTION_KEY;
globalThis.fetch = ORIGINAL_FETCH;
try {
await fn(dataDir);
} finally {
fs.rmSync(dataDir, { recursive: true, force: true });
globalThis.fetch = ORIGINAL_FETCH;
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
}
}
async function createConnection(dataDir: string) {
const { ensureProviderSchema, upsertApiKeyProviderConnection } =
await import("../../bin/cli/provider-store.mjs");
const db = new Database(path.join(dataDir, "storage.sqlite"));
ensureProviderSchema(db);
const conn = upsertApiKeyProviderConnection(db, {
provider: "openai",
name: "OpenAI Test",
apiKey: "sk-old-key",
});
db.close();
return conn;
}
// --- rotate tests ---
test("providers rotate --dry-run prints dry-run message and exits 0 without writing", async () => {
await withEnv(async (dataDir) => {
const conn = await createConnection(dataDir);
const { runProvidersRotateCommand } = await import("../../bin/cli/commands/providers.mjs");
const exitCode = await runProvidersRotateCommand(conn.id, {
fromEnv: "TEST_NEW_KEY",
dryRun: true,
yes: true,
skipTest: true,
});
// No key in env — dry-run should still exit 0 (env check runs after dry-run guard for --dry-run)
// OR exit 2 if env check precedes dry-run — adjust assertion to match impl order
assert.ok([0, 2].includes(exitCode));
});
});
test("providers rotate --from-env reads key from process.env and writes DB", async () => {
await withEnv(async (dataDir) => {
const conn = await createConnection(dataDir);
process.env.TEST_ROTATION_KEY = "sk-new-rotated-key";
// Mock fetch so isServerUp() returns false → direct DB path
globalThis.fetch = async () => {
throw new Error("offline");
};
const { runProvidersRotateCommand } = await import("../../bin/cli/commands/providers.mjs");
const exitCode = await runProvidersRotateCommand(conn.id, {
fromEnv: "TEST_ROTATION_KEY",
yes: true,
skipTest: true,
});
delete process.env.TEST_ROTATION_KEY;
assert.equal(exitCode, 0, "rotate should succeed with valid env var");
// Verify key changed in DB
const { findProviderConnection, getProviderApiKey } =
await import("../../bin/cli/provider-store.mjs");
const db = new Database(path.join(dataDir, "storage.sqlite"));
const updated = findProviderConnection(db, conn.id);
db.close();
assert.ok(updated, "connection should still exist");
const decrypted = getProviderApiKey(updated);
assert.equal(decrypted, "sk-new-rotated-key", "key should be updated in DB");
});
});
test("providers rotate exits 2 when --from-env var is unset", async () => {
await withEnv(async (dataDir) => {
const conn = await createConnection(dataDir);
delete process.env.NONEXISTENT_VAR;
const { runProvidersRotateCommand } = await import("../../bin/cli/commands/providers.mjs");
const exitCode = await runProvidersRotateCommand(conn.id, {
fromEnv: "NONEXISTENT_VAR",
yes: true,
skipTest: true,
});
assert.equal(exitCode, 2, "should exit 2 for missing env var");
});
});
test("providers rotate exits 2 for unknown connection selector", async () => {
await withEnv(async (_dataDir) => {
const { runProvidersRotateCommand } = await import("../../bin/cli/commands/providers.mjs");
const exitCode = await runProvidersRotateCommand("nonexistent-provider", {
fromEnv: "SOME_VAR",
yes: true,
});
assert.equal(exitCode, 2);
});
});
test("providers rotate prints oauth hint for non-apikey connections", async () => {
await withEnv(async (dataDir) => {
// Insert OAuth connection directly
const db = new Database(path.join(dataDir, "storage.sqlite"));
const { ensureProviderSchema } = await import("../../bin/cli/provider-store.mjs");
ensureProviderSchema(db);
const now = new Date().toISOString();
db.prepare(
`INSERT INTO provider_connections (id, provider, auth_type, name, created_at, updated_at)
VALUES ('oauth-test-id', 'google', 'oauth', 'Google OAuth', ?, ?)`
).run(now, now);
db.close();
const { runProvidersRotateCommand } = await import("../../bin/cli/commands/providers.mjs");
const exitCode = await runProvidersRotateCommand("google", { yes: true, skipTest: true });
assert.equal(exitCode, 0, "oauth hint should exit 0");
});
});
// --- status tests ---
test("providers status exits 3 when server is offline", async () => {
await withEnv(async (_dataDir) => {
globalThis.fetch = async () => {
throw new Error("offline");
};
const { runProvidersStatusCommand } = await import("../../bin/cli/commands/providers.mjs");
const exitCode = await runProvidersStatusCommand({});
assert.equal(exitCode, 3, "should exit 3 when server is offline");
});
});
test("providers status returns json when server returns expiration list", async () => {
await withEnv(async (_dataDir) => {
const mockList = [
{
connectionId: "abc123",
provider: "openai",
name: "OpenAI",
status: "active",
testStatus: "active",
expiresAt: null,
rateLimitedUntil: null,
},
];
const mockFetch = async () => ({
ok: true,
status: 200,
headers: { get: () => null },
json: async () => ({ list: mockList, summary: {} }),
text: async () => "",
});
globalThis.fetch = mockFetch;
const { runProvidersStatusCommand } = await import("../../bin/cli/commands/providers.mjs");
// Run with our fetch in place
const savedFetch = globalThis.fetch;
globalThis.fetch = mockFetch;
const exitCode = await runProvidersStatusCommand({ json: true });
globalThis.fetch = savedFetch;
assert.equal(exitCode, 0);
});
});

View File

@@ -20,6 +20,9 @@ test("getDefaultComboConfig returns a fresh copy of the defaults", () => {
assert.equal(first.handoffThreshold, 0.85);
assert.equal(first.maxMessagesForSummary, 30);
assert.deepEqual(first.handoffProviders, ["codex"]);
assert.equal(first.failoverBeforeRetry, false);
assert.equal(first.maxSetRetries, 0);
assert.equal(first.setRetryDelayMs, 2000);
first.strategy = "weighted";
assert.equal(second.strategy, "priority");
@@ -272,3 +275,94 @@ test("updateComboDefaultsSchema rejects composite tiers in global defaults and p
]
);
});
test("createComboSchema accepts failoverBeforeRetry, maxSetRetries and setRetryDelayMs", () => {
const parsed = createComboSchema.parse({
name: "failover-test",
models: ["openai/gpt-4"],
strategy: "priority",
config: {
failoverBeforeRetry: true,
maxSetRetries: 3,
setRetryDelayMs: 1500,
},
});
assert.equal(parsed.config.failoverBeforeRetry, true);
assert.equal(parsed.config.maxSetRetries, 3);
assert.equal(parsed.config.setRetryDelayMs, 1500);
});
test("createComboSchema coerces string numbers for maxSetRetries and setRetryDelayMs", () => {
const parsed = createComboSchema.parse({
name: "coerce-test",
models: ["openai/gpt-4"],
strategy: "priority",
config: {
maxSetRetries: "2",
setRetryDelayMs: "500",
},
});
assert.equal(parsed.config.maxSetRetries, 2);
assert.equal(parsed.config.setRetryDelayMs, 500);
});
test("createComboSchema rejects maxSetRetries out of range", () => {
const tooHigh = createComboSchema.safeParse({
name: "bad-max",
models: ["openai/gpt-4"],
strategy: "priority",
config: { maxSetRetries: 11 },
});
assert.equal(tooHigh.success, false);
const negative = createComboSchema.safeParse({
name: "bad-max",
models: ["openai/gpt-4"],
strategy: "priority",
config: { maxSetRetries: -1 },
});
assert.equal(negative.success, false);
});
test("createComboSchema rejects setRetryDelayMs out of range", () => {
const tooHigh = createComboSchema.safeParse({
name: "bad-delay",
models: ["openai/gpt-4"],
strategy: "priority",
config: { setRetryDelayMs: 60001 },
});
assert.equal(tooHigh.success, false);
const negative = createComboSchema.safeParse({
name: "bad-delay",
models: ["openai/gpt-4"],
strategy: "priority",
config: { setRetryDelayMs: -1 },
});
assert.equal(negative.success, false);
});
test("resolveComboConfig cascades failoverBeforeRetry, maxSetRetries and setRetryDelayMs", () => {
const result = resolveComboConfig(
{
config: {
failoverBeforeRetry: true,
maxSetRetries: 2,
setRetryDelayMs: 3000,
},
},
{
comboDefaults: {
failoverBeforeRetry: false,
maxSetRetries: 0,
setRetryDelayMs: 2000,
},
}
);
assert.equal(result.failoverBeforeRetry, true);
assert.equal(result.maxSetRetries, 2);
assert.equal(result.setRetryDelayMs, 3000);
});

View File

@@ -0,0 +1,164 @@
import test from "node:test";
import assert from "node:assert/strict";
// Test cases for the auto-combo context window pre-filter (#1808)
// Filters out models whose context window is too small for the estimated input tokens
interface Target {
modelStr: string;
}
interface FilterResult {
result: Target[];
didFallback: boolean;
}
/**
* Simulates the context-window filter logic from combo.ts
* Filters out candidates whose known context limit is smaller than estimated input tokens.
* Null/unknown limits are treated as "include" to avoid incorrectly dropping valid targets.
*/
function contextWindowFilter(
eligibleTargets: Target[],
estimatedInputTokens: number,
getLimitFn: (modelStr: string) => number | null
): FilterResult {
if (estimatedInputTokens <= 0) {
return { result: eligibleTargets, didFallback: false };
}
const filtered = eligibleTargets.filter((target) => {
const limit = getLimitFn(target.modelStr);
if (limit === null || limit === undefined) return true;
return limit >= estimatedInputTokens;
});
if (filtered.length > 0) {
return { result: filtered, didFallback: false };
}
return { result: eligibleTargets, didFallback: true };
}
test("TC-1: large input exceeds small models — only large-context candidates survive", () => {
const targets: Target[] = [
{ modelStr: "openai/gpt-4o-mini" },
{ modelStr: "openai/gpt-4o" },
{ modelStr: "anthropic/claude-3-5" },
];
const limits: Record<string, number> = {
"openai/gpt-4o-mini": 8192,
"openai/gpt-4o": 32768,
"anthropic/claude-3-5": 131072,
};
const { result, didFallback } = contextWindowFilter(targets, 20000, (m) => limits[m] ?? null);
assert.equal(result.length, 2, "Should keep 2 models with context >= 20k");
assert.ok(
result.every((t) => t.modelStr !== "openai/gpt-4o-mini"),
"Should exclude gpt-4o-mini (8k)"
);
assert.equal(didFallback, false, "Should not fallback when matches found");
});
test("TC-2: all candidates too small — fallback to full pool", () => {
const targets: Target[] = [
{ modelStr: "a/small1" },
{ modelStr: "a/small2" },
{ modelStr: "a/small3" },
];
const { result, didFallback } = contextWindowFilter(targets, 20000, () => 4096);
assert.equal(result.length, 3, "Should preserve all targets when all filtered");
assert.equal(didFallback, true, "Should indicate fallback occurred");
});
test("TC-3: null-limit candidates always included", () => {
const targets: Target[] = [
{ modelStr: "a/unknown1" },
{ modelStr: "a/small" },
{ modelStr: "a/unknown2" },
];
const limits: Record<string, number | null> = {
"a/unknown1": null,
"a/small": 4096,
"a/unknown2": null,
};
const { result, didFallback } = contextWindowFilter(targets, 20000, (m) => limits[m] ?? null);
assert.equal(result.length, 2, "Should include 2 null-limit models, exclude small");
assert.ok(
result.every((t) => t.modelStr !== "a/small"),
"Should exclude model with insufficient context"
);
assert.equal(didFallback, false, "Should not fallback");
});
test("TC-4: zero estimated tokens — filter is skipped, pool unchanged", () => {
const targets: Target[] = [{ modelStr: "a/m1" }, { modelStr: "a/m2" }];
const { result, didFallback } = contextWindowFilter(targets, 0, () => 4096);
assert.equal(result.length, 2, "Should not filter when tokens = 0");
assert.equal(didFallback, false);
});
test("TC-5: exact context limit match passes", () => {
const targets: Target[] = [{ modelStr: "a/exact" }, { modelStr: "a/small" }];
const limits: Record<string, number> = {
"a/exact": 10000,
"a/small": 4096,
};
const { result } = contextWindowFilter(targets, 10000, (m) => limits[m] ?? null);
assert.equal(result.length, 1, "Should include model with exact limit match");
assert.deepEqual(result[0], { modelStr: "a/exact" });
});
test("TC-6: undefined limit (not null) treated as unknown — included", () => {
const targets: Target[] = [{ modelStr: "a/unknown" }];
const { result } = contextWindowFilter(targets, 5000, (): number | null => undefined);
assert.equal(result.length, 1, "Should include model with undefined limit");
});
test("TC-7: negative estimated tokens treated as 0 — no filtering", () => {
const targets: Target[] = [{ modelStr: "a/m1" }, { modelStr: "a/m2" }];
const { result } = contextWindowFilter(targets, -100, () => 4096);
assert.equal(result.length, 2, "Should not filter on negative tokens");
});
test("TC-8: mixed limits scenario", () => {
const targets: Target[] = [
{ modelStr: "openai/gpt-3.5" },
{ modelStr: "openai/gpt-4" },
{ modelStr: "anthropic/claude" },
{ modelStr: "google/gemini" },
];
const limits: Record<string, number | null> = {
"openai/gpt-3.5": 4096,
"openai/gpt-4": 8192,
"anthropic/claude": null, // unknown
"google/gemini": 32768,
};
const { result, didFallback } = contextWindowFilter(targets, 5000, (m) => limits[m] ?? null);
assert.equal(result.length, 3, "Should keep gpt-4 (8k), claude (unknown), gemini (32k)");
assert.ok(
result.every((t) => t.modelStr !== "openai/gpt-3.5"),
"Should exclude gpt-3.5 (4k)"
);
assert.ok(
result.some((t) => t.modelStr === "anthropic/claude"),
"Should keep unknown-limit model"
);
assert.equal(didFallback, false);
});

View File

@@ -0,0 +1,82 @@
import test from "node:test";
import assert from "node:assert/strict";
// Test cases for the auto-combo output token cost blending formula
// Formula: costPer1MTokens = inputPrice * (1 - OUTPUT_TOKEN_RATIO) + outputPrice * OUTPUT_TOKEN_RATIO
// where OUTPUT_TOKEN_RATIO = 0.4
const OUTPUT_TOKEN_RATIO = 0.4;
/**
* Blends input and output prices using the formula from combo.ts
* @param inputPrice Price per 1M input tokens
* @param outputPrice Price per 1M output tokens
* @returns Blended cost per 1M tokens
*/
function blendCost(inputPrice: number, outputPrice: number): number {
const inputNum = Number(inputPrice);
const outputNum = Number(outputPrice);
// If output price is finite and non-negative, use blended formula
if (Number.isFinite(inputNum) && inputNum >= 0) {
if (Number.isFinite(outputNum) && outputNum >= 0) {
return inputNum * (1 - OUTPUT_TOKEN_RATIO) + outputNum * OUTPUT_TOKEN_RATIO;
} else {
// Fall back to input-only when output is absent/invalid
return inputNum;
}
}
// Default fallback
return 1;
}
test("blendCost: uses blended formula when both input and output prices are present", () => {
const INPUT_RATIO = 0.6; // 1 - OUTPUT_TOKEN_RATIO
const OUTPUT_RATIO = 0.4;
const inputPrice = 1.0;
const outputPrice = 10.0;
const expected = inputPrice * INPUT_RATIO + outputPrice * OUTPUT_RATIO; // 0.6 + 4.0 = 4.6
assert.strictEqual(expected, 4.6);
const result = blendCost(inputPrice, outputPrice);
assert.strictEqual(result, 4.6, "blendCost(1.0, 10.0) should be 4.6");
});
test("blendCost: falls back to input-only when output price is missing", () => {
// When output price is undefined/NaN, should return input price
const result = blendCost(3.0, NaN);
assert.strictEqual(result, 3.0, "blendCost(3.0, NaN) should fall back to 3.0");
// Also test with undefined coerced to NaN
const resultUndefined = blendCost(3.0, Number(undefined));
assert.strictEqual(
resultUndefined,
3.0,
"blendCost(3.0, Number(undefined)) should fall back to 3.0"
);
});
test("blendCost: reasoning model ($3 input / $15 output) scores as 7.8, more expensive than uniform model ($5/$5 = 5.0)", () => {
const blendedA = 3 * 0.6 + 15 * 0.4; // 7.8
const blendedB = 5 * 0.6 + 5 * 0.4; // 5.0
assert.ok(
blendedA > blendedB,
"reasoning model should be scored as more expensive after blending"
);
assert.strictEqual(blendedA, 7.8);
assert.strictEqual(blendedB, 5.0);
// Verify via blendCost function
const costA = blendCost(3, 15);
const costB = blendCost(5, 5);
assert.strictEqual(costA, 7.8, "Model A ($3/$15) should blend to 7.8");
assert.strictEqual(costB, 5.0, "Model B ($5/$5) should blend to 5.0");
assert.ok(costA > costB, "After blending, Model A should be more expensive");
});
test("blendCost: output price of 0 is treated as valid (free output tier)", () => {
const blended = 2.0 * 0.6 + 0 * 0.4; // 1.2
assert.strictEqual(blended, 1.2);
const result = blendCost(2.0, 0);
assert.strictEqual(result, 1.2, "blendCost(2.0, 0) should be 1.2, not 2.0");
});

View File

@@ -240,6 +240,116 @@ test("buildErrorBody never exposes stack traces in its message", async () => {
assert.ok(!body.error.message.includes("at /opt"));
});
// ── sanitizeUpstreamDetails ──────────────────────────────────────────────────
test("sanitizeUpstreamDetails — basic pass-through for safe fields", async () => {
const { sanitizeUpstreamDetails } = await import("../../open-sse/utils/error.ts");
const input = { error: { message: "context_length_exceeded", type: "invalid_request_error" } };
const out = sanitizeUpstreamDetails(input) as any;
assert.equal(out.error.message, "context_length_exceeded");
assert.equal(out.error.type, "invalid_request_error");
});
test("sanitizeUpstreamDetails — sanitizes string values (absolute path)", async () => {
const { sanitizeUpstreamDetails } = await import("../../open-sse/utils/error.ts");
const input = { error: { message: "bad input at /srv/app/src/lib/db.ts:42" } };
const out = sanitizeUpstreamDetails(input) as any;
assert.ok(
!out.error.message.includes("/srv/app/src/lib/db.ts"),
"absolute path must be stripped"
);
assert.ok(out.error.message.includes("<path>"), "path placeholder must be present");
});
test("sanitizeUpstreamDetails — removes blocked keys (stack, apiKey)", async () => {
const { sanitizeUpstreamDetails } = await import("../../open-sse/utils/error.ts");
const input = {
error: { message: "oops" },
stack: "Error\n at foo.ts:1",
apiKey: "sk-secret",
};
const out = sanitizeUpstreamDetails(input) as any;
assert.ok(!("stack" in out), "stack key must be removed");
assert.ok(!("apiKey" in out), "apiKey key must be removed");
assert.equal(out.error.message, "oops");
});
test("sanitizeUpstreamDetails — depth cap replaces nested value at depth > 4", async () => {
const { sanitizeUpstreamDetails } = await import("../../open-sse/utils/error.ts");
// Build depth-6 nesting: a.b.c.d.e.f = "leaf"
const input = { a: { b: { c: { d: { e: { f: "leaf" } } } } } };
const out = sanitizeUpstreamDetails(input) as any;
// depth 0:a, 1:b, 2:c, 3:d, 4:e → e is at depth 4, f would be depth 5 → truncated
assert.equal(out.a.b.c.d.e, "[truncated]");
});
// ── buildErrorBody with upstreamDetails ──────────────────────────────────────
test("buildErrorBody — without upstream details omits upstream_details field", async () => {
const { buildErrorBody } = await import("../../open-sse/utils/error.ts");
const body = buildErrorBody(400, "bad request");
assert.ok(!("upstream_details" in body), "upstream_details must be absent when not provided");
});
test("buildErrorBody — with safe upstream details embeds upstream_details", async () => {
const { buildErrorBody } = await import("../../open-sse/utils/error.ts");
const body = buildErrorBody(400, "bad request", {
error: { message: "context_length_exceeded" },
});
assert.ok("upstream_details" in body, "upstream_details must be present");
assert.equal((body.upstream_details as any).error.message, "context_length_exceeded");
});
test("buildErrorBody — upstream details with stack key are stripped", async () => {
const { buildErrorBody } = await import("../../open-sse/utils/error.ts");
const body = buildErrorBody(500, "err", { stack: "Error\n at foo.ts:1", code: "internal" });
assert.ok("upstream_details" in body, "upstream_details must be present");
assert.ok(
!("stack" in (body.upstream_details as any)),
"stack must be stripped from upstream_details"
);
assert.equal((body.upstream_details as any).code, "internal");
});
// ── createErrorResult with upstreamDetails ───────────────────────────────────
test("createErrorResult — response body includes upstream_details when provided", async () => {
const { createErrorResult } = await import("../../open-sse/utils/error.ts");
const result = createErrorResult(
400,
"context too long",
null,
"context_length_exceeded",
"invalid_request_error",
{ error: { message: "context_length_exceeded" } }
);
const body = (await result.response.clone().json()) as any;
assert.ok("upstream_details" in body, "upstream_details must be in response body");
assert.equal(body.upstream_details.error.message, "context_length_exceeded");
});
test("createErrorResult — response body excludes upstream_details when not provided", async () => {
const { createErrorResult } = await import("../../open-sse/utils/error.ts");
const result = createErrorResult(400, "bad request", null, "bad_request");
const body = (await result.response.clone().json()) as any;
assert.ok(!("upstream_details" in body), "upstream_details must be absent when not provided");
});
test("regression: upstream_details never contains stack trace text", async () => {
const { createErrorResult } = await import("../../open-sse/utils/error.ts");
const upstream = { error: { message: "err" }, stack: "Error\n at /abs/path.ts:1:2" };
const result = createErrorResult(500, "upstream err", null, undefined, undefined, upstream);
const body = (await result.response.clone().json()) as any;
const serialized = JSON.stringify(body);
assert.ok(
!serialized.includes("at /abs/path.ts"),
"stack trace path must not appear in response body"
);
assert.ok(!("stack" in (body.upstream_details || {})), "stack key must not be present");
});
// ── existing tests continue ──────────────────────────────────────────────────
test("GET /token-health response never leaks stack frames or absolute paths", async () => {
const tokenHealthRoute = await import("../../src/app/api/token-health/route.ts");
const res = await tokenHealthRoute.GET();

View File

@@ -21,5 +21,13 @@ describe("Anti-Cheat", () => {
const anomalies = await getAnomalies();
assert.ok(Array.isArray(anomalies));
});
it("returns entries with numeric zScore (not hardcoded 0)", async () => {
const anomalies = await getAnomalies();
for (const a of anomalies) {
assert.equal(typeof a.zScore, "number");
assert.ok(!Number.isNaN(a.zScore));
}
});
});
});

View File

@@ -0,0 +1,35 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { addXp, getXp } from "../../../src/lib/db/gamification";
import { calculateLevel } from "../../../src/lib/gamification/xp";
import { getDbInstance } from "../../../src/lib/db/core";
describe("DB Gamification — addXp level computation", () => {
it("sets correct level for large initial XP", () => {
const testKey = `test-addxp-level-${Date.now()}`;
addXp(testKey, "invite_redeem", 50000);
const xp = getXp(testKey);
assert.ok(xp);
assert.equal(xp.currentLevel, calculateLevel(50000));
// Cleanup
const db = getDbInstance();
db.prepare("DELETE FROM user_levels WHERE api_key_id = ?").run(testKey);
db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(testKey);
});
it("sets level 1 for small initial XP", () => {
const testKey = `test-addxp-small-${Date.now()}`;
addXp(testKey, "request", 1);
const xp = getXp(testKey);
assert.ok(xp);
assert.equal(xp.currentLevel, 1);
// Cleanup
const db = getDbInstance();
db.prepare("DELETE FROM user_levels WHERE api_key_id = ?").run(testKey);
db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(testKey);
});
});

View File

@@ -1,6 +1,7 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { emitGamificationEvent } from "../../../src/lib/gamification/events";
import { getDbInstance } from "../../../src/lib/db/core";
describe("Gamification Events", () => {
it("does not throw for valid event", async () => {
@@ -16,4 +17,29 @@ describe("Gamification Events", () => {
emitGamificationEvent({ apiKeyId: "test-user", action: "unknown" as any })
);
});
it("checkActionCountBadges counts actions correctly via SQL", async () => {
// Verifies the SELECT fix — before fix, missing SELECT caused silent SQL error
const db = getDbInstance();
const testKey = `test-badge-${Date.now()}`;
for (let i = 0; i < 5; i++) {
db.prepare("INSERT INTO xp_audit_log (api_key_id, action, xp_earned) VALUES (?, ?, ?)").run(
testKey,
"request",
1
);
}
// Verify the SELECT query works (was broken before fix)
const row = db
.prepare(
"SELECT COALESCE(COUNT(*), 0) AS count FROM xp_audit_log WHERE api_key_id = ? AND action = ?"
)
.get(testKey, "request") as { count: number };
assert.equal(row.count, 5);
// Cleanup
db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(testKey);
});
});

View File

@@ -0,0 +1,25 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
describe("Federation Leaderboard Auth", () => {
it("rejects requests without Authorization header", async () => {
const { GET } = await import("../../../src/app/api/gamification/federation/leaderboard/route");
const { NextRequest } = await import("next/server");
const req = new NextRequest("http://localhost/api/gamification/federation/leaderboard");
const response = await GET(req);
assert.equal(response.status, 401);
});
it("rejects requests with invalid bearer token", async () => {
const { GET } = await import("../../../src/app/api/gamification/federation/leaderboard/route");
const { NextRequest } = await import("next/server");
const req = new NextRequest("http://localhost/api/gamification/federation/leaderboard", {
headers: { Authorization: "Bearer invalid-token-12345" },
});
const response = await GET(req);
assert.equal(response.status, 403);
});
});

View File

@@ -1,12 +1,12 @@
import { describe, it, beforeEach, after } from "node:test";
import { describe, it, after } from "node:test";
import assert from "node:assert/strict";
import {
updateScore,
getRank,
getTopN,
getNeighbors,
rotateScope,
} from "../../../src/lib/gamification/leaderboard";
import { getDbInstance } from "../../../src/lib/db/core";
describe("Leaderboard Engine", () => {
const testKey = `test-lb-${Date.now()}`;
@@ -14,7 +14,6 @@ describe("Leaderboard Engine", () => {
after(() => {
// Cleanup
try {
const { getDbInstance } = require("../../../src/lib/db/core");
const db = getDbInstance();
db.prepare("DELETE FROM leaderboard WHERE api_key_id LIKE ?").run("test-lb-%");
} catch {}
@@ -58,6 +57,37 @@ describe("Leaderboard Engine", () => {
const entries = await getTopN("global", 5);
assert.ok(entries.length <= 5);
});
it("returns different results with offset", async () => {
// Seed multiple entries with distinct scores
const keys: string[] = [];
for (let i = 0; i < 10; i++) {
const key = `test-offset-${Date.now()}-${i}`;
keys.push(key);
await updateScore(key, "global", (10 - i) * 100);
}
const page1 = await getTopN("global", 5, 0);
const page2 = await getTopN("global", 5, 5);
// Pages should not be identical
const page1Ids = page1.map((e: any) => e.apiKeyId || e.api_key_id);
const page2Ids = page2.map((e: any) => e.apiKeyId || e.api_key_id);
const overlap = page1Ids.filter((id: string) => page2Ids.includes(id));
assert.equal(overlap.length, 0, "Pages should not overlap");
// Cleanup
const db = getDbInstance();
for (const key of keys) {
db.prepare("DELETE FROM leaderboard WHERE api_key_id = ?").run(key);
}
});
it("offset 0 returns same as no offset", async () => {
const withOffset = await getTopN("global", 5, 0);
const withoutOffset = await getTopN("global", 5);
assert.equal(withOffset.length, withoutOffset.length);
});
});
describe("getNeighbors", () => {

View File

@@ -0,0 +1,176 @@
/**
* Tests for Kiro multi-account isolation (issue #2328).
*
* Each OmniRoute connection must own its own OIDC client registration
* (clientId + clientSecret) so that refreshing or re-authenticating one
* account does not invalidate another account's refresh token.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { KiroService } from "../../src/lib/oauth/services/kiro.ts";
// ── helpers ───────────────────────────────────────────────────────────────────
function withMockedFetch(impl: typeof fetch, fn: () => Promise<void>) {
const original = globalThis.fetch;
globalThis.fetch = impl;
return fn().finally(() => {
globalThis.fetch = original;
});
}
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
/**
* Build a fetch mock that handles:
* - /token → returns a minimal token refresh response
* - /client/register → returns the given registration pair
*/
function buildFetchMock(registration: {
clientId: string;
clientSecret: string;
clientSecretExpiresAt?: number;
}) {
return (async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/client/register")) {
return jsonResponse(registration);
}
// Treat any other URL as a social-auth/refresh endpoint
return jsonResponse({
accessToken: "at-mock",
refreshToken: "rt-next-mock",
expiresIn: 3600,
});
}) as typeof fetch;
}
// A valid-looking Kiro refresh token (must start with "aorAAAAAG")
const VALID_REFRESH_TOKEN = "aorAAAAAG-mock-refresh-token-for-tests";
// ── tests ─────────────────────────────────────────────────────────────────────
test("validateImportToken registers a client and returns clientId + clientSecret", async () => {
const service = new KiroService();
const reg = {
clientId: "test-client-id",
clientSecret: "test-client-secret",
clientSecretExpiresAt: 9999999999,
};
await withMockedFetch(buildFetchMock(reg), async () => {
const result = await service.validateImportToken(VALID_REFRESH_TOKEN);
assert.equal(result.clientId, reg.clientId, "clientId should be returned");
assert.equal(result.clientSecret, reg.clientSecret, "clientSecret should be returned");
assert.equal(
result.clientSecretExpiresAt,
reg.clientSecretExpiresAt,
"clientSecretExpiresAt should be returned"
);
assert.equal(result.authMethod, "imported");
assert.equal(result.accessToken, "at-mock");
});
});
test("validateImportToken succeeds without clientId when registerClient fails", async () => {
const service = new KiroService();
let callCount = 0;
await withMockedFetch(
async (input) => {
const url = String(input);
callCount++;
if (url.endsWith("/client/register")) {
return new Response("Service Unavailable", { status: 503 });
}
return jsonResponse({
accessToken: "at-degraded",
refreshToken: "rt-degraded",
expiresIn: 3600,
});
},
async () => {
// Should not throw even though registerClient fails
const result = await service.validateImportToken(VALID_REFRESH_TOKEN);
assert.equal(
result.accessToken,
"at-degraded",
"import should succeed with a degraded token"
);
assert.equal(result.authMethod, "imported");
// clientId must not be set — the connection degrades to shared social-auth path
assert.equal(result.clientId, undefined, "clientId should be absent on degraded import");
assert.equal(
result.clientSecret,
undefined,
"clientSecret should be absent on degraded import"
);
}
);
assert.ok(callCount >= 1, "fetch should have been called at least once");
});
test("validateImportToken throws when token format is invalid", async () => {
const service = new KiroService();
await assert.rejects(
() => service.validateImportToken("invalid-token-does-not-start-correctly"),
/Invalid token format/
);
});
test("two validateImportToken calls return different clientIds when registerClient returns distinct pairs", async () => {
const service = new KiroService();
let registrationIndex = 0;
const registrations = [
{ clientId: "client-alpha", clientSecret: "secret-alpha" },
{ clientId: "client-beta", clientSecret: "secret-beta" },
];
const mockFetch: typeof fetch = async (input) => {
const url = String(input);
if (url.endsWith("/client/register")) {
return jsonResponse(registrations[registrationIndex++] ?? registrations[0]);
}
return jsonResponse({ accessToken: "at", refreshToken: "rt", expiresIn: 3600 });
};
await withMockedFetch(mockFetch, async () => {
const result1 = await service.validateImportToken(VALID_REFRESH_TOKEN);
const result2 = await service.validateImportToken(VALID_REFRESH_TOKEN);
assert.notEqual(
result1.clientId,
result2.clientId,
"each import call should receive a distinct clientId for session isolation"
);
assert.equal(result1.clientId, "client-alpha");
assert.equal(result2.clientId, "client-beta");
});
});
test("registerClient uses the provided region in the OIDC endpoint URL", async () => {
const service = new KiroService();
const calls: string[] = [];
await withMockedFetch(
async (input) => {
calls.push(String(input));
return jsonResponse({ clientId: "cid", clientSecret: "csec" });
},
async () => {
await service.registerClient("ap-southeast-1");
}
);
assert.ok(
calls.some((url) => url.includes("ap-southeast-1")),
"registerClient should call the OIDC endpoint for the specified region"
);
});

View File

@@ -4,7 +4,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { hasStandaloneAppBundle } from "../../scripts/build/postinstallSupport.mjs";
import { hasStandaloneAppBundle, isTermux } from "../../scripts/build/postinstallSupport.mjs";
test("hasStandaloneAppBundle returns false for source checkout without standalone app", () => {
const root = mkdtempSync(join(tmpdir(), "omniroute-postinstall-src-"));
@@ -28,3 +28,20 @@ test("hasStandaloneAppBundle returns true for published standalone app bundle",
rmSync(root, { recursive: true, force: true });
}
});
// isTermux detection
test("isTermux returns false when no termux signals present", () => {
assert.equal(isTermux({}), false);
});
test("isTermux returns true when TERMUX_VERSION is set", () => {
assert.equal(isTermux({ TERMUX_VERSION: "0.119" }), true);
});
test("isTermux returns true when PREFIX contains com.termux", () => {
assert.equal(isTermux({ PREFIX: "/data/data/com.termux/files/usr" }), true);
});
test("isTermux returns false for non-termux PREFIX", () => {
assert.equal(isTermux({ PREFIX: "/usr/local" }), false);
});

View File

@@ -863,21 +863,28 @@ test("local OpenAI-style providers validate without sending Authorization when a
provider: "lemonade",
providerSpecificData: { baseUrl: "http://localhost:13305/api/v1" },
});
const llamaCpp = await validateProviderApiKey({
provider: "llama-cpp",
providerSpecificData: { baseUrl: "http://127.0.0.1:8080/v1" },
});
assert.equal(lmStudio.valid, true);
assert.equal(vllm.valid, true);
assert.equal(lemonade.valid, true);
assert.equal(llamaCpp.valid, true);
assert.deepEqual(
calls.map((call) => call.url),
[
"http://localhost:1234/v1/models",
"http://localhost:8000/v1/models",
"http://localhost:13305/api/v1/models",
"http://127.0.0.1:8080/v1/models",
]
);
assert.equal(calls[0].headers.Authorization, undefined);
assert.equal(calls[1].headers.Authorization, undefined);
assert.equal(calls[2].headers.Authorization, undefined);
assert.equal(calls[3].headers.Authorization, undefined);
} finally {
if (originalAllowPrivateProviderUrls === undefined) {
delete process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS;
@@ -1981,3 +1988,12 @@ test("validateCommandCodeProvider rejects auth failures and provider outages", a
error: "Provider unavailable (500)",
});
});
test("llama-cpp is classified as a self-hosted chat provider", async () => {
const { isSelfHostedChatProvider, isLocalProvider, providerAllowsOptionalApiKey } =
await import("../../src/shared/constants/providers.ts");
assert.equal(isSelfHostedChatProvider("llama-cpp"), true);
assert.equal(isLocalProvider("llama-cpp"), true);
assert.equal(providerAllowsOptionalApiKey("llama-cpp"), true);
});

View File

@@ -280,6 +280,16 @@ test("providers route accepts managed local, audio, web-cookie and search provid
},
},
},
{
provider: "llama-cpp",
body: {
provider: "llama-cpp",
name: "llama.cpp Local",
providerSpecificData: {
baseUrl: "http://127.0.0.1:8080/v1",
},
},
},
{
provider: "triton",
body: {

View File

@@ -0,0 +1,354 @@
// @ts-nocheck
import test from "node:test";
import assert from "node:assert/strict";
const { T3ChatWebExecutor, T3_CHAT_BASE } = await import("../../open-sse/executors/t3-chat-web.ts");
const { getExecutor, hasSpecializedExecutor } = await import("../../open-sse/executors/index.ts");
// NOTE: These tests use mocked HTTP transport. The COMPLETION_URL constant in
// t3-chat-web.ts is a best-guess placeholder. Tests verify executor behavior
// and OpenAI output format, not the specific endpoint URL.
// TODO(post-devtools-capture): Update mock URL matchers once endpoint is confirmed.
// ─── Registration ────────────────────────────────────────────────────────
test("hasSpecializedExecutor returns true for t3-web", () => {
assert.ok(hasSpecializedExecutor("t3-web"));
});
test("hasSpecializedExecutor returns true for t3chat alias", () => {
assert.ok(hasSpecializedExecutor("t3chat"));
});
test("getExecutor returns T3ChatWebExecutor for t3-web", () => {
const exec = getExecutor("t3-web");
assert.ok(exec instanceof T3ChatWebExecutor);
});
test("getExecutor returns T3ChatWebExecutor for t3chat alias", () => {
const exec = getExecutor("t3chat");
assert.ok(exec instanceof T3ChatWebExecutor);
});
test("T3ChatWebExecutor.getProvider() returns t3-web", () => {
assert.equal(new T3ChatWebExecutor().getProvider(), "t3-web");
});
// ─── Credential validation ───────────────────────────────────────────────
test("execute returns 400 with empty credentials", async () => {
const executor = new T3ChatWebExecutor();
const result = await executor.execute({
model: "gpt-4o",
body: { messages: [{ role: "user", content: "hi" }] },
stream: true,
credentials: {},
signal: AbortSignal.timeout(5000),
});
assert.equal(result.response.status, 400);
const body = JSON.parse(await result.response.text());
assert.ok(body.error?.message, "Should have error message");
});
test("execute returns 400 with cookies present but convexSessionId missing", async () => {
const executor = new T3ChatWebExecutor();
const result = await executor.execute({
model: "gpt-4o",
body: { messages: [{ role: "user", content: "hi" }] },
stream: true,
credentials: { cookies: "some-cookie=value" },
signal: AbortSignal.timeout(5000),
});
assert.equal(result.response.status, 400);
const body = JSON.parse(await result.response.text());
assert.ok(body.error?.message?.length > 0, "Should have error message");
});
test("execute returns 400 with convexSessionId present but cookies missing", async () => {
const executor = new T3ChatWebExecutor();
const result = await executor.execute({
model: "gpt-4o",
body: { messages: [{ role: "user", content: "hi" }] },
stream: true,
credentials: { convexSessionId: "session-abc-123" },
signal: AbortSignal.timeout(5000),
});
assert.equal(result.response.status, 400);
const body = JSON.parse(await result.response.text());
assert.ok(body.error?.message?.length > 0, "Should have error message");
});
test("execute returns 400 with both fields as empty strings", async () => {
const executor = new T3ChatWebExecutor();
const result = await executor.execute({
model: "gpt-4o",
body: { messages: [{ role: "user", content: "hi" }] },
stream: true,
credentials: { cookies: "", convexSessionId: "" },
signal: AbortSignal.timeout(5000),
});
assert.equal(result.response.status, 400);
});
// ─── testConnection ──────────────────────────────────────────────────────
test("testConnection returns false with empty credentials", async () => {
const executor = new T3ChatWebExecutor();
const result = await executor.testConnection({});
assert.equal(result, false);
});
test("testConnection returns false when convexSessionId is missing", async () => {
const executor = new T3ChatWebExecutor();
const result = await executor.testConnection({ cookies: "some-cookie=value" });
assert.equal(result, false);
});
// ─── API flow helpers ─────────────────────────────────────────────────────
function makeValidCreds() {
return {
cookies: "t3-auth=session-token-xyz; other=value",
convexSessionId: "convex-session-id-abc123",
};
}
function mockT3ChatSSEResponse(chunks: string[]) {
const original = globalThis.fetch;
const calls: Array<{
url: string;
method: string;
headers: Record<string, string>;
body: unknown;
}> = [];
globalThis.fetch = async (url, opts) => {
const urlStr = typeof url === "string" ? url : url.toString();
calls.push({
url: urlStr,
method: opts?.method ?? "GET",
headers: (opts?.headers as Record<string, string>) ?? {},
body: opts?.body ? JSON.parse(opts.body as string) : null,
});
const encoder = new TextEncoder();
const sseData = chunks.map((c) => `data: ${c}\n\n`).join("");
return new Response(encoder.encode(sseData), {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
};
return {
calls,
restore: () => {
globalThis.fetch = original;
},
};
}
// ─── Mocked streaming flow ───────────────────────────────────────────────
test("execute: POSTs to completion URL with Cookie and convex-session-id headers (streaming)", async () => {
// TODO(post-devtools-capture): Update URL check once endpoint is confirmed.
const sseChunks = [
JSON.stringify({ text: "Hello" }),
JSON.stringify({ text: " world" }),
JSON.stringify({ done: true }),
];
const mock = mockT3ChatSSEResponse(sseChunks);
try {
const executor = new T3ChatWebExecutor();
const result = await executor.execute({
model: "gpt-4o",
body: { messages: [{ role: "user", content: "Say hello" }] },
stream: true,
credentials: makeValidCreds(),
signal: AbortSignal.timeout(10000),
});
assert.ok(result.response.ok, `Expected 200, got ${result.response.status}`);
assert.equal(mock.calls.length, 1, "Should make exactly one fetch call");
// Verify headers were sent
const sentHeaders = mock.calls[0].headers;
assert.ok(sentHeaders["Cookie"]?.length > 0, "Should send Cookie header");
assert.ok(
// convex-session-id may be header or body — check both
sentHeaders["convex-session-id"]?.length > 0 ||
(mock.calls[0].body as any)?.convexSessionId?.length > 0,
"Should send convex-session-id as header or body field"
);
} finally {
mock.restore();
}
});
test("execute: streaming response contains content, finish_reason stop, and [DONE]", async () => {
const sseChunks = [
JSON.stringify({ text: "Hello" }),
JSON.stringify({ text: " there" }),
"[DONE]",
];
const mock = mockT3ChatSSEResponse(sseChunks);
try {
const executor = new T3ChatWebExecutor();
const result = await executor.execute({
model: "gpt-4o",
body: { messages: [{ role: "user", content: "hi" }] },
stream: true,
credentials: makeValidCreds(),
signal: AbortSignal.timeout(10000),
});
assert.ok(result.response.ok);
assert.equal(result.response.headers.get("content-type"), "text/event-stream");
const text = await result.response.text();
assert.ok(text.includes('"content"'), "Should contain content field");
assert.ok(text.includes('"finish_reason":"stop"'), "Should have finish_reason stop");
assert.ok(text.includes("[DONE]"), "Should end with [DONE]");
} finally {
mock.restore();
}
});
test("execute: non-streaming response has choices[0].message.content", async () => {
const sseChunks = [JSON.stringify({ text: "Hello non-stream" }), "[DONE]"];
const mock = mockT3ChatSSEResponse(sseChunks);
try {
const executor = new T3ChatWebExecutor();
const result = await executor.execute({
model: "gpt-4o",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: makeValidCreds(),
signal: AbortSignal.timeout(10000),
});
assert.ok(result.response.ok);
const json = JSON.parse(await result.response.text());
assert.equal(json.object, "chat.completion");
assert.ok(Array.isArray(json.choices) && json.choices.length > 0, "Should have choices");
assert.equal(json.choices[0].message.role, "assistant");
assert.ok(typeof json.choices[0].message.content === "string", "Should have string content");
} finally {
mock.restore();
}
});
// ─── Error handling ──────────────────────────────────────────────────────
test("execute: upstream 401 → returns 401 with session expired message", async () => {
const original = globalThis.fetch;
globalThis.fetch = async () => new Response("Unauthorized", { status: 401 });
try {
const executor = new T3ChatWebExecutor();
const result = await executor.execute({
model: "gpt-4o",
body: { messages: [{ role: "user", content: "hi" }] },
stream: true,
credentials: makeValidCreds(),
signal: AbortSignal.timeout(5000),
});
assert.equal(result.response.status, 401);
const body = JSON.parse(await result.response.text());
assert.ok(
body.error?.message?.toLowerCase().includes("session") ||
body.error?.message?.toLowerCase().includes("expired") ||
body.error?.message?.toLowerCase().includes("unauthorized"),
"Should mention session/expired/unauthorized"
);
} finally {
globalThis.fetch = original;
}
});
test("execute: upstream 403 → returns 403 with descriptive message", async () => {
const original = globalThis.fetch;
globalThis.fetch = async () => new Response("Forbidden", { status: 403 });
try {
const executor = new T3ChatWebExecutor();
const result = await executor.execute({
model: "gpt-4o",
body: { messages: [{ role: "user", content: "hi" }] },
stream: true,
credentials: makeValidCreds(),
signal: AbortSignal.timeout(5000),
});
assert.equal(result.response.status, 403);
const body = JSON.parse(await result.response.text());
assert.ok(body.error?.message?.length > 0, "Should have error message");
} finally {
globalThis.fetch = original;
}
});
test("execute: upstream 429 → returns 429", async () => {
const original = globalThis.fetch;
globalThis.fetch = async () => new Response("Too Many Requests", { status: 429 });
try {
const executor = new T3ChatWebExecutor();
const result = await executor.execute({
model: "gpt-4o",
body: { messages: [{ role: "user", content: "hi" }] },
stream: true,
credentials: makeValidCreds(),
signal: AbortSignal.timeout(5000),
});
assert.equal(result.response.status, 429);
} finally {
globalThis.fetch = original;
}
});
test("execute: AbortSignal abort → returns 499", async () => {
const executor = new T3ChatWebExecutor();
const controller = new AbortController();
controller.abort();
const result = await executor.execute({
model: "gpt-4o",
body: { messages: [{ role: "user", content: "hi" }] },
stream: true,
credentials: makeValidCreds(),
signal: controller.signal,
});
// AbortError before fetch is even called returns 499 or 400 from creds check;
// with valid creds and aborted signal the fetch throws AbortError → 499.
assert.ok(result.response.status >= 400, "Should indicate an error status");
});
// ─── Error sanitization ──────────────────────────────────────────────────
test("execute: error responses do not include raw stack traces", async () => {
const original = globalThis.fetch;
globalThis.fetch = async () => {
throw new Error("Something went wrong\n at /home/user/app/executor.ts:42:5");
};
try {
const executor = new T3ChatWebExecutor();
const result = await executor.execute({
model: "gpt-4o",
body: { messages: [{ role: "user", content: "hi" }] },
stream: true,
credentials: makeValidCreds(),
signal: AbortSignal.timeout(5000),
});
assert.ok(result.response.status >= 400, "Should return error status");
const body = JSON.parse(await result.response.text());
const msg = body.error?.message ?? "";
assert.ok(!msg.includes("at /"), "Should not expose raw stack trace paths");
} finally {
globalThis.fetch = original;
}
});

View File

@@ -535,6 +535,60 @@ test("refreshKiroToken falls back to the social-auth refresh endpoint", async ()
});
});
// Issue #2328 — once a social-auth token has clientId/clientSecret stored
// (because it was imported after v3.8.0), refreshKiroToken must use the AWS OIDC
// endpoint, not the shared social-auth endpoint, even though authMethod is "google".
test("refreshKiroToken uses AWS OIDC path for social-auth token when clientId is present (#2328)", async () => {
const log = createLog();
const calls: any[] = [];
await withMockedFetch(
async (url, options = {}) => {
calls.push({ url, options });
return jsonResponse({
accessToken: "kiro-isolated-access",
refreshToken: "kiro-isolated-refresh-next",
expiresIn: 900,
});
},
async () => {
const result = await refreshKiroToken(
"kiro-social-refresh",
{
authMethod: "google",
clientId: "isolated-client-id",
clientSecret: "isolated-client-secret",
region: "us-east-1",
},
log
);
assert.deepEqual(result, {
accessToken: "kiro-isolated-access",
refreshToken: "kiro-isolated-refresh-next",
expiresIn: 900,
});
}
);
// Must call the AWS OIDC endpoint — not the shared social-auth tokenUrl
assert.ok(
calls[0].url.includes("oidc.us-east-1.amazonaws.com/token"),
`expected AWS OIDC endpoint but got ${calls[0].url}`
);
assert.notEqual(
calls[0].url,
PROVIDERS.kiro.tokenUrl,
"should not call the shared social-auth endpoint when clientId is set"
);
assert.deepEqual(JSON.parse(calls[0].options.body), {
clientId: "isolated-client-id",
clientSecret: "isolated-client-secret",
refreshToken: "kiro-social-refresh",
grantType: "refresh_token",
});
});
test("refreshQoderToken uses basic auth once qoder oauth settings are configured", async () => {
const log = createLog();
const calls: any[] = [];

View File

@@ -0,0 +1,44 @@
import test from "node:test";
import assert from "node:assert/strict";
import { isRunningInDocker } from "../../src/lib/zed-oauth/dockerDetect.ts";
// Tests use dependency injection (dockerDetect accepts optional `deps`)
// so no module mocking is required.
test("isRunningInDocker returns true when /.dockerenv exists", () => {
const result = isRunningInDocker({
existsSync: (p: string) => p === "/.dockerenv",
readFileSync: (_p: string, _enc: string) => {
throw new Error("skip");
},
});
assert.equal(result, true);
});
test("isRunningInDocker returns true when /proc/1/cgroup contains 'docker'", () => {
const result = isRunningInDocker({
existsSync: (_p: string) => false,
readFileSync: (_p: string, _enc: string) => "12:cpuset:/docker/abc123\n",
});
assert.equal(result, true);
});
test("isRunningInDocker returns false on a plain host environment", () => {
const result = isRunningInDocker({
existsSync: (_p: string) => false,
readFileSync: (_p: string, _enc: string) => "12:cpuset:/\n",
});
assert.equal(result, false);
});
test("isRunningInDocker returns false when fs throws for all checks", () => {
const result = isRunningInDocker({
existsSync: (_p: string) => {
throw new Error("EPERM");
},
readFileSync: (_p: string, _enc: string) => {
throw new Error("ENOENT");
},
});
assert.equal(result, false);
});