mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 22:22:57 +03:00
Release/v3.8.0 — full changelog with 660+ commits (#2419)
* fix(cli-tools): guard modelId type before calling indexOf E2E shakedown v3.8.0: cli-tools quebrava com TypeError quando dynamicModels continha entradas sem .id (objeto retornado diretamente em vez de string). * fix(offline): avoid SSR/CSR hydration mismatch on navigator.onLine Replace useState+lazy-initializer with useSyncExternalStore so the server snapshot (() => false) and client snapshot (() => navigator.onLine) are declared separately. React hydrates with the server value and switches to the real online status client-side without a mismatch. * chore(i18n): add missing en.json keys for translator, cli-tools, memory, onboarding Adds 58 missing keys identified by the new dashboard audit script: - cliTools: 18 custom CLI builder keys (CustomCliCard) - translator: 24 keys covering stream transformer, live monitor, test bench - memory: 12 health/pagination/dialog keys - onboarding.tier: 8 keys for the tier tour walkthrough Also adds scripts/i18n/audit-dashboard-pages.mjs which scans all dashboard pages, reports t() calls referencing missing en.json keys, and flags candidate hardcoded JSX/attribute strings. * chore(i18n): replace hardcoded UI text with t() calls across dashboard (round 1) Subagents refactored 8 high-impact dashboard pages, replacing 81 of the 407 hardcoded English/PT strings flagged by the audit with proper useTranslations() lookups. Added 73 corresponding keys to en.json across the home, apiManager, providers, settings, and usage namespaces. Pages affected: - BudgetTab (27 → 0) - HomePageClient (2 → 0) - RoutingTab (25 → 7) - ResilienceTab (38 → 18) - SystemStorageTab (42 → 21) - providers/[id] (17 → 15) - ApiManagerPageClient (14 → 13) - OneproxyTab (13 → 10) Also adds two helper scripts: - scripts/i18n/extract-keys-from-diff.mjs — extracts new keys from git diff - scripts/i18n/merge-keys.mjs — merges a pending-keys JSON into en.json Remaining hardcoded strings will be addressed in follow-up rounds. * chore(i18n): replace hardcoded UI text with t() calls across dashboard (round 2) Continues round 1 (commit8d34f4c65). Round-2 subagents refactored additional dashboard pages, replacing 77 more hardcoded strings with useTranslations() lookups. Added 79 corresponding keys to en.json across the a2aDashboard, agents, analytics, apiManager, cliTools, common, and settings namespaces. Pages affected: - a2a/page (new useTranslations + 6 keys) - agent-skills/page (new useTranslations + 9 keys) - AutoRoutingAnalyticsTab (new useTranslations + 6 keys) - AppearanceTab (8 → 6 remaining) - OneproxyTab (10 → 0) - ResilienceTab (18 → 0 missing key) - RoutingTab (7 → 0 missing key) - VisionBridgeSettingsTab (new useTranslations + 6 keys) - CopilotToolCard (7 → 0 missing key) - ApiManagerPageClient (13 → 0 missing key) - gamification/admin (new useTranslations + 7 keys) Hardcoded total: 326 → 249. Real missing keys: 0 (the 6 still flagged are false positives in exampleTemplates.tsx where t is passed as a parameter — keys exist at translator.templatePayloads.*). * chore(i18n): replace hardcoded UI text with t() calls across dashboard (round 3) Round-3 subagents and manual edits refactored 9 more dashboard pages (plus 2 small extras), replacing ~80 hardcoded strings with useTranslations() lookups. Added 79 corresponding keys to en.json across analytics, cloudAgents, combos, common, health, settings, and usage namespaces. Pages affected: - analytics/ComboHealthTab (new useTranslations + 15 keys) - analytics/CompressionAnalyticsTab (new useTranslations + 11 keys) - settings/SystemStorageTab (21 → 0 missing key) - tokens/page (new useTranslations + 13 keys) - usage/BudgetTab (9 missing fixed) - health/page (manual: 6 keys) - cloud-agents/page (manual: 3 keys) - combos/page (manual: 1 key) Hardcoded total: 249 → 164. Real missing keys: 0 (6 remaining are exampleTemplates.tsx false positives). Also adds scripts/i18n/build-pending-from-missing.mjs which reads _audit.json and locates English values from HEAD to rebuild _pending-keys.json after race-condition resets between subagent edits. * chore(i18n): localize remaining dashboard settings labels Replace hardcoded labels in compression and resilience settings with translation lookups to continue the dashboard i18n cleanup. Add the v3.8.0 dashboard shakedown runbook to document the manual smoke-test process and known dev environment pitfalls. * chore(i18n): replace hardcoded UI text with t() calls across dashboard (round 4) Round-4 subagent + manual key-resolution refactored remaining strings in 3 high-traffic settings/API tabs, plus extracted English values for keys that were already added as t() calls but lost during the previous en.json race-condition resets. Pages affected: - api-manager/ApiManagerPageClient (7 → 0 missing key) - settings/CompressionSettingsTab (8 → 0 missing key) - settings/MemorySkillsTab (8 → 0 missing key) - settings/ResilienceTab (4 more keys recovered) Hardcoded total: 164 → 140. Real missing keys: 0 (6 remaining are the exampleTemplates.tsx false positives — t passed as parameter). * chore(i18n): replace hardcoded UI text with t() calls across dashboard (round 5) Round-5 agent began processing the remaining smaller dashboard files. Added 5 more keys to en.json for providers/[id]/page.tsx OAuth flow labels and the cross-OS auto-detection hint. Pages affected: - providers/[id]/page.tsx (5 keys) Hardcoded total: 140 → 136. Real missing keys: 0. * chore(i18n): resolve last 2 missing providers/[id] keys Adds providerDetailMyClaudeAccountPlaceholder and providerDetailPathAutoDetected — the final user-visible labels in the providers/[id] page that the round-5 subagent rewrote to t() calls without yet adding to en.json. Real missing keys: 0 (6 remaining are exampleTemplates.tsx false positives — t is passed as a parameter so the audit cannot resolve the namespace; keys do exist at translator.templatePayloads.*). * chore(i18n): replace hardcoded UI text with t() calls across dashboard (round 6 — 10 parallel agents) Round-6 dispatched 10 parallel subagents covering all 57 remaining dashboard files. Each agent worked on a disjoint file set to avoid en.json race conditions. Added ~60 new i18n keys across 9 namespaces covering small UI labels, table headers, search placeholders, and empty-state messages. Major changes: - analytics: SearchAnalyticsTab, ProviderUtilizationTab, DiversityScoreCard, CompressionAnalyticsTab (new useTranslations + keys) - batch: BatchDetailModal, BatchListTab, FileDetailModal, FilesListTab (new useTranslations + keys) - settings: CliproxyapiSettingsTab, PayloadRulesTab, ModelCooldownsCard, AppearanceTab, PricingTab (mostly new useTranslations) - endpoint: TokenSaverCard, ApiEndpointsTab, EndpointPageClient - cache: CachePerformance, IdempotencyLayer, ReasoningCacheTab, MediaPageClient, page - combos: IntelligentComboPanel, page - playground: ChatPlayground, SearchPlayground - providers: ProviderCard - onboarding: TierFlowDiagram - changelog: ChangelogViewer - home: ProviderTopology, TierCoverageWidget, BootstrapBanner, BadgeToast - usage: BudgetTab, BudgetTelemetryCards, QuotaTable - quotaShare: QuotaSharePageClient - profile: page - leaderboard: page - skills: page Hardcoded total: 131 → 60. Real missing keys: 0 plus 1 false-positive for combos.modePack (lookup via prop-passed t). * chore(i18n): finalize round-6 keys for batch/cache/endpoint/usage Adds the remaining keys produced by parallel agents A4, A6, A8, A9: - common: batch-related labels (BatchDetailModal, BatchListTab, FileDetailModal, FilesListTab, page) + profile/leaderboard - cache: hit rate, latency, retry, avg chars - endpoint: token saver, API endpoints, copy URL, cloud/local labels - usage: noSpend, activeSessions, quotaAlerts, budget timing - skills: install/marketplace/filter - proxyRegistry/quotaShare/mcpDashboard: misc labels Hardcoded total: 60 → 48. Real missing keys: 0 (modePack remaining is a false positive — combos.modePack exists but the audit can't resolve it since IntelligentComboPanel receives t as a prop). * fix(playground): dedupe filteredModels to avoid duplicate React key warning The /v1/models endpoint can return the same model id twice (e.g., when a model is listed by both an alias and its canonical provider), which made the <Select> emit two <option> elements with the same key — triggering "Encountered two children with the same key, codex/gpt-5.5". Replace the chained filter + map with a single pass that skips ids already added. * fix(playground): guard against non-string model ids before .split/.startsWith The /v1/models endpoint can include synthetic entries (combos, locals, in-progress imports) with a null/undefined id. The playground used to call m.id.split("/") in the provider-discovery loop, which threw on the first non-string entry; the surrounding .catch(() => {}) silently swallowed the error, so the provider/model/account dropdowns ended up empty even though /v1/models returned thousands of valid entries. - Skip entries without a string id before split/startsWith. - Log the rejection in the .catch handler so future regressions are visible in DevTools instead of silently emptying the UI. * fix(playground): guard ChatPlayground filteredModels for non-string ids Same root cause as commit49fe356b9: ChatPlayground filtered models with m.id.startsWith(...) which crashed on null/undefined ids returned by /v1/models (synthetic combo entries). Apply the same defensive guard and dedupe used in the parent page. * fix(claude): drop orphan tool_result after fixToolAdjacency strip (discussion #2410) Discussion #2410 reports Claude returning 400 for sequences like: assistant: tool_use(id=X) user: <plain text> ← breaks adjacency user: tool_result(id=X) The previous round added `fixToolAdjacency` (commit44d9abac9) which correctly strips the orphan tool_use from the assistant message. But that left the now-unmatched tool_result intact, so the upstream rejected the request with: messages.N.content.M: unexpected `tool_use_id` found in `tool_result` blocks: X. Each tool_result block must have a corresponding tool_use block in the previous message. Fix: after running `fixToolAdjacency`, re-run `fixToolPairs` to drop the orphaned tool_result blocks. All three call sites updated: - contextManager.purifyHistory (both inside the binary-search loop and the final pass) - BaseExecutor message-prep (Claude path) - claudeCodeCompatible request signer Also tightens an unrelated dynamic-key access in readNestedString (claudeCodeCompatible) to satisfy the prototype- pollution scanner triggered by the post-tool semgrep hook. * fix(mitm): point runtime manager re-export to js entrypoint Use the emitted `.js` path for the runtime manager re-export so dynamic runtime loading resolves correctly outside the Turbopack alias handling. * docs: add AgentRouter setup guide (#2422) Integrated into release/v3.8.0 — AgentRouter setup guide docs. * feat: add new feature on combos - falloverBeforeRetry (#2417) Integrated into release/v3.8.0 — falloverBeforeRetry for per-model quota skipping in combos. * feat(batch): implement 10 feature requests harvested (#2414) Integrated into release/v3.8.0 — batch of 10 feature requests: llama.cpp local provider, upstream error exposure, Termux detection, providers rotate CLI, t3.chat web skeleton, Zed Docker integration, Kiro multi-account OAuth isolation, auto-combo cost blending, auto-combo context filter, combo provider-level exhaustion tracking (#1731). Conflicts with #2417 (falloverBeforeRetry) resolved. * fix(gamification): resolve SQL bug, auth gap, pagination, and anomaly scoring (#2421) Integrated into release/v3.8.0 — 6 critical gamification bug fixes: SQL SELECT in checkActionCountBadges, federation auth enforcement, leaderboard pagination offset, real z-score computation, addXp level calculation, and barrel index.ts * docs(changelog): add post-release entries for #2414 #2417 #2421 #2422 - feat(batch): T3-Chat-Web executor, exhaustedProviders set (#1731), Zed Docker - feat(combos): falloverBeforeRetry + setTry loop (#2417 — @hartmark) - fix(gamification): SQL SELECT bug, federation auth, pagination, z-score (#2421 — @oyi77) - docs: AgentRouter setup guide (#2422 — @leninejunior) * fix(security): resolve CodeQL random/password-hash alerts and sync docs & tests --------- Co-authored-by: diegosouzapw <diego.souza.pw@gmail.com> Co-authored-by: Lenine Júnior <lenine@engrene.com.br> Co-authored-by: Markus Hartung <mail@hartmark.se> Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
f04c02c221
commit
6248699ce5
@@ -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 1–5.
|
||||
|
||||
---
|
||||
|
||||
## 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 7–10 (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`
|
||||
@@ -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 1–5.
|
||||
|
||||
---
|
||||
|
||||
## 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 7–10 (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`
|
||||
@@ -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 1–5.
|
||||
|
||||
---
|
||||
|
||||
## 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 7–10 (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`
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -153,3 +153,13 @@ http-client.private.env.json
|
||||
|
||||
# Feature-triage ephemeral artifact (regenerated each run)
|
||||
_ideia/_triage.json
|
||||
|
||||
# i18n audit artifact (generated by scripts/i18n/audit-dashboard-pages.mjs)
|
||||
scripts/i18n/_audit.json
|
||||
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
|
||||
|
||||
@@ -4,9 +4,9 @@ rules:
|
||||
- pattern: new Database(...)
|
||||
paths:
|
||||
include:
|
||||
- "bin/**"
|
||||
- "/bin/**"
|
||||
exclude:
|
||||
- "bin/cli/sqlite.mjs"
|
||||
- "/bin/cli/sqlite.mjs"
|
||||
message: >
|
||||
Direct SQLite access in bin/ is banned. Use src/lib/db/* helpers or
|
||||
withRuntime() from bin/cli/runtime.mjs. See CLAUDE.md hard rule #5 and
|
||||
@@ -21,9 +21,9 @@ rules:
|
||||
- pattern: $DB.prepare("UPDATE $TABLE SET ...")
|
||||
paths:
|
||||
include:
|
||||
- "bin/**"
|
||||
- "/bin/**"
|
||||
exclude:
|
||||
- "bin/cli/sqlite.mjs"
|
||||
- "/bin/cli/sqlite.mjs"
|
||||
message: >
|
||||
Raw SQL in bin/ is banned. Use src/lib/db/* helpers. See CLAUDE.md
|
||||
hard rule #5 and bin/cli/CONVENTIONS.md.
|
||||
|
||||
234
CHANGELOG.md
234
CHANGELOG.md
@@ -2,8 +2,199 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
---
|
||||
|
||||
## [3.8.0] — 2026-05-06
|
||||
|
||||
### 🚀 Post-release hotfixes e contribuições (2026-05-06 → 2026-05-20)
|
||||
|
||||
#### 2026-05-20
|
||||
|
||||
- **feat(batch):** implement 10 feature requests harvested from issues — T3 Chat Web executor (cookie-based), per-request exhausted-provider tracking (#1731) to skip quota-drained providers mid-combo, Zed Docker detection, API key rotator health dashboard, Kiro multi-account isolation, context-window model filtering, cost blending in combos, combo config tests, provider validation branches, and postinstall support scripts. ([#2414](https://github.com/diegosouzapw/OmniRoute/pull/2414))
|
||||
- **feat(combos):** add `falloverBeforeRetry` strategy — combo routing now falls over to the next target before retrying the same model, eliminating the tail-latency spike from exhausting all per-model retries on a failing endpoint. Also wraps the retry loop in a `setTry` outer loop for per-target retry coordination. ([#2417](https://github.com/diegosouzapw/OmniRoute/pull/2417) — thanks @hartmark)
|
||||
- **fix(gamification):** resolve 6 implementation gaps — missing `SELECT` in `checkActionCountBadges` SQL (was silently skipping 8 badges), federation leaderboard auth enforcement, pagination `offset` parameter no longer silently discarded, admin anomaly view now computes real z-scores, `addXp` correctly calculates initial level from XP amount, and barrel `index.ts` for clean module exports. 72-test suite covering all fixes. ([#2421](https://github.com/diegosouzapw/OmniRoute/pull/2421) — thanks @oyi77)
|
||||
- **docs:** add AgentRouter provider setup guide — step-by-step instructions for connecting OmniRoute to AgentRouter.org's Claude-compatible relay endpoint, covering API key configuration and wire-image headers. ([#2422](https://github.com/diegosouzapw/OmniRoute/pull/2422) — thanks @leninejunior)
|
||||
- **fix(claude):** drop orphan `tool_result` blocks left behind when `fixToolAdjacency` strips a dangling `tool_use` — resolves HTTP 400 "unexpected tool_use_id in tool_result blocks" from the Anthropic API on truncated histories. `fixToolPairs` now re-runs after every `fixToolAdjacency` pass across all three call sites (`contextManager.ts`, `base.ts`, `claudeCodeCompatible.ts`). (discussion [#2410](https://github.com/diegosouzapw/OmniRoute/discussions/2410))
|
||||
- **fix(playground):** guard against `null`/non-string model IDs in Playground dropdowns — `typeof m?.id !== "string"` check prevents a silent crash in the provider discovery loop and `filteredModels` computation that was leaving all Playground dropdowns empty when `/v1/models` returned entries with `id: null`; adds deduplication via `Set` to eliminate duplicate React key warnings.
|
||||
- **fix(mitm):** point MITM runtime manager re-export to the compiled `.js` entrypoint — fixes module resolution after build when the `.ts` source is no longer present.
|
||||
|
||||
#### 2026-05-19
|
||||
|
||||
- **chore(i18n):** comprehensive dashboard i18n coverage — 6 rounds of parallel refactoring replacing hardcoded English/Portuguese text with `t()` calls across 57+ dashboard pages; 420+ new keys added to `en.json` covering `settings`, `playground`, `analytics`, `apiManager`, `providers`, `skills`, `memory`, `agents`, and 15 other namespaces (coverage: ~88%, up from ~20%).
|
||||
- **fix(offline):** avoid SSR/CSR hydration mismatch on the offline status page — switches from a `useState` lazy initializer (which accessed `navigator.onLine` on the server) to `useSyncExternalStore` with a distinct `false` server snapshot, eliminating the React hydration warning.
|
||||
- **fix(cli-tools):** guard `modelId` type before calling `.indexOf()` — prevents a `TypeError` when a model entry without a string `id` reaches the comparison logic in CLI Tools.
|
||||
- **fix(providers):** add missing `isLocalProvider` import and update changelog.
|
||||
- **fix(resilience):** add API Key health tracking with automatic rotation and UI toast alerts. ([#2412](https://github.com/diegosouzapw/OmniRoute/pull/2412) — thanks @clousky2020)
|
||||
- **feat(providers):** support Gemini API keys for Gemini CLI executor. ([#2408](https://github.com/diegosouzapw/OmniRoute/pull/2408) — thanks @benzntech)
|
||||
- **feat(gamification):** implement Gamification & Leaderboard System with non-blocking event-driven updates. ([#2405](https://github.com/diegosouzapw/OmniRoute/pull/2405) — thanks @oyi77)
|
||||
- **fix(providers):** Kilo Code provider no longer blocks on a missing local `kilocode` CLI binary — the provider uses OAuth device flow + direct HTTPS to `api.kilo.ai` and never required the CLI at runtime; the connection test was hard-failing with "Local CLI runtime is not installed" even when the OAuth token was valid. CLI Tools integration (`/api/cli-tools/kilo-settings`) keeps its own runtime check. ([#2404](https://github.com/diegosouzapw/OmniRoute/issues/2404) — thanks @Flexible78)
|
||||
- **fix(db):** `bun add -g omniroute` (and other runtimes that skip postinstall) no longer surfaces a generic 500 — `isNativeSqliteLoadError` now also detects "Could not locate the bindings file" / `MODULE_NOT_FOUND`, so the user gets the friendly rebuild guide instead. ([#2358](https://github.com/diegosouzapw/OmniRoute/issues/2358) — thanks @yamansin)
|
||||
- **fix(kiro):** enable Google OAuth login option in the Kiro auth modal — surfaces the Google SSO button alongside the existing identity providers. ([#2392](https://github.com/diegosouzapw/OmniRoute/pull/2392) — thanks @congvc-dev)
|
||||
- **fix(security):** drop hashing layer in `sessionPoolKey` after switching to a non-cryptographic key derivation strategy that clears CodeQL alert #247. ([#2396](https://github.com/diegosouzapw/OmniRoute/pull/2396))
|
||||
- **feat(providers):** Gemini Web cookie-based provider — proxies google.com chat through a session cookie, allowing free Gemini access without API keys. ([#2380](https://github.com/diegosouzapw/OmniRoute/pull/2380) — thanks @oyi77)
|
||||
- **model:** add Composer 2.5 to the Cursor provider catalog. ([#2381](https://github.com/diegosouzapw/OmniRoute/pull/2381) — thanks @backryun)
|
||||
- **fix:** `tool_use` without adjacent `tool_result` causes Claude 400 — adjacency guard now also applies inside `compressContext`. ([#2383](https://github.com/diegosouzapw/OmniRoute/pull/2383) — thanks @oyi77)
|
||||
- **build(deps):** bump `electron` from 42.0.1 to 42.1.0 in `/electron`. ([#2397](https://github.com/diegosouzapw/OmniRoute/pull/2397))
|
||||
- **build(deps):** production group bumps — 4 updates. ([#2398](https://github.com/diegosouzapw/OmniRoute/pull/2398))
|
||||
- **build(deps):** development group bumps — 4 updates. ([#2399](https://github.com/diegosouzapw/OmniRoute/pull/2399))
|
||||
- **chore:** sync `release/v3.8.0` with `main` (CodeQL hotfixes + Dependabot bumps) via merge commit.
|
||||
|
||||
#### 2026-05-18
|
||||
|
||||
- **fix(security):** resolve CodeQL alerts #243/#244/#245 — incomplete URL substring sanitization and weak crypto signal hardening. ([#2391](https://github.com/diegosouzapw/OmniRoute/pull/2391))
|
||||
- **fix(security):** switch `sessionPoolKey` derivation to HMAC-SHA256 to clear CodeQL alert #246 (insecure hash for sensitive data). ([#2394](https://github.com/diegosouzapw/OmniRoute/pull/2394))
|
||||
- **docs(readme):** restore the 9router acknowledgment that was inadvertently dropped during the v3.8.0 README rework. ([#2393](https://github.com/diegosouzapw/OmniRoute/pull/2393))
|
||||
- **refactor(dashboard):** comprehensive nav, providers, endpoint, runtime, quota, pricing, budget redesign + quota sharing preview (sidebar restructure → 12 collapsible sections, 22 new routes). ([#2384](https://github.com/diegosouzapw/OmniRoute/pull/2384))
|
||||
- **fix(dashboard):** PR #2384 follow-up review fixes — Runtime quota i18n, Budget projection logic, QuotaShare i18n externalization, Provider Limits semantic markup, bulk endpoint usage. ([#2389](https://github.com/diegosouzapw/OmniRoute/pull/2389))
|
||||
- **feat(content):** add Haiper, Leonardo, Ideogram, Suno, and Udio as content/media providers. ([#2377](https://github.com/diegosouzapw/OmniRoute/pull/2377) — thanks @oyi77)
|
||||
- **feat(@omniroute/opencode-provider):** expand config helpers, add MCP entry, live model fetch, and combo builder. ([#2375](https://github.com/diegosouzapw/OmniRoute/pull/2375) — thanks @mrmm)
|
||||
- **fix(claude-oauth):** enable system-transforms pipeline for the native Claude executor (closes 400 billing-gate). ([#2370](https://github.com/diegosouzapw/OmniRoute/pull/2370) — thanks @thepigdestroyer)
|
||||
- **feat(content):** extend providers with video, audio, TTS, music capabilities — Pollinations, MiniMax, Together, Replicate across Audio TTS and Transcription registries. ([#2369](https://github.com/diegosouzapw/OmniRoute/pull/2369) — thanks @oyi77)
|
||||
- **feat(providers):** add Veo AI Free as a web-wrapper provider for generating video, image, and TTS without an API key. ([#2366](https://github.com/diegosouzapw/OmniRoute/pull/2366) — thanks @oyi77)
|
||||
- **feat(providers):** add Replicate as a free provider for OpenAI-compatible inference with community models. ([#2364](https://github.com/diegosouzapw/OmniRoute/pull/2364) — thanks @oyi77)
|
||||
- **fix(claude):** avoided redundant deep cloning of Claude Code messages during semantic passthrough preparation, improving memory/CPU efficiency for large histories. ([#2362](https://github.com/diegosouzapw/OmniRoute/pull/2362) — thanks @terence71-glitch)
|
||||
- **fix(providers):** register `llm7` in the executor registry and route Cohere via OpenAI-compatible layer. ([#2361](https://github.com/diegosouzapw/OmniRoute/pull/2361), [#2360](https://github.com/diegosouzapw/OmniRoute/pull/2360))
|
||||
- **fix(rate-limiter):** Redis is now opt-in — when `REDIS_URL` is unset, the rate limiter falls back to the in-memory store instead of spamming `ECONNREFUSED`. ([#2357](https://github.com/diegosouzapw/OmniRoute/pull/2357))
|
||||
- **fix(streaming):** emit protocol-aware stream errors — `createDisconnectAwareStream()` now emits native Responses API or Claude API SSE error blocks based on the client protocol. ([#2355](https://github.com/diegosouzapw/OmniRoute/pull/2355) — thanks @dhaern)
|
||||
- **fix(combos):** allow bracketed combo names (e.g. `Claude [1m]`) by updating validation schemas. ([#2354](https://github.com/diegosouzapw/OmniRoute/pull/2354) — thanks @congvc-dev)
|
||||
- **fix(claude-code):** semantic passthrough — preserve Claude Code `messages[]` structure for native Claude OAuth and relay routes. ([#2351](https://github.com/diegosouzapw/OmniRoute/pull/2351) — thanks @terence71-glitch)
|
||||
- **fix(usage):** extract flat `cached_tokens` and `reasoning_tokens` from OpenAI-compatible usage objects. ([#2350](https://github.com/diegosouzapw/OmniRoute/pull/2350) — thanks @TF0rd)
|
||||
- **fix(translator):** DeepSeek tool-call response lookup reads cached reasoning before falling back to empty string. ([#2349](https://github.com/diegosouzapw/OmniRoute/pull/2349) — thanks @herjarsa)
|
||||
- **fix(ui/tooltip):** render in portal + clamp to viewport so tooltips aren't clipped in modal dialogs. ([#2352](https://github.com/diegosouzapw/OmniRoute/pull/2352) — thanks @slider23)
|
||||
- **fix(auto-routing):** replace bare `getSettings()` with `getCachedSettings` to stop 500 on `auto/*` requests. ([#2346](https://github.com/diegosouzapw/OmniRoute/pull/2346))
|
||||
- **fix(docker):** ship Dashboard Docs markdown in the container image. ([#2348](https://github.com/diegosouzapw/OmniRoute/pull/2348))
|
||||
- **fix(combo/validator):** treat upstream responses carrying a non-empty `reasoning_content` as valid output. ([#2341](https://github.com/diegosouzapw/OmniRoute/pull/2341))
|
||||
- **fix(account-fallback):** classify Anthropic `Usage Limit Reached` as `QUOTA_EXHAUSTED` with a 1h cooldown. ([#2321](https://github.com/diegosouzapw/OmniRoute/pull/2321))
|
||||
- **feat(providers):** add GitHub Models as a free provider — GPT-5, o-series, DeepSeek-R1, Llama 4, Grok 3. ([#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. ([#2339](https://github.com/diegosouzapw/OmniRoute/pull/2339) — thanks @oyi77)
|
||||
- **feat(providers):** add Microsoft Copilot Web executor — WebSocket-based provider. ([#2340](https://github.com/diegosouzapw/OmniRoute/pull/2340) — thanks @oyi77)
|
||||
- **feat(routing):** LKGP stores last known good account `connectionId` alongside provider. ([#2338](https://github.com/diegosouzapw/OmniRoute/pull/2338) — thanks @oyi77)
|
||||
- **feat(dashboard):** add Claude Code auth import/export UI + i18n (three-PR series: libs, API routes, dashboard UI).
|
||||
- **feat(dashboard):** add Gemini CLI auth import/export UI + i18n (three-PR series: libs, API routes, dashboard UI).
|
||||
- **fix(routing):** implement embedding combos, local provider validation bypass, and resolve migration collisions.
|
||||
- **fix(build):** import Monaco ESM API to fix webpack `nls.messages-loader` error.
|
||||
- **fix(ui):** v3.8.0 polish — connections border, sticky tabs, EN translations, save toasts, auto-combo catalog. ([#2305](https://github.com/diegosouzapw/OmniRoute/pull/2305) — thanks @mrmm)
|
||||
- **fix(auth+build):** Bearer manage scope on management routes + lazy-load deepseek PoW solver. ([#2308](https://github.com/diegosouzapw/OmniRoute/pull/2308) — thanks @mrmm)
|
||||
- **fix(claude):** guard orphan tool_use/tool_result pairs before upstream send. ([#2312](https://github.com/diegosouzapw/OmniRoute/pull/2312) — thanks @mrmm)
|
||||
- **fix(ui):** remove count from batch removal button. ([#2309](https://github.com/diegosouzapw/OmniRoute/pull/2309) — thanks @hartmark)
|
||||
- **fix:** remove implicit API key request caps — removes default 1K/5K/20K rate caps. ([#2289](https://github.com/diegosouzapw/OmniRoute/pull/2289) — thanks @josephvoxone)
|
||||
- **fix(sse):** strip stale `Content-Encoding`, `Content-Length`, and `Transfer-Encoding` headers on non-streaming forward. ([#2264](https://github.com/diegosouzapw/OmniRoute/pull/2264) — thanks @gleber)
|
||||
- **chore(providers):** refresh provider model metadata and ordering. ([#2318](https://github.com/diegosouzapw/OmniRoute/pull/2318) — thanks @backryun)
|
||||
- **chore(providers):** consolidate Alibaba provider entries. ([#2319](https://github.com/diegosouzapw/OmniRoute/pull/2319) — thanks @backryun)
|
||||
- **fix(streaming):** harden stream readiness detection. ([#2317](https://github.com/diegosouzapw/OmniRoute/pull/2317) — thanks @dhaern)
|
||||
- **fix(v1/messages):** default to non-streaming when `stream` field is absent for Anthropic format. ([#2326](https://github.com/diegosouzapw/OmniRoute/pull/2326) — thanks @thepigdestroyer)
|
||||
- **fix(claude):** `fitThinkingToMaxTokens` caps thinking budget to model's output ceiling. ([#2327](https://github.com/diegosouzapw/OmniRoute/pull/2327) — thanks @thepigdestroyer)
|
||||
- **fix(codex):** Codex reasoning priority resolves `modelEffort` before `explicitReasoning`. ([#2335](https://github.com/diegosouzapw/OmniRoute/pull/2335) — thanks @terence71-glitch)
|
||||
- **fix(providers):** providers page no longer deadlocks when no providers are configured. ([#2329](https://github.com/diegosouzapw/OmniRoute/pull/2329) — thanks @slider23)
|
||||
- **chore(providers):** update HuggingFace to use the new `/v1/` router endpoint. ([#2322](https://github.com/diegosouzapw/OmniRoute/pull/2322) — thanks @backryun)
|
||||
- **fix(security):** resolve CodeQL ReDoS + URL sanitization alerts.
|
||||
- **fix(auth):** stop retrying unrecoverable token refresh failures and include connection id in token health check credentials.
|
||||
- **fix(auth):** return synthetic credentials for noAuth free providers and show no-auth card in dashboard instead of OAuth modal.
|
||||
- **fix(endpoint):** replace nested `<button>` with `<div role=button>` in tunnel toggle rows to fix hydration warnings.
|
||||
- **fix(migrations):** resolve version collision at migration slot 056 and add batch deletion API. ([#2294](https://github.com/diegosouzapw/OmniRoute/pull/2294) — thanks @hartmark)
|
||||
- **feat(batch):** global rate-limit header cache with 60s TTL + 24h retry window. ([#2299](https://github.com/diegosouzapw/OmniRoute/pull/2299) — thanks @hartmark)
|
||||
- **feat(cc-bridge):** config-driven per-provider system-block transform DSL. ([#2286](https://github.com/diegosouzapw/OmniRoute/pull/2286), closes #2260 — thanks @mrmm)
|
||||
- **feat(deepseek-web):** full DeepSeek web API executor with Keccak PoW solver. ([#2295](https://github.com/diegosouzapw/OmniRoute/pull/2295) — thanks @oyi77)
|
||||
- **feat(i18n):** add Azerbaijani (az / 🇦🇿) language support — new locale in `config/i18n.json`, 42 total supported languages.
|
||||
- **build(deps):** bump `actions/checkout` from 4 to 6 in CI workflows. ([#2288](https://github.com/diegosouzapw/OmniRoute/pull/2288))
|
||||
|
||||
#### 2026-05-17
|
||||
|
||||
- **fix(codex):** bulk import Codex `auth.json` — multi-file upload, paste-from-clipboard, and ZIP archive support. ([#2343](https://github.com/diegosouzapw/OmniRoute/pull/2343))
|
||||
- **feat(codex):** import single Codex `auth.json` as an OAuth connection (one-click migration from Codex Desktop). ([#2336](https://github.com/diegosouzapw/OmniRoute/pull/2336))
|
||||
- **feat(codex-auth):** rename `export` action + gate "Apply Local" behind a confirmation modal to prevent accidental local config overwrite. ([#2332](https://github.com/diegosouzapw/OmniRoute/pull/2332))
|
||||
- **fix(providers):** providers page empty-state — missing i18n keys and "Add Provider" CTA so first-time users can add a provider. ([#2333](https://github.com/diegosouzapw/OmniRoute/pull/2333), [#2337](https://github.com/diegosouzapw/OmniRoute/pull/2337))
|
||||
- **fix(providers):** Fix Providers empty state blocking first provider setup. (thanks @slider23)
|
||||
- **feat(providers):** bulk add API keys with Single/Bulk tabs.
|
||||
- **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 @oyi77)
|
||||
- **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(i18n):** add simple/advanced mode keys and missing provider filter keys (`allProviders`, `audioProviders`, `showFreeOnly`).
|
||||
|
||||
#### 2026-05-16
|
||||
|
||||
- **feat(deepseek-web):** full DeepSeek web API executor with PoW solver — also landed via PR #2295. (thanks @oyi77)
|
||||
- **feat(batch):** global rate-limit header cache with 60s TTL — also via #2299.
|
||||
- **feat(cc-bridge):** config-driven per-provider system-block transform DSL — also via #2286.
|
||||
- **feat(dashboard):** provider summary card, free test button, sidebar order, i18n fix.
|
||||
- **feat(dashboard):** A2A audit page, stats bar on MCP audit, sidebar deduplication.
|
||||
- **feat(skills):** add 5 CLI skill manifests + AgentSkills / OmniSkills dashboard pages. ([#2284](https://github.com/diegosouzapw/OmniRoute/pull/2284))
|
||||
- **fix(translator):** map `developer` → `system` by default for non-OpenAI-family providers. ([#2281](https://github.com/diegosouzapw/OmniRoute/pull/2281))
|
||||
- **fix(api/combos):** add API-key-safe `GET /v1/combos` endpoint. ([#2300](https://github.com/diegosouzapw/OmniRoute/pull/2300))
|
||||
- **fix(embeddings/registry):** add DeepInfra to the embedding provider registry. ([#2298](https://github.com/diegosouzapw/OmniRoute/pull/2298))
|
||||
- **fix(opencode-zen):** flag `qwen3.6-plus` and `qwen3.6-plus-free` with `targetFormat: "claude"`. ([#2292](https://github.com/diegosouzapw/OmniRoute/pull/2292))
|
||||
- **fix(settings):** default `debugMode` to `true` on fresh installations.
|
||||
- **fix(sse):** remove dead-code flag leak in `claudeCodeToolRemapper`. ([#2290](https://github.com/diegosouzapw/OmniRoute/pull/2290) — thanks @thepigdestroyer)
|
||||
- **fix(sse):** strip stale `Content-Encoding`, `Content-Length`, `Transfer-Encoding` from upstream responses. ([#2291](https://github.com/diegosouzapw/OmniRoute/pull/2291) — thanks @thepigdestroyer)
|
||||
- **fix(migrations):** resolve version collisions and add schema repair for quota thresholds.
|
||||
|
||||
#### 2026-05-15
|
||||
|
||||
- **feat(cli):** CLI v4 — Commander.js architecture, 50+ commands, interactive TUI, full i18n (42 locales), plugin system (Fases 0–9). ([#2280](https://github.com/diegosouzapw/OmniRoute/pull/2280))
|
||||
- **feat(skills):** publish 3 operational SKILL.md manifests + AI Skills dashboard entry. ([#2276](https://github.com/diegosouzapw/OmniRoute/pull/2276))
|
||||
- **feat(termux):** Android/Termux headless support — auto-detect Android platform for headless mode. ([#2273](https://github.com/diegosouzapw/OmniRoute/pull/2273) — thanks @t-way666)
|
||||
- **feat(limits):** per-window quota cutoffs across all providers with usage data. ([#2267](https://github.com/diegosouzapw/OmniRoute/pull/2267) — thanks @payne0420)
|
||||
- **feat(api-keys):** configurable default rate limits via `DEFAULT_RATE_LIMIT_PER_DAY` env var. ([#2266](https://github.com/diegosouzapw/OmniRoute/pull/2266) — thanks @gleber)
|
||||
- **feat(authz):** `managementPolicy` accepts API keys with `manage` scope. ([#2265](https://github.com/diegosouzapw/OmniRoute/pull/2265) — thanks @gleber)
|
||||
- **feat(mcp):** MCP accessibility-tree smart filter engine — collapses ≥30 repeated sibling lines, 60-80% token savings.
|
||||
- **feat(auth):** CLI machine-ID HMAC-SHA256 token for zero-friction local auth without JWT/password.
|
||||
- **feat(security):** route protection tiers — 5 tiers: public/read-only/protected/always/local-only.
|
||||
- **feat(compression):** Caveman `SHARED_BOUNDARIES` — all 6 languages × 3 intensities embed boundary clause.
|
||||
- **feat(runtime):** dynamic SQLite 5-step fallback chain — bundled → runtime-installed → lazy-install → node:sqlite → sql.js.
|
||||
- **feat(cli):** standalone system tray with PowerShell fallback on Windows (`omniroute --tray`).
|
||||
- **fix(providers/command-code):** send required `skills` and `stream` payload fields. ([#2271](https://github.com/diegosouzapw/OmniRoute/pull/2271) — thanks @ddarkr)
|
||||
- **chore:** ignore `.playwright-mcp/` generated artifacts. ([#2269](https://github.com/diegosouzapw/OmniRoute/pull/2269) — thanks @backryun)
|
||||
- **chore:** tidy up deprecated models from Windsurf provider registry. ([#2279](https://github.com/diegosouzapw/OmniRoute/pull/2279) — thanks @backryun)
|
||||
- **chore(deps):** node dependency updates. ([#2259](https://github.com/diegosouzapw/OmniRoute/pull/2259) — thanks @backryun)
|
||||
- **build(deps):** bump `mermaid` from 11.14.0 to 11.15.0. ([#2178](https://github.com/diegosouzapw/OmniRoute/pull/2178))
|
||||
|
||||
#### 2026-05-08 a 2026-05-14
|
||||
|
||||
- **feat(guardrails/vision-bridge):** add `VISION_BRIDGE_BASE_URL` + `VISION_BRIDGE_API_KEY` env overrides for non-Anthropic vision-bridge routing. ([#2232](https://github.com/diegosouzapw/OmniRoute/pull/2232))
|
||||
- **feat(claude-web):** implement session-based Claude Web executor with auto-refresh authentication. ([#2283](https://github.com/diegosouzapw/OmniRoute/pull/2283) — thanks @oyi77)
|
||||
- **refactor(@omniroute/opencode-provider):** complete rewrite of the npm helper — tsup build (CJS + ESM + `.d.ts`), schema-correct output, `baseURL` deduplication, input validation, 13 unit tests. Versioned as `0.1.0`.
|
||||
- **BREAKING:** dropped Node 20.x support. Minimum Node version is now 22.22.2 (or 24.0.0+).
|
||||
- **fix(auth):** accept `x-api-key` header in `extractApiKey` so Anthropic-native clients hit the same per-key policy enforcement. ([#2225](https://github.com/diegosouzapw/OmniRoute/pull/2225))
|
||||
- **fix(translator/claude-to-openai):** stop including `cache_creation_input_tokens` in `prompt_tokens`. ([#2215](https://github.com/diegosouzapw/OmniRoute/pull/2215))
|
||||
- **fix(kiro):** harden OpenAI-to-Kiro translator for API compliance. ([#2251](https://github.com/diegosouzapw/OmniRoute/pull/2251) — thanks @8mbe)
|
||||
- **fix(models):** sync managed model aliases with provider model visibility. ([#2250](https://github.com/diegosouzapw/OmniRoute/pull/2250) — thanks @InkshadeWoods)
|
||||
- **fix(models/cleanup):** align managed model cleanup for imported models. ([#2261](https://github.com/diegosouzapw/OmniRoute/pull/2261) — thanks @InkshadeWoods)
|
||||
- **fix(executor/claude-code):** store tool-name round-trip metadata in non-enumerable `_toolNameMap`. ([#2254](https://github.com/diegosouzapw/OmniRoute/pull/2254) — thanks @Rikonorus)
|
||||
- **fix(streaming):** strip upstream `Content-Encoding`, `Content-Length`, `Transfer-Encoding` headers from SSE responses. ([#2253](https://github.com/diegosouzapw/OmniRoute/pull/2253) — thanks @Rikonorus)
|
||||
- **fix(security):** remediate CodeQL vulnerabilities (ReDoS, cryptographic bias, stack trace exposure, weak password hashing). ([#216](https://github.com/diegosouzapw/OmniRoute/issues/216), [#215](https://github.com/diegosouzapw/OmniRoute/issues/215), [#211](https://github.com/diegosouzapw/OmniRoute/issues/211), [#208](https://github.com/diegosouzapw/OmniRoute/issues/208), [#206](https://github.com/diegosouzapw/OmniRoute/issues/206), [#210](https://github.com/diegosouzapw/OmniRoute/issues/210))
|
||||
- **fix(providers/blackbox-web):** add `BLACKBOX_WEB_VALIDATED_TOKEN` env override and 403 token-error disambiguation. ([#2252](https://github.com/diegosouzapw/OmniRoute/pull/2252))
|
||||
- **fix(auth):** `REQUIRE_API_KEY=false` invalid Bearer no longer 401s the whole request. ([#2257](https://github.com/diegosouzapw/OmniRoute/pull/2257))
|
||||
- **feat(resilience):** add model cooldowns dashboard card with real-time list, individual/bulk re-enable, and auto-refresh.
|
||||
- **feat(resilience):** `useUpstream429BreakerHints` toggle. ([#2133](https://github.com/diegosouzapw/OmniRoute/pull/2133) — thanks @eleata)
|
||||
- **feat(auto):** zero-config auto-routing with `auto/` prefix — dynamic virtual combo from connected providers with 6 variant profiles. ([#2131](https://github.com/diegosouzapw/OmniRoute/pull/2131) — thanks @oyi77)
|
||||
- **feat(kiro):** headless auth via kiro-cli SQLite, image support, tool overflow handling, model list sync. ([#2129](https://github.com/diegosouzapw/OmniRoute/pull/2129) — thanks @christlau)
|
||||
- **feat(cursor):** surface Cursor Pro plan usage on provider-limits dashboard. ([#2128](https://github.com/diegosouzapw/OmniRoute/pull/2128) — thanks @payne0420)
|
||||
- **feat(mitm):** dynamic Linux certificate path detection for multi-distro MITM cert trust. ([#2134](https://github.com/diegosouzapw/OmniRoute/pull/2134) — thanks @flyingmongoose)
|
||||
- **feat(1proxy):** add dedicated settings tab with proxy rotation support. ([#2135](https://github.com/diegosouzapw/OmniRoute/pull/2135) — thanks @oyi77)
|
||||
- **feat(responses):** degrade `background: true` to synchronous execution with a warning. ([#2164](https://github.com/diegosouzapw/OmniRoute/pull/2164) — thanks @Yosee11)
|
||||
- **feat(api):** aggregate combo model metadata in catalog endpoint. ([#2166](https://github.com/diegosouzapw/OmniRoute/pull/2166) — thanks @faisalill)
|
||||
- **feat(oauth):** complete Windsurf and Devin CLI OAuth + API-token flows. ([#2168](https://github.com/diegosouzapw/OmniRoute/pull/2168) — thanks @Zhaba1337228)
|
||||
- **feat(antigravity):** support custom Google Cloud project ID. ([#2227](https://github.com/diegosouzapw/OmniRoute/pull/2227) — thanks @nickwizard)
|
||||
- **feat(cli):** CLI Integration Suite — 5 new management commands, 3 API endpoints, config generators for 6 tools. ([#2240](https://github.com/diegosouzapw/OmniRoute/pull/2240) — thanks @oyi77)
|
||||
- **fix(sanitizer):** preserve `reasoning_content` on assistant messages with `tool_calls`. ([#2140](https://github.com/diegosouzapw/OmniRoute/pull/2140) — thanks @DavyMassoneto)
|
||||
- **fix(catalog):** ensure individual models expose `context_length` via `getTokenLimit()` fallback chain. ([#2136](https://github.com/diegosouzapw/OmniRoute/pull/2136) — thanks @herjarsa)
|
||||
- **fix(docker):** remove docs directory from `.dockerignore`. ([#2137](https://github.com/diegosouzapw/OmniRoute/pull/2137), [#2120](https://github.com/diegosouzapw/OmniRoute/pull/2120) — thanks @hartmark)
|
||||
- **fix(providers):** restore cloud agent provider exports and logger import. ([#2138](https://github.com/diegosouzapw/OmniRoute/pull/2138) — thanks @backryun)
|
||||
- **fix(providers):** remove duplicate `CLOUD_AGENT_PROVIDERS` declaration. ([#2141](https://github.com/diegosouzapw/OmniRoute/pull/2141) — thanks @backryun)
|
||||
- **fix(translator):** preserve `body.system` in openai→claude when Claude Code sends native format. ([#2130](https://github.com/diegosouzapw/OmniRoute/pull/2130))
|
||||
- **fix(authz):** classify `/dashboard/onboarding` as PUBLIC to unblock setup wizard. ([#2127](https://github.com/diegosouzapw/OmniRoute/pull/2127))
|
||||
- **fix(i18n):** complete Simplified Chinese translations. ([#2115](https://github.com/diegosouzapw/OmniRoute/pull/2115) — thanks @boa-z)
|
||||
- **fix(sse):** classify hour quota errors as QUOTA_EXHAUSTED. ([#2119](https://github.com/diegosouzapw/OmniRoute/pull/2119) — thanks @clousky2020)
|
||||
- **fix(sse):** fix CC-compatible streaming bridge. ([#2118](https://github.com/diegosouzapw/OmniRoute/pull/2118) — thanks @rdself)
|
||||
- **fix(cliproxyapi):** detect Anthropic-shaped request bodies and route to `/v1/messages`. ([#2165](https://github.com/diegosouzapw/OmniRoute/pull/2165) — thanks @Brkic-Nikola)
|
||||
- **fix(claudeHelper):** preserve latest assistant thinking blocks verbatim. ([#2224](https://github.com/diegosouzapw/OmniRoute/pull/2224) — thanks @NomenAK)
|
||||
- **fix(deepseek):** preserve `reasoning_content` through full pipeline for DeepSeek V4 models. ([#2231](https://github.com/diegosouzapw/OmniRoute/pull/2231) — thanks @kang-heewon)
|
||||
- **fix(chatcore):** stop leaking provider credentials in response headers.
|
||||
- **fix(export):** exclude telemetry/usage-history tables from JSON config backups by default. ([#2125](https://github.com/diegosouzapw/OmniRoute/pull/2125))
|
||||
- **build(deps):** regenerate `package-lock.json` to match `http-proxy-middleware` 4.x bump. ([#2228](https://github.com/diegosouzapw/OmniRoute/pull/2228) — thanks @NomenAK)
|
||||
|
||||
#### 2026-05-06 a 2026-05-07 (lançamento inicial v3.8.0)
|
||||
|
||||
- **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 +211,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)
|
||||
@@ -113,38 +305,6 @@
|
||||
- **chore:** narrow `.claude/` gitignore to runtime files only and untrack `scheduled_tasks.lock`.
|
||||
- **Docs:** 270 broken internal markdown links repaired.
|
||||
|
||||
### Post-release hotfixes (2026-05-18 → 2026-05-19)
|
||||
|
||||
- **fix(kiro):** enable Google OAuth login option in the Kiro auth modal — surfaces the Google SSO button alongside the existing identity providers. ([#2392](https://github.com/diegosouzapw/OmniRoute/pull/2392) — thanks @congvc-dev)
|
||||
- **fix(security):** resolve CodeQL alerts #243/#244/#245 — incomplete URL substring sanitization and weak crypto signal hardening. ([#2391](https://github.com/diegosouzapw/OmniRoute/pull/2391))
|
||||
- **fix(security):** switch `sessionPoolKey` derivation to HMAC-SHA256 to clear CodeQL alert #246 (insecure hash for sensitive data). ([#2394](https://github.com/diegosouzapw/OmniRoute/pull/2394))
|
||||
- **fix(security):** drop hashing layer in `sessionPoolKey` after switching to a non-cryptographic key derivation strategy that clears CodeQL alert #247. ([#2396](https://github.com/diegosouzapw/OmniRoute/pull/2396))
|
||||
- **docs(readme):** restore the 9router acknowledgment that was inadvertently dropped during the v3.8.0 README rework. ([#2393](https://github.com/diegosouzapw/OmniRoute/pull/2393))
|
||||
- **refactor(dashboard):** comprehensive nav, providers, endpoint, runtime, quota, pricing, budget redesign + quota sharing preview (sidebar restructure → 12 collapsible sections, 22 new routes). ([#2384](https://github.com/diegosouzapw/OmniRoute/pull/2384))
|
||||
- **fix(dashboard):** PR #2384 follow-up review fixes — Runtime quota i18n, Budget projection logic, QuotaShare i18n externalization, Provider Limits semantic markup, bulk endpoint usage. ([#2389](https://github.com/diegosouzapw/OmniRoute/pull/2389))
|
||||
- **fix:** `tool_use` without adjacent `tool_result` causes Claude 400 — adjacency guard now also applies inside `compressContext`. ([#2383](https://github.com/diegosouzapw/OmniRoute/pull/2383) — thanks @oyi77)
|
||||
- **model:** add Composer 2.5 to the Cursor provider catalog. ([#2381](https://github.com/diegosouzapw/OmniRoute/pull/2381) — thanks @backryun)
|
||||
- **feat(providers):** Gemini Web cookie-based provider — proxies google.com chat through a session cookie, allowing free Gemini access without API keys. ([#2380](https://github.com/diegosouzapw/OmniRoute/pull/2380) — thanks @oyi77)
|
||||
- **feat(content):** add Haiper, Leonardo, Ideogram, Suno, and Udio as content/media providers. ([#2377](https://github.com/diegosouzapw/OmniRoute/pull/2377) — thanks @oyi77)
|
||||
- **feat(@omniroute/opencode-provider):** expand config helpers, add MCP entry, live model fetch, and combo builder. ([#2375](https://github.com/diegosouzapw/OmniRoute/pull/2375) — thanks @mrmm)
|
||||
- **fix(claude-oauth):** enable system-transforms pipeline for the native Claude executor (closes 400 billing-gate). ([#2370](https://github.com/diegosouzapw/OmniRoute/pull/2370) — thanks @thepigdestroyer)
|
||||
- **feat(codex):** bulk import Codex `auth.json` — multi-file upload, paste-from-clipboard, and ZIP archive support. ([#2343](https://github.com/diegosouzapw/OmniRoute/pull/2343))
|
||||
- **feat(codex):** import single Codex `auth.json` as an OAuth connection (one-click migration from Codex Desktop). ([#2336](https://github.com/diegosouzapw/OmniRoute/pull/2336))
|
||||
- **feat(codex-auth):** rename `export` action + gate "Apply Local" behind a confirmation modal to prevent accidental local config overwrite. ([#2332](https://github.com/diegosouzapw/OmniRoute/pull/2332))
|
||||
- **fix(providers):** providers page empty-state — missing i18n keys and "Add Provider" CTA so first-time users can add a provider. ([#2333](https://github.com/diegosouzapw/OmniRoute/pull/2333), [#2337](https://github.com/diegosouzapw/OmniRoute/pull/2337))
|
||||
- **feat(cli):** CLI v4 — Commander.js architecture, 50+ commands, interactive TUI, full i18n (42 locales), plugin system (Fases 0–9). ([#2280](https://github.com/diegosouzapw/OmniRoute/pull/2280))
|
||||
- **feat(skills):** publish 3 operational SKILL.md manifests + AI Skills dashboard entry — covers operator-facing workflows on top of the developer-facing manifests shipped in #2284. ([#2276](https://github.com/diegosouzapw/OmniRoute/pull/2276))
|
||||
- **build(deps):** bump `mermaid` from 11.14.0 to 11.15.0. ([#2178](https://github.com/diegosouzapw/OmniRoute/pull/2178))
|
||||
- **build(deps):** bump `electron` from 42.0.1 to 42.1.0 in `/electron`. ([#2397](https://github.com/diegosouzapw/OmniRoute/pull/2397))
|
||||
- **build(deps):** production group bumps — 4 updates. ([#2398](https://github.com/diegosouzapw/OmniRoute/pull/2398))
|
||||
- **build(deps):** development group bumps — 4 updates. ([#2399](https://github.com/diegosouzapw/OmniRoute/pull/2399))
|
||||
- **chore:** sync `release/v3.8.0` with `main` (CodeQL hotfixes + Dependabot bumps) via merge commit.
|
||||
- **fix(providers):** Kilo Code provider no longer blocks on a missing local `kilocode` CLI binary — the provider uses OAuth device flow + direct HTTPS to `api.kilo.ai` and never required the CLI at runtime; the connection test was hard-failing with "Local CLI runtime is not installed" even when the OAuth token was valid. CLI Tools integration (`/api/cli-tools/kilo-settings`) keeps its own runtime check. ([#2404](https://github.com/diegosouzapw/OmniRoute/issues/2404) — thanks @Flexible78)
|
||||
- **fix(db):** `bun add -g omniroute` (and other runtimes that skip postinstall) no longer surfaces a generic 500 — `isNativeSqliteLoadError` now also detects "Could not locate the bindings file" / `MODULE_NOT_FOUND`, so the user gets the friendly rebuild guide instead. ([#2358](https://github.com/diegosouzapw/OmniRoute/issues/2358) — thanks @yamansin)
|
||||
- **feat(gamification):** implement Gamification & Leaderboard System with non-blocking event-driven updates. ([#2405](https://github.com/diegosouzapw/OmniRoute/pull/2405))
|
||||
- **feat(providers):** support Gemini API keys for Gemini CLI executor. ([#2408](https://github.com/diegosouzapw/OmniRoute/pull/2408) — thanks @benzntech)
|
||||
- **fix(resilience):** add API Key health tracking with automatic rotation and UI toast alerts. ([#2412](https://github.com/diegosouzapw/OmniRoute/pull/2412) — thanks @clousky2020)
|
||||
|
||||
### 🏆 v3.8.0 Hall of Fame — extended credits (post-release)
|
||||
|
||||
The following contributions landed after the initial v3.8.0 cut and supplement the 55+ community hall of fame below. Updated tallies:
|
||||
@@ -175,9 +335,7 @@ Thanks also to **@app/dependabot** for keeping our dependency tree current via #
|
||||
|
||||
---
|
||||
|
||||
## [3.8.0] - 2026-05-15
|
||||
|
||||
### Added
|
||||
### Detalhes completos — features do release (2026-05-15)
|
||||
|
||||
- **feat(providers):** expanded capabilities for Pollinations, MiniMax, Together, and Replicate across Video, Audio TTS, and Transcription registries. ([#2369](https://github.com/diegosouzapw/OmniRoute/pull/2369) — thanks @oyi77)
|
||||
- **feat(providers):** added Veo AI Free as a web-wrapper provider for generating video, image, and TTS without an API key. ([#2366](https://github.com/diegosouzapw/OmniRoute/pull/2366) — thanks @oyi77)
|
||||
@@ -221,9 +379,9 @@ Thanks also to **@app/dependabot** for keeping our dependency tree current via #
|
||||
|
||||
- **chore(deps):** node dependency updates — bump multiple runtime and dev dependencies to latest patch/minor versions. ([#2259](https://github.com/diegosouzapw/OmniRoute/pull/2259) — thanks @backryun)
|
||||
|
||||
## [3.8.0] — 2026-05-06
|
||||
### Detalhes completos — features e fixes do lançamento (2026-05-06 a 2026-05-14)
|
||||
|
||||
### ✨ New Features
|
||||
#### ✨ New Features
|
||||
|
||||
- **feat(providers):** add Command Code provider (#2199 — thanks @ddarkr)
|
||||
- **feat(providers):** add ModelScope provider-specific 429 handling and retry logic (#2202 — thanks @InkshadeWoods)
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||
@@ -11,6 +12,7 @@ import { dirname, join, extname, basename } from "node:path";
|
||||
import { resolveDataDir } from "../data-dir.mjs";
|
||||
import { apiFetch, isServerUp } from "../api.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { backupSqliteFile } from "../sqlite.mjs";
|
||||
|
||||
function getBackupDir() {
|
||||
return join(resolveDataDir(), "backups");
|
||||
@@ -187,13 +189,6 @@ export async function runBackupCommand(opts = {}) {
|
||||
try {
|
||||
if (!existsSync(backupDir)) mkdirSync(backupDir, { recursive: true });
|
||||
|
||||
let Database;
|
||||
try {
|
||||
Database = (await import("better-sqlite3")).default;
|
||||
} catch {
|
||||
Database = null;
|
||||
}
|
||||
|
||||
let backedUp = 0;
|
||||
let skipped = 0;
|
||||
|
||||
@@ -207,14 +202,11 @@ export async function runBackupCommand(opts = {}) {
|
||||
const destName = opts.encrypt ? `${file.name}.enc` : file.name;
|
||||
const destPath = join(backupPath, destName);
|
||||
mkdirSync(dirname(destPath), { recursive: true });
|
||||
if (file.name.endsWith(".sqlite") && Database) {
|
||||
const db = new Database(sourcePath, { readonly: true });
|
||||
if (file.name.endsWith(".sqlite")) {
|
||||
const tmpPath = destPath.replace(/\.enc$/, "");
|
||||
await db.backup(tmpPath);
|
||||
db.close();
|
||||
await backupSqliteFile(sourcePath, tmpPath);
|
||||
if (opts.encrypt) {
|
||||
encryptFile(tmpPath, destPath, passphrase);
|
||||
const { unlinkSync } = await import("node:fs");
|
||||
unlinkSync(tmpPath);
|
||||
}
|
||||
} else if (opts.encrypt) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { pathToFileURL } from "node:url";
|
||||
import { resolveDataDir, resolveStoragePath } from "../data-dir.mjs";
|
||||
import { printHeading } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { readDatabaseHealth, readEncryptedCredentialSamples } from "../sqlite.mjs";
|
||||
|
||||
const STATIC_SALT = "omniroute-field-encryption-v1";
|
||||
const KEY_LENGTH = 32;
|
||||
@@ -78,14 +79,6 @@ function checkConfig(dataDir) {
|
||||
return ok("Config", `.env found at ${envFile}`, { envFile });
|
||||
}
|
||||
|
||||
async function loadBetterSqlite() {
|
||||
try {
|
||||
return (await import("better-sqlite3")).default;
|
||||
} catch (error) {
|
||||
return { error };
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMigrationsDir(rootDir) {
|
||||
const configured = process.env.OMNIROUTE_MIGRATIONS_DIR;
|
||||
const candidates = [
|
||||
@@ -115,18 +108,9 @@ async function checkDatabase(dbPath, rootDir) {
|
||||
return warn("Database", `SQLite database not found at ${dbPath}`, { dbPath });
|
||||
}
|
||||
|
||||
const Database = await loadBetterSqlite();
|
||||
if (Database.error) {
|
||||
return fail("Database", "better-sqlite3 could not be loaded", {
|
||||
error: Database.error instanceof Error ? Database.error.message : String(Database.error),
|
||||
});
|
||||
}
|
||||
|
||||
let db;
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
const quickCheck = db.prepare("PRAGMA quick_check").get();
|
||||
const quickCheckValue = Object.values(quickCheck || {})[0];
|
||||
const { quickCheckValue, hasMigrationTable, appliedMigrationVersions } =
|
||||
await readDatabaseHealth(dbPath);
|
||||
if (quickCheckValue !== "ok") {
|
||||
return fail("Database", `SQLite quick_check failed: ${quickCheckValue}`, { dbPath });
|
||||
}
|
||||
@@ -137,18 +121,11 @@ async function checkDatabase(dbPath, rootDir) {
|
||||
return ok("Database", "SQLite quick_check passed", { dbPath, migrations: "not_checked" });
|
||||
}
|
||||
|
||||
const table = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get("_omniroute_migrations");
|
||||
if (!table) {
|
||||
if (!hasMigrationTable) {
|
||||
return warn("Database", "SQLite is readable, but migration table is missing", { dbPath });
|
||||
}
|
||||
|
||||
const appliedRows = db
|
||||
.prepare("SELECT version FROM _omniroute_migrations")
|
||||
.all()
|
||||
.map((row) => row.version);
|
||||
const applied = new Set(appliedRows);
|
||||
const applied = new Set(appliedMigrationVersions);
|
||||
const pending = migrationFiles.filter((migration) => !applied.has(migration.version));
|
||||
|
||||
if (pending.length > 0) {
|
||||
@@ -164,8 +141,6 @@ async function checkDatabase(dbPath, rootDir) {
|
||||
dbPath,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
if (db) db.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,42 +178,14 @@ async function checkStorageEncryption(dbPath) {
|
||||
: warn("Storage/encryption", "No STORAGE_ENCRYPTION_KEY configured; passthrough mode");
|
||||
}
|
||||
|
||||
const Database = await loadBetterSqlite();
|
||||
if (Database.error) {
|
||||
return fail("Storage/encryption", "Could not inspect encrypted credentials", {
|
||||
error: Database.error instanceof Error ? Database.error.message : String(Database.error),
|
||||
});
|
||||
}
|
||||
|
||||
let db;
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
const hasProviderTable = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get("provider_connections");
|
||||
const { hasProviderTable, encryptedValues } = await readEncryptedCredentialSamples(dbPath);
|
||||
if (!hasProviderTable) {
|
||||
return secret
|
||||
? ok("Storage/encryption", "Encryption key is configured; provider table not initialized")
|
||||
: warn("Storage/encryption", "No STORAGE_ENCRYPTION_KEY configured; passthrough mode");
|
||||
}
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT api_key, access_token, refresh_token, id_token
|
||||
FROM provider_connections
|
||||
WHERE api_key LIKE 'enc:v1:%'
|
||||
OR access_token LIKE 'enc:v1:%'
|
||||
OR refresh_token LIKE 'enc:v1:%'
|
||||
OR id_token LIKE 'enc:v1:%'
|
||||
LIMIT 20`
|
||||
)
|
||||
.all();
|
||||
const encryptedValues = rows.flatMap((row) =>
|
||||
["api_key", "access_token", "refresh_token", "id_token"]
|
||||
.filter((key) => typeof row[key] === "string" && row[key].startsWith("enc:v1:"))
|
||||
.map((key) => row[key])
|
||||
);
|
||||
|
||||
if (encryptedValues.length === 0) {
|
||||
return secret
|
||||
? ok("Storage/encryption", "Encryption key is configured; no encrypted samples found")
|
||||
@@ -268,8 +215,6 @@ async function checkStorageEncryption(dbPath) {
|
||||
return fail("Storage/encryption", "Encrypted credential check failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
if (db) db.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,33 +1,42 @@
|
||||
import fs from "node:fs";
|
||||
import { resolveDataDir, resolveStoragePath } from "./data-dir.mjs";
|
||||
import { ensureProviderSchema } from "./provider-store.mjs";
|
||||
import { ensureSettingsSchema } from "./settings-store.mjs";
|
||||
import { ensureSettingsSchema, hashManagementPassword, updateSettings } from "./settings-store.mjs";
|
||||
|
||||
async function loadBetterSqlite() {
|
||||
try {
|
||||
return (await import("better-sqlite3")).default;
|
||||
} catch {
|
||||
throw new Error("better-sqlite3 is not installed. Run npm install before using setup.");
|
||||
}
|
||||
}
|
||||
|
||||
function createSqliteNativeError(error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("NODE_MODULE_VERSION") || message.includes("ERR_DLOPEN_FAILED")) {
|
||||
return new Error(
|
||||
"better-sqlite3 native binding is incompatible with this Node.js runtime. " +
|
||||
"Run `npm rebuild better-sqlite3` in the OmniRoute project and try again."
|
||||
);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
async function openSqliteDatabase(dbPath, options = {}) {
|
||||
const Database = await loadBetterSqlite();
|
||||
try {
|
||||
return new Database(dbPath, options);
|
||||
} catch (error) {
|
||||
throw createSqliteNativeError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function openOmniRouteDb() {
|
||||
const dataDir = resolveDataDir();
|
||||
const dbPath = resolveStoragePath(dataDir);
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
|
||||
let Database;
|
||||
try {
|
||||
Database = (await import("better-sqlite3")).default;
|
||||
} catch {
|
||||
throw new Error("better-sqlite3 is not installed. Run npm install before using setup.");
|
||||
}
|
||||
|
||||
let db;
|
||||
try {
|
||||
db = new Database(dbPath);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("NODE_MODULE_VERSION") || message.includes("ERR_DLOPEN_FAILED")) {
|
||||
throw new Error(
|
||||
"better-sqlite3 native binding is incompatible with this Node.js runtime. " +
|
||||
"Run `npm rebuild better-sqlite3` in the OmniRoute project and try again."
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const db = await openSqliteDatabase(dbPath);
|
||||
|
||||
db.pragma("journal_mode = WAL");
|
||||
ensureSettingsSchema(db);
|
||||
@@ -35,3 +44,113 @@ export async function openOmniRouteDb() {
|
||||
|
||||
return { db, dataDir, dbPath };
|
||||
}
|
||||
|
||||
export async function withReadonlySqlite(dbPath, callback) {
|
||||
const db = await openSqliteDatabase(dbPath, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
return await callback(db);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function backupSqliteFile(sourcePath, destPath) {
|
||||
const db = await openSqliteDatabase(sourcePath, { readonly: true });
|
||||
try {
|
||||
await db.backup(destPath);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function readDatabaseHealth(dbPath) {
|
||||
return withReadonlySqlite(dbPath, (db) => {
|
||||
const quickCheck = db.prepare("PRAGMA quick_check").get();
|
||||
const quickCheckValue = Object.values(quickCheck || {})[0];
|
||||
const hasMigrationTable = !!db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get("_omniroute_migrations");
|
||||
const appliedMigrationVersions = hasMigrationTable
|
||||
? db
|
||||
.prepare("SELECT version FROM _omniroute_migrations")
|
||||
.all()
|
||||
.map((row) => row.version)
|
||||
: [];
|
||||
|
||||
return { quickCheckValue, hasMigrationTable, appliedMigrationVersions };
|
||||
});
|
||||
}
|
||||
|
||||
export async function readEncryptedCredentialSamples(dbPath) {
|
||||
return withReadonlySqlite(dbPath, (db) => {
|
||||
const hasProviderTable = !!db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get("provider_connections");
|
||||
if (!hasProviderTable) {
|
||||
return { hasProviderTable: false, encryptedValues: [] };
|
||||
}
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT api_key, access_token, refresh_token, id_token
|
||||
FROM provider_connections
|
||||
WHERE api_key LIKE 'enc:v1:%'
|
||||
OR access_token LIKE 'enc:v1:%'
|
||||
OR refresh_token LIKE 'enc:v1:%'
|
||||
OR id_token LIKE 'enc:v1:%'
|
||||
LIMIT 20`
|
||||
)
|
||||
.all();
|
||||
|
||||
const encryptedValues = rows.flatMap((row) =>
|
||||
["api_key", "access_token", "refresh_token", "id_token"]
|
||||
.filter((key) => typeof row[key] === "string" && row[key].startsWith("enc:v1:"))
|
||||
.map((key) => row[key])
|
||||
);
|
||||
|
||||
return { hasProviderTable: true, encryptedValues };
|
||||
});
|
||||
}
|
||||
|
||||
export async function readManagementPasswordState(dbPath = resolveStoragePath(resolveDataDir())) {
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
return { exists: false, hasPassword: false };
|
||||
}
|
||||
|
||||
return withReadonlySqlite(dbPath, (db) => {
|
||||
const hasSettingsTable = !!db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get("key_value");
|
||||
if (!hasSettingsTable) {
|
||||
return { exists: true, hasPassword: false };
|
||||
}
|
||||
const row = db
|
||||
.prepare("SELECT value FROM key_value WHERE namespace = 'settings' AND key = ?")
|
||||
.get("password");
|
||||
let password = row?.value;
|
||||
if (typeof password === "string") {
|
||||
try {
|
||||
password = JSON.parse(password);
|
||||
} catch {}
|
||||
}
|
||||
return {
|
||||
exists: true,
|
||||
hasPassword: typeof password === "string" && password.length > 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetManagementPassword(
|
||||
password,
|
||||
dbPath = resolveStoragePath(resolveDataDir())
|
||||
) {
|
||||
const db = await openSqliteDatabase(dbPath);
|
||||
try {
|
||||
db.pragma("journal_mode = WAL");
|
||||
ensureSettingsSchema(db);
|
||||
const hashedPassword = await hashManagementPassword(password);
|
||||
updateSettings(db, { password: hashedPassword, requireLogin: true });
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,16 +14,12 @@
|
||||
*/
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { existsSync } from "node:fs";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
import { resolveDataDir, resolveStoragePath } from "./cli/data-dir.mjs";
|
||||
import { readManagementPasswordState, resetManagementPassword } from "./cli/sqlite.mjs";
|
||||
|
||||
// Resolve data directory — same logic as the server
|
||||
const DATA_DIR = process.env.DATA_DIR || resolve(__dirname, "..", "data");
|
||||
const DB_PATH = resolve(DATA_DIR, "settings.db");
|
||||
const DATA_DIR = resolveDataDir();
|
||||
const DB_PATH = resolveStoragePath(DATA_DIR);
|
||||
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
@@ -34,37 +30,19 @@ function ask(question) {
|
||||
return new Promise((resolve) => rl.question(question, resolve));
|
||||
}
|
||||
|
||||
function generateSecretDigest(input) {
|
||||
// Use bcrypt with a salt round of 10 to match login/route.ts expectations
|
||||
// and resolve CodeQL js/insufficient-password-hash warning.
|
||||
return bcrypt.hashSync(input, 10);
|
||||
}
|
||||
|
||||
console.log("\n🔑 OmniRoute — Password Reset\n");
|
||||
|
||||
async function main() {
|
||||
// Check if database exists
|
||||
if (!existsSync(DB_PATH)) {
|
||||
const passwordState = await readManagementPasswordState(DB_PATH);
|
||||
if (!passwordState.exists) {
|
||||
console.error(`❌ Database not found at: ${DB_PATH}`);
|
||||
console.error(` Make sure OmniRoute has been started at least once.`);
|
||||
console.error(` Or set DATA_DIR env var to your data directory.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let Database;
|
||||
try {
|
||||
Database = (await import("better-sqlite3")).default;
|
||||
} catch {
|
||||
console.error("❌ better-sqlite3 not installed. Run: npm install");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
// Check current settings
|
||||
const row = db.prepare("SELECT value FROM settings WHERE key = 'password'").get();
|
||||
|
||||
if (row) {
|
||||
if (passwordState.hasPassword) {
|
||||
console.log("ℹ️ A password is currently set.");
|
||||
} else {
|
||||
console.log("ℹ️ No password is currently set.");
|
||||
@@ -74,7 +52,6 @@ async function main() {
|
||||
|
||||
if (!password || password.length < 8) {
|
||||
console.error("\n❌ Password must be at least 8 characters.\n");
|
||||
db.close();
|
||||
rl.close();
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -83,28 +60,11 @@ async function main() {
|
||||
|
||||
if (password !== confirm) {
|
||||
console.error("\n❌ Passwords do not match.\n");
|
||||
db.close();
|
||||
rl.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const hashed = generateSecretDigest(password);
|
||||
|
||||
// Upsert the password
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO settings (key, value) VALUES ('password', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
`);
|
||||
stmt.run(hashed);
|
||||
|
||||
// Also ensure requireLogin is true
|
||||
const loginStmt = db.prepare(`
|
||||
INSERT INTO settings (key, value) VALUES ('requireLogin', 'true')
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
`);
|
||||
loginStmt.run();
|
||||
|
||||
db.close();
|
||||
await resetManagementPassword(password, DB_PATH);
|
||||
rl.close();
|
||||
|
||||
console.log("\n✅ Password reset successfully!");
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
services:
|
||||
# ── Redis (Rate Limiter Backend) ──────────────────────────────────
|
||||
redis:
|
||||
image: redis:8.6.2
|
||||
image: redis:8.6.2-alpine
|
||||
container_name: omniroute-redis-prod
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
|
||||
@@ -6,14 +6,11 @@
|
||||
# base → minimal image, no CLI tools
|
||||
# cli → CLIs installed inside the container (portable)
|
||||
# host → runner-base + host-mounted CLI binaries (Linux-first)
|
||||
# cliproxyapi → CLIProxyAPI sidecar on port 8317
|
||||
#
|
||||
# Usage:
|
||||
# docker compose --profile base up -d
|
||||
# docker compose --profile cli up -d
|
||||
# docker compose --profile host up -d
|
||||
# docker compose --profile cliproxyapi up -d
|
||||
# docker compose --profile cli --profile cliproxyapi up -d
|
||||
#
|
||||
# Before first run, copy .env.example → .env and edit your secrets.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
@@ -130,30 +127,6 @@ services:
|
||||
profiles:
|
||||
- host
|
||||
|
||||
# ── Profile: cliproxyapi (CLIProxyAPI as sidecar) ─────────────────
|
||||
cliproxyapi:
|
||||
container_name: cliproxyapi
|
||||
image: ghcr.io/router-for-me/cliproxyapi:v6.9.7
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${CLIPROXYAPI_PORT:-8317}:${CLIPROXYAPI_PORT:-8317}"
|
||||
volumes:
|
||||
- cliproxyapi-data:/root/.cli-proxy-api
|
||||
environment:
|
||||
- PORT=${CLIPROXYAPI_PORT:-8317}
|
||||
- HOST=0.0.0.0
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD", "wget", "--spider", "-q", "http://127.0.0.1:${CLIPROXYAPI_PORT:-8317}/v1/models"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
profiles:
|
||||
- cliproxyapi
|
||||
|
||||
volumes:
|
||||
cliproxyapi-data:
|
||||
name: cliproxyapi-data
|
||||
redis-data:
|
||||
name: omniroute-redis-data
|
||||
|
||||
149
docs/AGENTROUTER.md
Normal file
149
docs/AGENTROUTER.md
Normal 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
|
||||
@@ -17,7 +17,7 @@ It provides a single OpenAI-compatible endpoint (`/v1/*`) and routes traffic acr
|
||||
|
||||
Core capabilities:
|
||||
|
||||
- OpenAI-compatible API surface for CLI/tools (177 providers, 31 executors)
|
||||
- OpenAI-compatible API surface for CLI/tools (177 providers, 38 executors)
|
||||
- Request/response translation across provider formats
|
||||
- Model combo fallback (multi-model sequence)
|
||||
- Structured combo steps (`provider + model + connection`) with runtime ordering by `compositeTiers`
|
||||
|
||||
@@ -411,7 +411,7 @@ open-sse/
|
||||
├── types.d.ts
|
||||
├── config/ Provider registries, header profiles, identity, …
|
||||
├── handlers/ Request handlers (chat, embeddings, audio, image, …)
|
||||
├── executors/ 31 provider-specific HTTP executors
|
||||
├── executors/ 38 provider-specific HTTP executors
|
||||
├── translator/ Format conversion (OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro)
|
||||
├── transformer/ Responses API ↔ Chat Completions stream transformer
|
||||
├── services/ 80+ service modules (combos, fallback, quotas, identity, …)
|
||||
@@ -441,7 +441,7 @@ open-sse/
|
||||
|
||||
### 4.2 `open-sse/executors/`
|
||||
|
||||
31 provider executors, each extending `BaseExecutor` (`base.ts`):
|
||||
38 provider executors, each extending `BaseExecutor` (`base.ts`):
|
||||
|
||||
`antigravity`, `azure-openai`, `blackbox-web`, `chatgpt-web`, `cliproxyapi`,
|
||||
`cloudflare-ai`, `codex`, `commandCode`, `cursor`, `default`, `devin-cli`,
|
||||
|
||||
@@ -64,23 +64,17 @@ docker compose --profile cli up -d
|
||||
|
||||
# Host profile (Linux-first; mounts host CLI binaries read-only)
|
||||
docker compose --profile host up -d
|
||||
|
||||
# Combine CLI + CLIProxyAPI sidecar
|
||||
docker compose --profile cli --profile cliproxyapi up -d
|
||||
```
|
||||
|
||||
## Available Profiles
|
||||
|
||||
OmniRoute ships four Compose profiles. Pick the one that matches your environment.
|
||||
OmniRoute ships three Compose profiles. Pick the one that matches your environment.
|
||||
|
||||
| Profile | Service | When to use | Command |
|
||||
| ---------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
|
||||
| `base` (default) | `omniroute-base` | Headless server / minimal runtime, no provider CLIs bundled | `docker compose --profile base up -d` |
|
||||
| `cli` | `omniroute-cli` | Agentic workflows that call `omniroute providers/setup/doctor` and bundled CLIs (Codex, Claude Code, Droid, OpenClaw) | `docker compose --profile cli up -d` |
|
||||
| `host` | `omniroute-host` | Linux hosts that want `network_mode`-like access to host CLIs by mounting `~/.local/bin`, `~/.codex`, `~/.claude`, etc. read-only | `docker compose --profile host up -d` |
|
||||
| `cliproxyapi` | `cliproxyapi` | Run the [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) sidecar on port `8317` for upstream CLI proxying | `docker compose --profile cliproxyapi up -d` |
|
||||
|
||||
> Multiple profiles can be combined: `docker compose --profile cli --profile cliproxyapi up -d`.
|
||||
| Profile | Service | When to use | Command |
|
||||
| ---------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
|
||||
| `base` (default) | `omniroute-base` | Headless server / minimal runtime, no provider CLIs bundled | `docker compose --profile base up -d` |
|
||||
| `cli` | `omniroute-cli` | Agentic workflows that call `omniroute providers/setup/doctor` and bundled CLIs (Codex, Claude Code, Droid, OpenClaw) | `docker compose --profile cli up -d` |
|
||||
| `host` | `omniroute-host` | Linux hosts that want `network_mode`-like access to host CLIs by mounting `~/.local/bin`, `~/.codex`, `~/.claude`, etc. read-only | `docker compose --profile host up -d` |
|
||||
|
||||
## Redis Sidecar
|
||||
|
||||
@@ -167,7 +161,6 @@ Beyond the defaults documented in [ENVIRONMENT.md](../reference/ENVIRONMENT.md),
|
||||
| `OMNIROUTE_MEMORY_MB` | Node heap ceiling (`NODE_OPTIONS=--max-old-space-size`) baked into the image | `256` (set in Dockerfile) |
|
||||
| `DASHBOARD_PORT` / `API_PORT` | Override exposed ports for dashboard (20128) and API (20129) | `20128` / `20129` |
|
||||
| `PROD_DASHBOARD_PORT` | Host-side dashboard port for `docker-compose.prod.yml` | `20130` |
|
||||
| `CLIPROXYAPI_PORT` | Host-side port for the `cliproxyapi` sidecar | `8317` |
|
||||
|
||||
## Docker Compose with Caddy (HTTPS Auto-TLS)
|
||||
|
||||
|
||||
136
docs/guides/KIRO_SETUP.md
Normal file
136
docs/guides/KIRO_SETUP.md
Normal 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).
|
||||
@@ -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
|
||||
|
||||
346
docs/ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md
Normal file
346
docs/ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md
Normal file
@@ -0,0 +1,346 @@
|
||||
# E2E Dashboard Shakedown — v3.8.0
|
||||
|
||||
**Branch alvo:** `release/v3.8.0`
|
||||
**Objetivo:** validar manualmente, em modo dev (Turbopack), que toda página renderiza sem erro de runtime ou de backend antes de fechar a versão 3.8.0. Para cada erro encontrado, o operador **corrige na própria página** e segue para a próxima — esse documento é o roteiro vivo da sessão.
|
||||
|
||||
> Este é um plano de **smoke test manual operacional**, não uma suíte automatizada. Pareceu didático demais? É proposital: o objetivo é que outro mantenedor consiga retomar do meio se a sessão for interrompida.
|
||||
|
||||
---
|
||||
|
||||
## 0. Pré-requisitos (rodar uma vez)
|
||||
|
||||
### 0.1 Estado do repositório
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git checkout release/v3.8.0
|
||||
git pull origin release/v3.8.0 --ff-only
|
||||
git status # working tree limpo
|
||||
```
|
||||
|
||||
### 0.2 Conflito conhecido — diretório `app/` na raiz
|
||||
|
||||
O `npm pack` e `npm run build` geram `app/` na raiz (gitignored, mirror de `src/app/`). Se ele existir, **o Next.js dev prefere a raiz e quebra todas as rotas** (Turbopack devolve `PageNotFoundError: Cannot find module for page: route not found /(dashboard)/...`).
|
||||
|
||||
```bash
|
||||
[ -d app ] && mv app /tmp/omniroute-pack-artifact-$(date +%s)
|
||||
ls -d app 2>/dev/null && echo "STILL THERE — abortar" || echo "ok"
|
||||
```
|
||||
|
||||
### 0.3 Cache do Turbopack
|
||||
|
||||
```bash
|
||||
rm -rf .next/dev
|
||||
```
|
||||
|
||||
### 0.4 Dev server
|
||||
|
||||
Em um terminal dedicado:
|
||||
|
||||
```bash
|
||||
npm run dev 2>&1 | tee /tmp/omniroute-dev.log
|
||||
```
|
||||
|
||||
Esperar `Ready` e `Local: http://localhost:20128`. Mantenha o terminal visível durante toda a sessão — é a fonte primária de erros de backend.
|
||||
|
||||
### 0.5 Browser
|
||||
|
||||
- Chrome com **DevTools aberto** (F12), aba **Console** ativa, **filtro `error|warning`**, e a aba **Network** com "Preserve log" marcado.
|
||||
- Limpar o console entre páginas (`Ctrl+L`) para isolar o ruído.
|
||||
- Login com a conta admin antes de começar (algumas páginas só carregam autenticado).
|
||||
|
||||
### 0.6 Side-channel — busca por erros no backend
|
||||
|
||||
Em outro terminal:
|
||||
|
||||
```bash
|
||||
tail -F /tmp/omniroute-dev.log | grep --line-buffered -iE "error|warn|cannot|undefined|TypeError|PageNotFoundError"
|
||||
```
|
||||
|
||||
Mantenha aberto. Se algo aparecer enquanto você está em uma página, anote na coluna **Erros** da linha correspondente.
|
||||
|
||||
---
|
||||
|
||||
## 1. O que conta como "passou"
|
||||
|
||||
Uma página passa quando **todas** estas condições são atendidas:
|
||||
|
||||
1. HTTP final é `200` (não `4xx`/`5xx`). Redirects (`307`/`302`) só são aceitáveis se intencionais (ex.: `/dashboard` → `/home`).
|
||||
2. Nenhum **error overlay** do Turbopack/React aparece na tela.
|
||||
3. Nenhum `console.error` no DevTools (warnings são toleráveis, mas anote os novos).
|
||||
4. Nenhuma stack trace nova no `/tmp/omniroute-dev.log`. Erros pré-existentes recorrentes (refresh de token de provider sem credencial, p.ex.) podem ser ignorados — mas confirme que são os mesmos de antes.
|
||||
5. Conteúdo principal da página renderiza (não apenas o layout/sidebar vazio).
|
||||
6. Pelo menos uma interação básica funciona (clique em uma aba, filtro, ou link interno) sem erro.
|
||||
|
||||
Se qualquer um falhar → **status `❌`**, descreva o sintoma na coluna **Erros**, corrija, recarregue, marque `✅` quando passar.
|
||||
|
||||
---
|
||||
|
||||
## 2. Categorias de erro mais comuns e padrão de correção
|
||||
|
||||
| Sintoma | Lugar onde aparece | Causa típica | Onde corrigir |
|
||||
| ---------------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ |
|
||||
| `PageNotFoundError: route not found /(group)/...` | Tela vermelha do Turbopack | `app/` na raiz, ou `.next/dev/` stale | Voltar à §0.2/0.3 |
|
||||
| `Cannot find module 'X'` em runtime | Tela ou log | Import inexistente, alias quebrado | Corrigir o import; checar `tsconfig.json` |
|
||||
| `Hydration failed because the server rendered HTML didn't match` | Console | Date/Math.random no render server, ou `useEffect` fora de `"use client"` | Mover lógica para `useEffect`, ou marcar a sub-árvore como `dynamic="force-dynamic"` |
|
||||
| `500` em rota de API chamada pela página | Network + log | Zod parse fail, DB error, validador de input | Ver o handler em `src/app/api/.../route.ts` e o módulo em `src/lib/db/` |
|
||||
| `Error: Text content does not match server-rendered HTML` | Console | i18n key faltando, ou string diferente entre SSR/CSR | `npm run i18n:run -- --files=<arquivo>` ou adicionar a chave |
|
||||
| Skeleton infinito | Tela | `useEffect` busca dados de uma API que retorna 401/500 | Conferir auth/proxy/middleware; testar a rota com `curl -H "cookie:..."` |
|
||||
| Sidebar/layout não renderiza | Tela | `(dashboard)/layout.tsx` falhando | Olhar o `DashboardLayout` em `src/shared/components/` |
|
||||
| Botão/tab dispara erro ao clicar | Console | Provider/context ausente, mock removido | Verificar providers no `(dashboard)/layout.tsx` |
|
||||
|
||||
**Regra de ouro:** se a correção exigir mais de ~20 linhas ou cruza módulos do `open-sse/`, anote como `bloqueador` e siga para a próxima página — não trave a release por um refactor.
|
||||
|
||||
---
|
||||
|
||||
## 3. Checklist de páginas (ordem sugerida)
|
||||
|
||||
Marque conforme avança. A ordem segue a sidebar (top → bottom) com as páginas órfãs no final. URLs assumem `http://localhost:20128`.
|
||||
|
||||
### 3.1 Auth & público (rodar **deslogado** primeiro, depois logar)
|
||||
|
||||
| Status | URL | O que validar | Erros |
|
||||
| ------ | ---------------------------------------------------------------------------- | ---------------------------------------------------- | ----- |
|
||||
| ☐ | `/` | Redireciona para `/login` ou `/home` conforme sessão | |
|
||||
| ☐ | `/login` | Form aparece, validação de campo vazio funciona | |
|
||||
| ☐ | `/forgot-password` | Form aparece | |
|
||||
| ☐ | `/landing` | Renderiza sem CSS quebrado | |
|
||||
| ☐ | `/docs` | Index dos docs carrega | |
|
||||
| ☐ | `/docs/api-explorer` | OpenAPI explorer carrega | |
|
||||
| ☐ | `/docs/quickstart` (ex. slug) | Markdown renderiza | |
|
||||
| ☐ | `/status` | Status page renderiza | |
|
||||
| ☐ | `/terms` | Texto carrega | |
|
||||
| ☐ | `/privacy` | Texto carrega | |
|
||||
| ☐ | `/maintenance` | Página estática | |
|
||||
| ☐ | `/offline` | Página estática | |
|
||||
| ☐ | `/forbidden`, `/400`, `/401`, `/403`, `/408`, `/429`, `/500`, `/502`, `/503` | Cada uma renderiza sem erro recursivo | |
|
||||
|
||||
> Após confirmar o `/login`, autentique e siga.
|
||||
|
||||
### 3.2 Sidebar — Home
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | ------------ | --------------------------------------------------- | ----- |
|
||||
| ☐ | `/dashboard` | Redireciona para `/home` (HTTP 307) | |
|
||||
| ☐ | `/home` | Cards de overview renderizam, sem skeleton infinito | |
|
||||
|
||||
### 3.3 Sidebar — OmniProxy
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | --------------------------------------------------------- | ------------------------------------------------------------- | ----- |
|
||||
| ☐ | `/dashboard/endpoint` | Lista de endpoints + tabs | |
|
||||
| ☐ | `/dashboard/api-manager` | Lista de API keys, botão "Create" abre modal | |
|
||||
| ☐ | `/dashboard/providers` | Tabela de providers carrega; filtro de status funciona | |
|
||||
| ☐ | `/dashboard/providers/new` | Form de novo provider; campos condicionais respondem | |
|
||||
| ☐ | `/dashboard/providers/anthropic` (qualquer `[id]` válido) | Detalhe do provider; abas "Connections", "Models", "Validate" | |
|
||||
| ☐ | `/dashboard/combos` | Lista de combos, drag/drop, modo cost-optimized | |
|
||||
| ☐ | `/dashboard/quota` | Quotas globais por provider/modelo | |
|
||||
|
||||
#### 3.3.1 Compressão e Contexto
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----- |
|
||||
| ☐ | `/dashboard/context/caveman` | Regras Caveman + preview ao vivo | |
|
||||
| ☐ | `/dashboard/context/rtk` | DSL editor (Monaco) — **atenção:** se Monaco falhar com `vs/nls.messages-loader`, é regressão do fix do `MonacoEditor.tsx` | |
|
||||
| ☐ | `/dashboard/context/combos` | Pipeline RTK→Caveman | |
|
||||
|
||||
#### 3.3.2 Ferramentas
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | ------------------------- | -------------------------------------------------------- | ----- |
|
||||
| ☐ | `/dashboard/cli-tools` | Lista de tools, botão "Generate config" responde sem 500 | |
|
||||
| ☐ | `/dashboard/agents` | Lista de agents | |
|
||||
| ☐ | `/dashboard/cloud-agents` | 3 cloud agents listados (codex-cloud, devin, jules) | |
|
||||
|
||||
#### 3.3.3 Integrações
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | -------------------------- | --------------------------------- | ----- |
|
||||
| ☐ | `/dashboard/api-endpoints` | OpenAPI auto-doc renderiza | |
|
||||
| ☐ | `/dashboard/webhooks` | Form de webhook + lista de events | |
|
||||
|
||||
#### 3.3.4 Proxy
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | ------------------------------ | ----------------------------------- | ----- |
|
||||
| ☐ | `/dashboard/system/proxy` | Config de proxy global/per-provider | |
|
||||
| ☐ | `/dashboard/system/mitm-proxy` | Cert install button, status | |
|
||||
| ☐ | `/dashboard/system/1proxy` | UI da feature 1proxy | |
|
||||
|
||||
### 3.4 Sidebar — Analytics
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | ----------------------------------- | ----------------------------------------- | ----- |
|
||||
| ☐ | `/dashboard/analytics` | Dashboard de uso geral, gráficos carregam | |
|
||||
| ☐ | `/dashboard/analytics/combo-health` | Tabela + sparklines por combo | |
|
||||
| ☐ | `/dashboard/analytics/utilization` | Heatmap | |
|
||||
| ☐ | `/dashboard/costs` | Custo total + breakdown | |
|
||||
| ☐ | `/dashboard/cache` | Métricas de cache, hit-rate | |
|
||||
| ☐ | `/dashboard/analytics/compression` | Métricas de RTK/Caveman | |
|
||||
| ☐ | `/dashboard/analytics/search` | Métricas de search providers | |
|
||||
| ☐ | `/dashboard/analytics/evals` | Suítes de eval + run history | |
|
||||
|
||||
### 3.5 Sidebar — Monitoring
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | -------------------------- | ---------------------------------------------------------- | ----- |
|
||||
| ☐ | `/dashboard/logs` | Hub de logs | |
|
||||
| ☐ | `/dashboard/logs/proxy` | Tail de requisições (live) — confirmar reconexão se houver | |
|
||||
| ☐ | `/dashboard/logs/console` | Console capturado | |
|
||||
| ☐ | `/dashboard/logs/activity` | Atividade do usuário | |
|
||||
| ☐ | `/dashboard/health` | Status dos providers (circuit breakers, cooldowns) | |
|
||||
| ☐ | `/dashboard/runtime` | Métricas runtime + memória/CPU | |
|
||||
|
||||
#### 3.5.1 Costs / Parameters
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | ------------------------------ | ---------------------------------------- | ----- |
|
||||
| ☐ | `/dashboard/costs/pricing` | Tabela pricing por modelo, edição inline | |
|
||||
| ☐ | `/dashboard/costs/budget` | Limites mensais + alertas | |
|
||||
| ☐ | `/dashboard/costs/quota-share` | Preview de quota sharing | |
|
||||
|
||||
#### 3.5.2 Audit
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | ---------------------- | ------------------------------- | ----- |
|
||||
| ☐ | `/dashboard/audit` | Lista de eventos audit, filtros | |
|
||||
| ☐ | `/dashboard/audit/mcp` | Audit do MCP server | |
|
||||
| ☐ | `/dashboard/audit/a2a` | Audit do A2A | |
|
||||
|
||||
### 3.6 Sidebar — DevTools
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | ------------------------- | ------------------------------------- | ----- |
|
||||
| ☐ | `/dashboard/translator` | OpenAI ↔ Claude ↔ Gemini side-by-side | |
|
||||
| ☐ | `/dashboard/playground` | Chat playground, streaming funciona | |
|
||||
| ☐ | `/dashboard/search-tools` | Lista de search providers | |
|
||||
|
||||
### 3.7 Sidebar — Agentic Features
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | ------------------------- | ------------------------------------ | ----- |
|
||||
| ☐ | `/dashboard/mcp` | MCP server config, 37 tools listadas | |
|
||||
| ☐ | `/dashboard/memory` | Memory store, FTS5 search | |
|
||||
| ☐ | `/dashboard/skills` | 10 skills publicadas | |
|
||||
| ☐ | `/dashboard/agent-skills` | Skill assignment per agent | |
|
||||
| ☐ | `/dashboard/a2a` | A2A registry + 5 skills | |
|
||||
|
||||
### 3.8 Sidebar — Other Features
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | ------------------------------- | --------------------------------- | ----- |
|
||||
| ☐ | `/dashboard/leaderboard` | Ranking + filtros | |
|
||||
| ☐ | `/dashboard/profile` | Perfil do user, edição básica | |
|
||||
| ☐ | `/dashboard/tokens` | Tokens & API keys do user | |
|
||||
| ☐ | `/dashboard/gamification/admin` | Admin-only — só logado como admin | |
|
||||
| ☐ | `/dashboard/cache/media` | Cache de mídia (imagens/áudio) | |
|
||||
| ☐ | `/dashboard/batch` | Batch jobs | |
|
||||
| ☐ | `/dashboard/batch/files` | Files API | |
|
||||
|
||||
### 3.9 Sidebar — Configuration
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | -------------------------------- | ------------------------------ | ----- |
|
||||
| ☐ | `/dashboard/settings` | Hub redireciona/exibe sub-tabs | |
|
||||
| ☐ | `/dashboard/settings/general` | Form salva sem 500 | |
|
||||
| ☐ | `/dashboard/settings/appearance` | Trocar tema aplica | |
|
||||
| ☐ | `/dashboard/settings/ai` | Config de AI models | |
|
||||
| ☐ | `/dashboard/settings/routing` | Estratégias de combo | |
|
||||
| ☐ | `/dashboard/settings/resilience` | Circuit breaker / cooldown | |
|
||||
| ☐ | `/dashboard/settings/advanced` | Toggles avançados | |
|
||||
| ☐ | `/dashboard/settings/security` | Auth, sessões, 2FA | |
|
||||
| ☐ | `/dashboard/settings/pricing` | Settings de pricing (legado) | |
|
||||
|
||||
### 3.10 Sidebar — Help
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | --------------------------- | ---------------------------------- | ----- |
|
||||
| ☐ | `/docs` (já testado em 3.1) | — | |
|
||||
| ☐ | `/dashboard/changelog` | Renderiza markdown do CHANGELOG.md | |
|
||||
|
||||
### 3.11 Páginas órfãs (existem como rota mas não estão na sidebar)
|
||||
|
||||
Testar para garantir que não estão quebradas (alguém pode ter link antigo bookmarkado).
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | ------------------------ | -------------------------------------------------------------- | ------------------------ |
|
||||
| ☐ | `/dashboard/auto-combo` | Página de Auto-Combo (9-factor scoring) | |
|
||||
| ☐ | `/dashboard/compression` | (legado — pode ter sido absorvido por `analytics/compression`) | |
|
||||
| ☐ | `/dashboard/limits` | Rate limits | |
|
||||
| ☐ | `/dashboard/onboarding` | Wizard de primeiro setup | |
|
||||
| ☐ | `/dashboard/usage` | Stats de uso (legado) | |
|
||||
| ☐ | `/auth/callback` | OAuth callback — só funciona via flow real | (não testar manualmente) |
|
||||
| ☐ | `/callback` | Mesmo do anterior | (não testar manualmente) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Procedimento por página
|
||||
|
||||
Para cada linha do checklist:
|
||||
|
||||
1. **Limpar console do DevTools** (`Ctrl+L`).
|
||||
2. **Navegar** clicando na sidebar (preferível a digitar URL — testa também a navegação).
|
||||
3. **Esperar carregar** (até o spinner sumir e o conteúdo principal aparecer; timeout subjetivo: 10s).
|
||||
4. **Olhar o DevTools Console**: qualquer `error` vermelho conta.
|
||||
5. **Olhar o terminal do `tail -F /tmp/omniroute-dev.log`**: stack trace nova = falha.
|
||||
6. **Interagir** com o elemento óbvio da página (1 clique em filtro/aba/CTA). Se o clique disparar erro, falha.
|
||||
7. **Marcar `✅`** se ok, **`❌` + nota** se falhar.
|
||||
8. **Se falhar**:
|
||||
a. Categorizar pela tabela §2.
|
||||
b. Corrigir.
|
||||
c. Salvar; aguardar Turbopack recompilar (~2-5s; olhar o terminal do dev server).
|
||||
d. Recarregar a página; refazer §1–6.
|
||||
e. Quando passar, atualizar a coluna **Erros** desta linha com "Fix: <resumo do que mudou>" e marcar `✅`.
|
||||
9. **Próxima linha.**
|
||||
|
||||
---
|
||||
|
||||
## 5. Commits durante a sessão
|
||||
|
||||
Não acumular commits enormes. **Um fix por página**, com escopo claro:
|
||||
|
||||
```bash
|
||||
# exemplo
|
||||
git add src/app/\(dashboard\)/dashboard/<página>/page.tsx
|
||||
git commit -m "fix(<area>): <descrição curta>
|
||||
|
||||
E2E shakedown v3.8.0: <página> quebrava com <sintoma>.
|
||||
<o que mudou e por quê>"
|
||||
```
|
||||
|
||||
Não usar `Co-Authored-By` (hard rule #16). Não rodar `--no-verify`.
|
||||
|
||||
Ao final da sessão, **push único** com todos os fixes:
|
||||
|
||||
```bash
|
||||
git push origin release/v3.8.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Encerramento da sessão
|
||||
|
||||
Quando todas as linhas tiverem `✅`:
|
||||
|
||||
1. Rodar a suíte rápida de sanidade:
|
||||
```bash
|
||||
npm run lint
|
||||
npm run typecheck:core
|
||||
npm run test:unit
|
||||
```
|
||||
2. Anexar este arquivo (preenchido) ao PR de release ou ao tag `v3.8.0` como evidência.
|
||||
3. Atualizar `CHANGELOG.md` com a linha:
|
||||
> E2E dashboard shakedown completed — see `docs/ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md`.
|
||||
4. Subir para `main` e disparar o release.
|
||||
|
||||
---
|
||||
|
||||
## 7. Tabela "página → ajuste aplicado" (preencher na sessão)
|
||||
|
||||
| Página | Sintoma | Causa-raiz | Correção | Commit |
|
||||
| ------------------------------- | ----------------------------------- | --------------------- | --------------------------- | -------- |
|
||||
| _exemplo: /dashboard/cli-tools_ | _500 no POST /api/cli-tools/config_ | _Zod schema faltando_ | _Adicionado `.safeParse()`_ | _abc123_ |
|
||||
| | | | | |
|
||||
| | | | | |
|
||||
|
||||
Mantenha a tabela crescendo conforme corrige. Esse é o trail de auditoria do shakedown.
|
||||
122
docs/providers/ZED-DOCKER.md
Normal file
122
docs/providers/ZED-DOCKER.md
Normal 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` |
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()`.
|
||||
|
||||
@@ -50,6 +50,8 @@ const eslintConfig = [
|
||||
"bin/**",
|
||||
// Dependencies
|
||||
"node_modules/**",
|
||||
".worktrees/**",
|
||||
".omnivscodeagent/**",
|
||||
// VS Code extension and its large test fixtures
|
||||
"vscode-extension/**",
|
||||
"_references/**",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -894,7 +897,10 @@ export class BaseExecutor {
|
||||
// Only apply for Claude/Claude-compatible — OpenAI allows results
|
||||
// spread across multiple subsequent messages.
|
||||
const isClaude = this.provider === "claude" || isClaudeCodeCompatible(this.provider);
|
||||
const adjacent = isClaude ? fixToolAdjacency(fixed) : fixed;
|
||||
// For Claude, fixToolAdjacency may strip tool_use blocks whose
|
||||
// tool_result isn't in the next message; re-run fixToolPairs to
|
||||
// drop any tool_result orphaned by that strip (discussion #2410).
|
||||
const adjacent = isClaude ? fixToolPairs(fixToolAdjacency(fixed)) : fixed;
|
||||
tb.messages = stripTrailingAssistantOrphanToolUse(adjacent);
|
||||
}
|
||||
}
|
||||
@@ -936,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
|
||||
) {
|
||||
@@ -955,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;
|
||||
@@ -969,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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
472
open-sse/executors/t3-chat-web.ts
Normal file
472
open-sse/executors/t3-chat-web.ts
Normal 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();
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -378,7 +378,11 @@ export async function buildAndSignClaudeCodeRequest(
|
||||
if (Array.isArray(b.messages)) {
|
||||
const fixed = fixToolPairs(b.messages as Record<string, unknown>[]);
|
||||
const adjacent = fixToolAdjacency(fixed);
|
||||
b.messages = stripTrailingAssistantOrphanToolUse(adjacent);
|
||||
// fixToolAdjacency can leave orphan tool_result blocks behind when it
|
||||
// strips a tool_use whose tool_result wasn't in the next message.
|
||||
// Re-pair to drop those orphans (discussion #2410).
|
||||
const cleaned = fixToolPairs(adjacent);
|
||||
b.messages = stripTrailingAssistantOrphanToolUse(cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1158,7 +1162,9 @@ function readNestedString(
|
||||
if (!current || typeof current !== "object" || Array.isArray(current)) {
|
||||
return null;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
if (key === "__proto__" || key === "constructor" || key === "prototype") return null;
|
||||
if (!Object.prototype.hasOwnProperty.call(current, key)) return null;
|
||||
current = Reflect.get(current as object, key);
|
||||
}
|
||||
return toNonEmptyString(current);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -267,6 +267,9 @@ function purifyHistory(messages: Record<string, unknown>[], targetTokens: number
|
||||
let candidate = [...system, ...nonSystem.slice(-keep)];
|
||||
candidate = fixToolPairs(candidate);
|
||||
candidate = fixToolAdjacency(candidate);
|
||||
// Re-run pair fix: fixToolAdjacency may have stripped tool_use blocks, leaving
|
||||
// orphan tool_results that Claude rejects ("tool_result without preceding tool_use").
|
||||
candidate = fixToolPairs(candidate);
|
||||
candidate = stripTrailingAssistantOrphanToolUse(candidate);
|
||||
const tokens = estimateTokens(JSON.stringify(candidate));
|
||||
if (tokens <= targetTokens) break;
|
||||
@@ -276,6 +279,9 @@ function purifyHistory(messages: Record<string, unknown>[], targetTokens: number
|
||||
let result = [...system, ...nonSystem.slice(-keep)];
|
||||
result = fixToolPairs(result);
|
||||
result = fixToolAdjacency(result);
|
||||
// Re-run pair fix to drop any tool_result whose matching tool_use was removed by
|
||||
// fixToolAdjacency (discussion #2410 — orphan tool_result -> upstream 400).
|
||||
result = fixToolPairs(result);
|
||||
result = stripTrailingAssistantOrphanToolUse(result);
|
||||
|
||||
// Add summary of dropped messages
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -17,22 +18,29 @@ const __dirname: string = dirname(__filename);
|
||||
const ROOT: string = join(__dirname, "..", "..");
|
||||
const npmCommand: string = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
|
||||
function runPackDryRun(): any {
|
||||
function runNpm(args: string[], stdio: "inherit" | "pipe" = "pipe"): string {
|
||||
const npmExecPath = process.env.npm_execpath;
|
||||
const command = npmExecPath ? process.execPath : npmCommand;
|
||||
const args = [
|
||||
...(npmExecPath ? [npmExecPath] : []),
|
||||
"pack",
|
||||
"--dry-run",
|
||||
"--json",
|
||||
"--ignore-scripts",
|
||||
];
|
||||
|
||||
const output = execFileSync(command, args, {
|
||||
return execFileSync(command, [...(npmExecPath ? [npmExecPath] : []), ...args], {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
stdio: stdio === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
function ensureAppStagingReady(): void {
|
||||
const missingAppRequiredPaths = PACK_ARTIFACT_REQUIRED_PATHS.filter((requiredPath) =>
|
||||
requiredPath.startsWith("app/")
|
||||
).filter((requiredPath) => !existsSync(join(ROOT, requiredPath)));
|
||||
|
||||
if (missingAppRequiredPaths.length === 0) return;
|
||||
|
||||
console.log("📦 app/ staging is missing required runtime files; running npm run build:cli...");
|
||||
runNpm(["run", "build:cli"], "inherit");
|
||||
}
|
||||
|
||||
function runPackDryRun(): any {
|
||||
const output = runNpm(["pack", "--dry-run", "--json", "--ignore-scripts"]);
|
||||
|
||||
const jsonStart = output.indexOf("[");
|
||||
const jsonEnd = output.lastIndexOf("]");
|
||||
@@ -66,6 +74,7 @@ function formatBytes(bytes: number): string {
|
||||
}
|
||||
|
||||
try {
|
||||
ensureAppStagingReady();
|
||||
const packReport = runPackDryRun();
|
||||
const artifactPaths: string[] = packReport.files.map((file: any) => file.path);
|
||||
const unexpectedPaths: string[] = findUnexpectedArtifactPaths(artifactPaths, {
|
||||
|
||||
284
scripts/i18n/audit-dashboard-pages.mjs
Normal file
284
scripts/i18n/audit-dashboard-pages.mjs
Normal file
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Audit every dashboard page for:
|
||||
* 1. Hardcoded user-visible strings in JSX (any language)
|
||||
* 2. t("...") calls that reference keys NOT present in en.json
|
||||
*
|
||||
* Output: a JSON report at scripts/i18n/_audit.json + a human summary on stdout.
|
||||
*
|
||||
* Scope: src/app/(dashboard)/**\/*.tsx
|
||||
*/
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, dirname, relative, basename } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = join(__dirname, "..", "..");
|
||||
const EN_JSON_PATH = join(REPO_ROOT, "src/i18n/messages/en.json");
|
||||
const DASHBOARD_ROOT = join(REPO_ROOT, "src/app/(dashboard)");
|
||||
const OUT_PATH = join(__dirname, "_audit.json");
|
||||
|
||||
const enJsonRaw = JSON.parse(readFileSync(EN_JSON_PATH, "utf8"));
|
||||
|
||||
/**
|
||||
* Deep-convert a parsed JSON object into a Map tree.
|
||||
* Using Map sidesteps prototype-pollution concerns when keys are
|
||||
* derived from source-code scraping (CWE-915).
|
||||
*/
|
||||
function toMapTree(value) {
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
if (Array.isArray(value)) return value.map(toMapTree);
|
||||
const m = new Map();
|
||||
for (const [k, v] of Object.entries(value)) m.set(k, toMapTree(v));
|
||||
return m;
|
||||
}
|
||||
const enJson = toMapTree(enJsonRaw);
|
||||
|
||||
/** Walk a directory recursively and return all .tsx files */
|
||||
function walkTsx(dir) {
|
||||
const out = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) out.push(...walkTsx(full));
|
||||
else if (entry.endsWith(".tsx")) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Walk a dotted path through the Map tree, returning the leaf or undefined */
|
||||
function walkPath(parts) {
|
||||
let cursor = enJson;
|
||||
for (const p of parts) {
|
||||
if (!(cursor instanceof Map) || !cursor.has(p)) return undefined;
|
||||
cursor = cursor.get(p);
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
/** Check if a dotted key exists, supporting dotted namespaces too */
|
||||
function keyExists(namespace, key) {
|
||||
const nsParts = namespace ? namespace.split(".") : [];
|
||||
const keyParts = key.split(".");
|
||||
return walkPath([...nsParts, ...keyParts]) !== undefined;
|
||||
}
|
||||
|
||||
/** Extract useTranslations("ns") and getTranslations("ns") calls */
|
||||
function extractNamespaces(source) {
|
||||
const namespaces = [];
|
||||
const reNs = /(?:useTranslations|getTranslations)\s*\(\s*["'`]([^"'`]+)["'`]\s*\)/g;
|
||||
for (const m of source.matchAll(reNs)) {
|
||||
namespaces.push(m[1]);
|
||||
}
|
||||
// Without arg: bare, key must be fully qualified
|
||||
if (/(?:useTranslations|getTranslations)\s*\(\s*\)/.test(source)) namespaces.push(null);
|
||||
return namespaces;
|
||||
}
|
||||
|
||||
/** Extract every t("key", ...) and t.rich("key", ...) — capture the literal key only */
|
||||
function extractTCalls(source) {
|
||||
const out = new Set();
|
||||
// t("..."), t('...') — match only quoted string literals (skip template literals)
|
||||
const re = /\bt(?:Or[A-Z][A-Za-z]*)?(?:\.\w+)?\s*\(\s*["']([^"']+)["']/g;
|
||||
for (const m of source.matchAll(re)) out.add(m[1]);
|
||||
// translateOrFallback("key", fallback) — also t("key") style
|
||||
for (const m of source.matchAll(/translateOrFallback\s*\(\s*["']([^"']+)["']/g)) {
|
||||
out.add(m[1]);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find candidate hardcoded user-visible strings in JSX.
|
||||
*
|
||||
* Patterns we detect:
|
||||
* A. JSX text: >Some text< (must contain a letter, length >= 2)
|
||||
* B. JSX attrs: title="..." placeholder="..." aria-label="..." alt="..."
|
||||
* C. Toast/error literals: toast.error("...") toast.success("...")
|
||||
*
|
||||
* We DELIBERATELY skip:
|
||||
* - strings inside {t(...)} or {tOrFallback(...)}
|
||||
* - strings inside import statements
|
||||
* - strings inside CSS class names (className/style)
|
||||
* - URLs (start with /, http, mailto:, #)
|
||||
* - All-uppercase/snake constants (CONSTANT_VAR)
|
||||
* - pure-symbol strings (no letters)
|
||||
* - lonely-word JSX text that is a console.log / pino call argument
|
||||
*/
|
||||
// TS / JS noise that the naive regex picks up between two `>` chars
|
||||
const TS_TYPE_NOISE = new Set([
|
||||
"Promise",
|
||||
"Record",
|
||||
"Map",
|
||||
"Set",
|
||||
"Array",
|
||||
"ReadonlyArray",
|
||||
"Partial",
|
||||
"Pick",
|
||||
"Omit",
|
||||
"Required",
|
||||
"Readonly",
|
||||
"Awaited",
|
||||
"ReturnType",
|
||||
"Parameters",
|
||||
"void | Promise",
|
||||
"Promise | undefined",
|
||||
]);
|
||||
|
||||
function findHardcodedStrings(source) {
|
||||
const findings = [];
|
||||
const lines = source.split("\n");
|
||||
|
||||
// (A) JSX text between > and < — fragile but good enough for an audit pass
|
||||
let lineIdx = 0;
|
||||
for (const line of lines) {
|
||||
lineIdx++;
|
||||
// Skip obvious non-JSX lines (imports, single-line comments, JSDoc lines)
|
||||
if (
|
||||
/^\s*(import|export\s+(type|interface)|\/\/|\*|\/\*|type\s+\w+\s*=|interface\s+)/.test(line)
|
||||
)
|
||||
continue;
|
||||
// Skip lines that are *clearly* TypeScript signatures
|
||||
if (
|
||||
/:\s*(Promise|Record|Map|Set|Array|Partial|Pick|Omit|Required|Readonly|Awaited)\s*</.test(
|
||||
line
|
||||
)
|
||||
)
|
||||
continue;
|
||||
// Neutralise arrow functions and TS generics that confuse the regex
|
||||
const safe = line
|
||||
.replace(/=>/g, "__ARROW__")
|
||||
.replace(/<\/?\w[\w.-]*\s*\/?>/g, (m) => m) // keep JSX tags
|
||||
.replace(/<\w[\w.,?:|\s]*?>/g, "__GEN__"); // strip TS generics like <T> or <T, U>
|
||||
|
||||
let m;
|
||||
const re = />([^<>{}\n]+)</g;
|
||||
while ((m = re.exec(safe)) !== null) {
|
||||
const raw = m[1].trim();
|
||||
if (!raw) continue;
|
||||
if (!/[A-Za-zÀ-ÿ一-鿿Ѐ-ӿ--ۿ]/.test(raw)) continue;
|
||||
if (raw.length < 2) continue;
|
||||
if (raw.startsWith("{") || raw.endsWith("}")) continue;
|
||||
if (/^[A-Z0-9_]+$/.test(raw)) continue; // CONSTANT
|
||||
if (/^(https?:\/\/|\/|mailto:|#)/.test(raw)) continue; // URL/path
|
||||
if (/^[a-z][a-z_0-9]*$/.test(raw) && raw.length < 22) continue; // icon/id slug
|
||||
if (TS_TYPE_NOISE.has(raw)) continue;
|
||||
// operator/expression debris like "0 && foo.size", "x !== y"
|
||||
if (/(&&|\|\||==|!=|<=|>=|=>|\?\s*:|\?\.|\.\w+\()/.test(raw)) continue;
|
||||
// pure number or simple variable.member with no spaces
|
||||
if (/^[\w.]+$/.test(raw) && !raw.includes(" ")) continue;
|
||||
findings.push({ kind: "jsx-text", line: lineIdx, value: raw });
|
||||
}
|
||||
}
|
||||
|
||||
// (B) JSX attributes that are user-visible
|
||||
const attrRe = /\b(title|placeholder|aria-label|alt|label)\s*=\s*["'`]([^"'`{}\n]{2,})["'`]/g;
|
||||
lineIdx = 0;
|
||||
for (const line of lines) {
|
||||
lineIdx++;
|
||||
if (/^\s*(import|\/\/|\*|\/\*)/.test(line)) continue;
|
||||
let m;
|
||||
const re = new RegExp(attrRe.source, "g");
|
||||
while ((m = re.exec(line)) !== null) {
|
||||
const value = m[2].trim();
|
||||
if (!/[A-Za-zÀ-ÿ一-鿿]/.test(value)) continue;
|
||||
if (/^[a-z][a-z_0-9-]*$/.test(value) && value.length < 16) continue; // probably an id/key
|
||||
findings.push({ kind: `attr:${m[1]}`, line: lineIdx, value });
|
||||
}
|
||||
}
|
||||
|
||||
// (C) toast.* and Error() arguments that look like user copy (length > 5, has a space)
|
||||
lineIdx = 0;
|
||||
const callRe =
|
||||
/\b(toast\.(error|success|info|warn|warning|message))\s*\(\s*["'`]([^"'`]{3,})["'`]/g;
|
||||
for (const line of lines) {
|
||||
lineIdx++;
|
||||
let m;
|
||||
const re = new RegExp(callRe.source, "g");
|
||||
while ((m = re.exec(line)) !== null) {
|
||||
findings.push({ kind: `toast:${m[2]}`, line: lineIdx, value: m[3].trim() });
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
/** Find t() calls whose key does NOT exist in en.json */
|
||||
function findMissingKeys(namespaces, tKeys) {
|
||||
const missing = [];
|
||||
for (const key of tKeys) {
|
||||
// A key may itself contain dots — handle namespace dotted into key
|
||||
let found = false;
|
||||
for (const ns of namespaces) {
|
||||
if (keyExists(ns, key)) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Also accept fully qualified bare-namespace keys (like "common.foo" with no useTranslations)
|
||||
if (!found && key.includes(".")) {
|
||||
const [maybeNs, ...rest] = key.split(".");
|
||||
if (keyExists(maybeNs, rest.join("."))) found = true;
|
||||
}
|
||||
if (!found) missing.push(key);
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
const files = walkTsx(DASHBOARD_ROOT);
|
||||
const report = [];
|
||||
|
||||
for (const file of files) {
|
||||
const rel = relative(REPO_ROOT, file);
|
||||
const src = readFileSync(file, "utf8");
|
||||
const namespaces = extractNamespaces(src);
|
||||
const tCalls = extractTCalls(src);
|
||||
const hardcoded = findHardcodedStrings(src);
|
||||
const missing = findMissingKeys(namespaces.length ? namespaces : [null], tCalls);
|
||||
// Only include files that have ANY user-visible content
|
||||
if (!namespaces.length && !tCalls.length && !hardcoded.length) continue;
|
||||
report.push({
|
||||
file: rel,
|
||||
namespaces,
|
||||
tCallCount: tCalls.length,
|
||||
missingKeys: missing,
|
||||
hardcodedCount: hardcoded.length,
|
||||
hardcoded,
|
||||
});
|
||||
}
|
||||
|
||||
writeFileSync(OUT_PATH, JSON.stringify(report, null, 2));
|
||||
|
||||
// Human summary
|
||||
let totalMissing = 0;
|
||||
let totalHardcoded = 0;
|
||||
console.log("# i18n audit — dashboard pages\n");
|
||||
console.log(`Scanned ${report.length} files with user-visible content.\n`);
|
||||
const onlyIssues = report
|
||||
.map((r) => ({ ...r, total: r.missingKeys.length + r.hardcoded.length }))
|
||||
.filter((r) => r.total > 0)
|
||||
.sort((a, b) => b.total - a.total);
|
||||
|
||||
for (const r of onlyIssues) {
|
||||
totalMissing += r.missingKeys.length;
|
||||
totalHardcoded += r.hardcoded.length;
|
||||
console.log(`\n## ${r.file}`);
|
||||
console.log(` ns=${JSON.stringify(r.namespaces)} t() calls=${r.tCallCount}`);
|
||||
if (r.missingKeys.length) {
|
||||
console.log(` ✗ missing keys (${r.missingKeys.length}):`);
|
||||
for (const k of r.missingKeys) console.log(` - ${k}`);
|
||||
}
|
||||
if (r.hardcoded.length) {
|
||||
console.log(` ✗ hardcoded strings (${r.hardcoded.length}):`);
|
||||
for (const h of r.hardcoded.slice(0, 30)) {
|
||||
console.log(` L${h.line} [${h.kind}] ${JSON.stringify(h.value)}`);
|
||||
}
|
||||
if (r.hardcoded.length > 30) console.log(` ... (+${r.hardcoded.length - 30} more)`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n# Summary`);
|
||||
console.log(`Files with issues: ${onlyIssues.length}`);
|
||||
console.log(`Missing keys total: ${totalMissing}`);
|
||||
console.log(`Hardcoded strings: ${totalHardcoded}`);
|
||||
console.log(`Report file: ${relative(REPO_ROOT, OUT_PATH)}`);
|
||||
104
scripts/i18n/build-pending-from-missing.mjs
Normal file
104
scripts/i18n/build-pending-from-missing.mjs
Normal file
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* For every missing key in _audit.json, locate its English value by
|
||||
* 1) finding the t("key") call site in the file
|
||||
* 2) grabbing the line(s) immediately around it in the HEAD version of the file
|
||||
*
|
||||
* This rebuilds a complete _pending-keys.json after subagents have already
|
||||
* rewritten .tsx files but en.json edits were lost.
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const audit = JSON.parse(readFileSync("scripts/i18n/_audit.json", "utf8"));
|
||||
|
||||
function loadHead(file) {
|
||||
try {
|
||||
return execSync(`git show "HEAD:${file}"`, {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const SKIP = new Set([
|
||||
"templatePayloads.vision.system",
|
||||
"templatePayloads.vision.userPrompt",
|
||||
"templatePayloads.vision.imageUrl",
|
||||
"templatePayloads.schemaCoercion.userPrompt",
|
||||
"templatePayloads.schemaCoercion.toolDescription",
|
||||
"templatePayloads.schemaCoercion.cityDescription",
|
||||
]);
|
||||
|
||||
/** Find the line in NEW source where t("key") is used, then derive English value from HEAD */
|
||||
function valueForKey(file, key) {
|
||||
const cur = readFileSync(file, "utf8").split("\n");
|
||||
const head = loadHead(file)?.split("\n") ?? [];
|
||||
// String search avoids ReDoS — key is our own audit data but better safe
|
||||
const needle1 = `t("${key}")`;
|
||||
const needle2 = `t('${key}')`;
|
||||
for (let i = 0; i < cur.length; i++) {
|
||||
const line = cur[i];
|
||||
if (!line.includes(needle1) && !line.includes(needle2)) continue;
|
||||
// The original line in HEAD should be similar around the same area
|
||||
// Find the most-recently corresponding HEAD line: scan backwards and forwards from i
|
||||
const probe = [i, i - 1, i + 1, i - 2, i + 2, i - 3, i + 3];
|
||||
for (const j of probe) {
|
||||
if (j < 0 || j >= head.length) continue;
|
||||
const line = head[j];
|
||||
// Try to extract the English literal: between > <, or attribute value
|
||||
const jsxMatch = line.match(/>([^<>{}\n]{2,})</);
|
||||
if (jsxMatch && !jsxMatch[1].includes("{") && !jsxMatch[1].includes("=>")) {
|
||||
const v = jsxMatch[1].trim();
|
||||
if (v && /[A-Za-z]/.test(v) && v !== key) return v;
|
||||
}
|
||||
const attrMatch = line.match(
|
||||
/\b(?:title|placeholder|aria-label|alt|label)\s*=\s*["']([^"']+)["']/
|
||||
);
|
||||
if (attrMatch) {
|
||||
const v = attrMatch[1].trim();
|
||||
if (v && /[A-Za-z]/.test(v) && v !== key) return v;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const inferredNamespaces = new Map();
|
||||
function ensureNs(ns) {
|
||||
if (!inferredNamespaces.has(ns)) inferredNamespaces.set(ns, {});
|
||||
return inferredNamespaces.get(ns);
|
||||
}
|
||||
|
||||
for (const entry of audit) {
|
||||
if (!entry.missingKeys.length) continue;
|
||||
const ns = entry.namespaces[0];
|
||||
if (!ns) continue; // exampleTemplates etc. — skip
|
||||
const target = ensureNs(ns);
|
||||
for (const key of entry.missingKeys) {
|
||||
if (SKIP.has(key)) continue;
|
||||
if (target[key]) continue;
|
||||
const value = valueForKey(entry.file, key);
|
||||
if (value) {
|
||||
target[key] = value;
|
||||
} else {
|
||||
// Could not infer — leave a TODO marker so we notice
|
||||
target[key] = `__TODO__${key}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const out = Object.fromEntries(inferredNamespaces);
|
||||
writeFileSync("scripts/i18n/_pending-keys.json", JSON.stringify(out, null, 2) + "\n");
|
||||
|
||||
let total = 0;
|
||||
let todo = 0;
|
||||
for (const [ns, keys] of Object.entries(out)) {
|
||||
const cnt = Object.keys(keys).length;
|
||||
total += cnt;
|
||||
for (const v of Object.values(keys)) if (String(v).startsWith("__TODO__")) todo++;
|
||||
console.log(`${ns}: ${cnt} keys`);
|
||||
}
|
||||
console.log(`\nTotal: ${total} keys (${todo} need manual resolution)`);
|
||||
130
scripts/i18n/extract-keys-from-diff.mjs
Normal file
130
scripts/i18n/extract-keys-from-diff.mjs
Normal file
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Extract NEW i18n keys created by subagents from git diff.
|
||||
*
|
||||
* For each modified .tsx file, walk the unified diff and pair "-" lines
|
||||
* (containing literal English text) with their following "+" lines
|
||||
* (now using `t("key")`). When we find a stable pairing, record the key
|
||||
* and its original English value.
|
||||
*
|
||||
* The output is a per-namespace map ready to merge into en.json.
|
||||
*/
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const diff = execSync('git diff --unified=0 "src/app/(dashboard)"', {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const blocks = diff.split(/^diff --git /m).slice(1);
|
||||
|
||||
const allPairs = []; // { file, removed, added }
|
||||
|
||||
for (const block of blocks) {
|
||||
const headLine = block.split("\n")[0];
|
||||
const fileMatch = headLine.match(/b\/(.+?)$/);
|
||||
const file = fileMatch ? fileMatch[1] : "?";
|
||||
const lines = block.split("\n");
|
||||
|
||||
// Walk in groups: consecutive "-" lines, then consecutive "+" lines.
|
||||
// Pair them positionally (removed[i] ↔ added[i]).
|
||||
let removed = [];
|
||||
let added = [];
|
||||
function flush() {
|
||||
const n = Math.min(removed.length, added.length);
|
||||
for (let i = 0; i < n; i++) allPairs.push({ file, removed: removed[i], added: added[i] });
|
||||
// If removed.length > added.length, pair remaining removed with last added (multi-string in one new line)
|
||||
if (removed.length > added.length && added.length > 0) {
|
||||
for (let i = added.length; i < removed.length; i++) {
|
||||
allPairs.push({ file, removed: removed[i], added: added[added.length - 1] });
|
||||
}
|
||||
}
|
||||
removed = [];
|
||||
added = [];
|
||||
}
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("---") || line.startsWith("+++")) continue;
|
||||
if (line.startsWith("-")) {
|
||||
if (added.length) flush();
|
||||
removed.push(line.slice(1));
|
||||
} else if (line.startsWith("+")) {
|
||||
added.push(line.slice(1));
|
||||
} else {
|
||||
flush();
|
||||
}
|
||||
}
|
||||
flush();
|
||||
}
|
||||
|
||||
/** Patterns inside JSX or attribute strings */
|
||||
const T_CALL_RE = /\bt\(\s*["']([^"']+)["']\s*\)/g;
|
||||
const JSX_TEXT_RE = />([^<>{}\n]+)</g;
|
||||
const ATTR_RE = /\b(?:title|placeholder|aria-label|alt|label)\s*=\s*["']([^"']+)["']/g;
|
||||
|
||||
const newKeys = new Map(); // key -> { value, file }
|
||||
|
||||
function recordKey(key, value, file) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
// Ignore if value contains JSX expression syntax — those were dynamic
|
||||
if (/^\{|\}$/.test(trimmed)) return;
|
||||
if (!newKeys.has(key)) {
|
||||
newKeys.set(key, { value: trimmed, file });
|
||||
}
|
||||
}
|
||||
|
||||
for (const { file, removed, added } of allPairs) {
|
||||
// Extract every t("xxx") key from the "added" line
|
||||
const tKeys = [...added.matchAll(T_CALL_RE)].map((m) => m[1]);
|
||||
if (!tKeys.length) continue;
|
||||
|
||||
// Extract candidate strings from the "removed" line: JSX text + attr values
|
||||
const candidates = [];
|
||||
for (const m of removed.matchAll(JSX_TEXT_RE)) candidates.push(m[1]);
|
||||
for (const m of removed.matchAll(ATTR_RE)) candidates.push(m[1]);
|
||||
|
||||
// Direct strings without surrounding markup (rare)
|
||||
const stripped = removed
|
||||
.replace(/<[^<>]+>/g, "")
|
||||
.replace(/\bt\([^)]*\)/g, "")
|
||||
.trim();
|
||||
if (stripped && /[A-Za-z]/.test(stripped)) candidates.push(stripped);
|
||||
|
||||
// For each tKey in added, try to align with the candidate at the same index.
|
||||
// The agents typically replaced strings in the same left-to-right order.
|
||||
for (let i = 0; i < tKeys.length; i++) {
|
||||
const candidate = candidates[i] ?? candidates[candidates.length - 1];
|
||||
if (!candidate) continue;
|
||||
recordKey(tKeys[i], candidate, file);
|
||||
}
|
||||
}
|
||||
|
||||
// Group by file's primary namespace via useTranslations() call in file
|
||||
import { readFileSync } from "node:fs";
|
||||
function inferNamespaceForFile(file) {
|
||||
try {
|
||||
const src = readFileSync(file, "utf8");
|
||||
const m = src.match(/useTranslations\s*\(\s*["']([^"']+)["']\s*\)/);
|
||||
return m?.[1] ?? "common";
|
||||
} catch {
|
||||
return "common";
|
||||
}
|
||||
}
|
||||
|
||||
const byNamespace = new Map();
|
||||
for (const [key, { value, file }] of newKeys) {
|
||||
const ns = inferNamespaceForFile(file);
|
||||
if (!byNamespace.has(ns)) byNamespace.set(ns, []);
|
||||
byNamespace.get(ns).push({ key, value });
|
||||
}
|
||||
|
||||
for (const [ns, items] of byNamespace) {
|
||||
console.log(`\nNEW_KEYS_FOR_NAMESPACE: ${ns}`);
|
||||
console.log("{");
|
||||
for (const { key, value } of items) {
|
||||
// Escape value for JSON
|
||||
console.log(` ${JSON.stringify(key)}: ${JSON.stringify(value)},`);
|
||||
}
|
||||
console.log("}");
|
||||
}
|
||||
console.log(`\nTotal extracted: ${newKeys.size} keys across ${byNamespace.size} namespaces`);
|
||||
38
scripts/i18n/merge-keys.mjs
Normal file
38
scripts/i18n/merge-keys.mjs
Normal file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Merge keys from scripts/i18n/_pending-keys.json into src/i18n/messages/en.json
|
||||
*
|
||||
* Format of _pending-keys.json:
|
||||
* { "namespace": { "key": "value", ... }, ... }
|
||||
*
|
||||
* Keys are appended to the END of each namespace block. Existing keys with
|
||||
* the same name are preserved (we never overwrite).
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const SRC = "src/i18n/messages/en.json";
|
||||
const PENDING = "scripts/i18n/_pending-keys.json";
|
||||
|
||||
const enJson = JSON.parse(readFileSync(SRC, "utf8"));
|
||||
const pending = JSON.parse(readFileSync(PENDING, "utf8"));
|
||||
|
||||
let added = 0;
|
||||
let skipped = 0;
|
||||
for (const [ns, keys] of Object.entries(pending)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(enJson, ns)) {
|
||||
console.warn(`! namespace missing in en.json: ${ns} — skipping`);
|
||||
continue;
|
||||
}
|
||||
const target = enJson[ns];
|
||||
for (const [k, v] of Object.entries(keys)) {
|
||||
if (Object.prototype.hasOwnProperty.call(target, k)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
target[k] = v;
|
||||
added++;
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(SRC, JSON.stringify(enJson, null, 2) + "\n");
|
||||
console.log(`✓ merged ${added} new keys (${skipped} skipped — already present)`);
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
/**
|
||||
* Shown when OmniRoute was started with auto-generated secrets (zero-config mode).
|
||||
* The banner is dismissable and persists only for the current session.
|
||||
*/
|
||||
export default function BootstrapBanner() {
|
||||
const t = useTranslations("common");
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
if (dismissed) return null;
|
||||
@@ -46,7 +48,7 @@ export default function BootstrapBanner() {
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="shrink-0 text-amber-600/60 hover:text-amber-700 dark:text-amber-400/60 dark:hover:text-amber-300 transition-colors ml-1"
|
||||
aria-label="Dismiss"
|
||||
aria-label={t("bootstrapBannerDismiss")}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
@@ -603,7 +603,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
<span className="material-symbols-outlined text-[18px]">check_circle</span>
|
||||
{updateSteps.find((s) => s.step === "complete")?.message || "Update complete!"}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-1">Reloading page automatically...</p>
|
||||
<p className="text-xs text-text-muted mt-1">{t("reloadingPageAutomatically")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -788,7 +788,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">Provider Topology</h2>
|
||||
<h2 className="text-base font-semibold">{t("providerTopology")}</h2>
|
||||
<p className="text-xs text-text-muted">
|
||||
Connected providers routing through OmniRoute in real time
|
||||
</p>
|
||||
|
||||
@@ -65,8 +65,8 @@ export function TierCoverageWidget() {
|
||||
<div className="rounded-xl border border-white/[0.06] bg-surface p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">Tier coverage</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">Providers configured per fallback tier</p>
|
||||
<h3 className="font-semibold text-sm">{t("tierCoverageTitle")}</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">{t("tierCoverageSubtitle")}</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/providers"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/shared/components";
|
||||
import A2ADashboardPage from "../endpoint/components/A2ADashboard";
|
||||
|
||||
@@ -118,6 +119,7 @@ function DisabledPanel() {
|
||||
}
|
||||
|
||||
export default function A2APage() {
|
||||
const t = useTranslations("a2aDashboard");
|
||||
const [a2aStatus, setA2aStatus] = useState<ServiceStatus>({ online: false, loading: true });
|
||||
const [a2aEnabled, setA2aEnabled] = useState(false);
|
||||
const [a2aToggling, setA2aToggling] = useState(false);
|
||||
@@ -192,19 +194,19 @@ export default function A2APage() {
|
||||
Discover the agent card at <code className="text-xs">/.well-known/agent.json</code>.
|
||||
</li>
|
||||
<li>
|
||||
Send JSON-RPC to <code className="text-xs">POST /a2a</code> using{" "}
|
||||
<code className="text-xs">message/send</code> or{" "}
|
||||
<code className="text-xs">message/stream</code>.
|
||||
Send JSON-RPC to <code className="text-xs">{t("rpcEndpoint")}</code> using{" "}
|
||||
<code className="text-xs">{t("rpcMethodSend")}</code> or{" "}
|
||||
<code className="text-xs">{t("rpcMethodStream")}</code>.
|
||||
</li>
|
||||
<li>
|
||||
Track and cancel tasks with <code className="text-xs">tasks/get</code> and{" "}
|
||||
<code className="text-xs">tasks/cancel</code>.
|
||||
Track and cancel tasks with <code className="text-xs">{t("rpcMethodGet")}</code> and{" "}
|
||||
<code className="text-xs">{t("rpcMethodCancel")}</code>.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<ServiceToggle
|
||||
label="A2A"
|
||||
label={t("serviceLabel")}
|
||||
status={a2aStatus}
|
||||
enabled={a2aEnabled}
|
||||
onToggle={() => void toggleA2a()}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
AGENT_SKILLS,
|
||||
AGENT_SKILLS_REPO_URL,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
|
||||
function CopyButton({ url }: { url: string }) {
|
||||
const t = useTranslations("agents");
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
const isCopied = copied === url;
|
||||
|
||||
@@ -21,17 +23,18 @@ function CopyButton({ url }: { url: string }) {
|
||||
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"
|
||||
: "bg-bg-subtle text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
title="Copy raw URL to clipboard"
|
||||
title={t("copyRawUrlTitle")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{isCopied ? "check" : "content_copy"}
|
||||
</span>
|
||||
{isCopied ? "Copied!" : "Copy URL"}
|
||||
{isCopied ? t("copied") : t("copyUrl")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillRow({ skill }: { skill: AgentSkill }) {
|
||||
const t = useTranslations("agents");
|
||||
const rawUrl = getAgentSkillRawUrl(skill.id);
|
||||
const blobUrl = getAgentSkillBlobUrl(skill.id);
|
||||
|
||||
@@ -46,12 +49,12 @@ function SkillRow({ skill }: { skill: AgentSkill }) {
|
||||
<span className="text-sm font-semibold text-text-main">{skill.name}</span>
|
||||
{skill.isEntry && (
|
||||
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary">
|
||||
Start Here
|
||||
{t("startHere")}
|
||||
</span>
|
||||
)}
|
||||
{skill.isNew && (
|
||||
<span className="rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-amber-700 dark:text-amber-400">
|
||||
New
|
||||
{t("badgeNew")}
|
||||
</span>
|
||||
)}
|
||||
{skill.endpoint && (
|
||||
@@ -69,7 +72,7 @@ function SkillRow({ skill }: { skill: AgentSkill }) {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium text-text-muted transition-colors hover:bg-bg-subtle hover:text-text-main"
|
||||
title="View on GitHub"
|
||||
title={t("viewOnGithub")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
|
||||
</a>
|
||||
@@ -109,6 +112,7 @@ function SkillSection({
|
||||
}
|
||||
|
||||
export default function AgentSkillsPage() {
|
||||
const t = useTranslations("agents");
|
||||
const apiSkills = AGENT_SKILLS.filter((s) => s.category === "api");
|
||||
const cliSkills = AGENT_SKILLS.filter((s) => s.category === "cli");
|
||||
|
||||
@@ -118,12 +122,12 @@ export default function AgentSkillsPage() {
|
||||
<div className="rounded-xl border border-border bg-bg-subtle/50 p-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-primary">info</span>
|
||||
<span className="text-sm font-semibold text-text-main">How to use</span>
|
||||
<span className="text-sm font-semibold text-text-main">{t("howToUse")}</span>
|
||||
</div>
|
||||
<ol className="space-y-1 text-xs text-text-muted">
|
||||
<li>
|
||||
1. Click <strong className="text-text-main">Copy URL</strong> on the skill you want your
|
||||
agent to know about.
|
||||
1. Click <strong className="text-text-main">{t("copyUrl")}</strong> on the skill you
|
||||
want your agent to know about.
|
||||
</li>
|
||||
<li>
|
||||
2. In your AI agent (Claude, Cursor, Cline…), say:
|
||||
@@ -144,20 +148,20 @@ export default function AgentSkillsPage() {
|
||||
className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
|
||||
Browse all skills on GitHub
|
||||
{t("browseAllSkillsOnGithub")}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Two-column grid: API Skills | CLI Skills */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<SkillSection
|
||||
title="API Skills"
|
||||
title={t("apiSkills")}
|
||||
subtitle={`${apiSkills.length} skills — control OmniRoute via REST / HTTP`}
|
||||
icon="api"
|
||||
skills={apiSkills}
|
||||
/>
|
||||
<SkillSection
|
||||
title="CLI Skills"
|
||||
title={t("cliSkills")}
|
||||
subtitle={`${cliSkills.length} skills — control OmniRoute via the omniroute terminal binary`}
|
||||
icon="terminal"
|
||||
skills={cliSkills}
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function AutoRoutingAnalyticsTab() {
|
||||
<span className="material-symbols-outlined text-[20px]">auto_awesome</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-text-muted">Total Auto Requests</p>
|
||||
<p className="text-sm text-text-muted">{t("autoRoutingTotalAutoRequests")}</p>
|
||||
<p className="text-2xl font-bold">{stats.totalRequests.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,7 +71,7 @@ export default function AutoRoutingAnalyticsTab() {
|
||||
<span className="material-symbols-outlined text-[20px]">target</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-text-muted">Avg Selection Score</p>
|
||||
<p className="text-sm text-text-muted">{t("autoRoutingAvgSelectionScore")}</p>
|
||||
<p className="text-2xl font-bold">{(stats.avgSelectionScore * 100).toFixed(1)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,7 +83,7 @@ export default function AutoRoutingAnalyticsTab() {
|
||||
<span className="material-symbols-outlined text-[20px]">explore</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-text-muted">Exploration Rate</p>
|
||||
<p className="text-sm text-text-muted">{t("autoRoutingExplorationRate")}</p>
|
||||
<p className="text-2xl font-bold">{(stats.explorationRate * 100).toFixed(1)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,7 +95,7 @@ export default function AutoRoutingAnalyticsTab() {
|
||||
<span className="material-symbols-outlined text-[20px]">history</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-text-muted">LKGP Hit Rate</p>
|
||||
<p className="text-sm text-text-muted">{t("autoRoutingLkgpHitRate")}</p>
|
||||
<p className="text-2xl font-bold">{(stats.lkgpHitRate * 100).toFixed(1)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,7 +104,7 @@ export default function AutoRoutingAnalyticsTab() {
|
||||
|
||||
{/* Variant Breakdown */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Requests by Variant</h3>
|
||||
<h3 className="text-lg font-semibold mb-4">{t("autoRoutingRequestsByVariant")}</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(stats.variantBreakdown).map(([variant, count]) => {
|
||||
const percentage = stats.totalRequests > 0 ? (count / stats.totalRequests) * 100 : 0;
|
||||
@@ -128,7 +128,7 @@ export default function AutoRoutingAnalyticsTab() {
|
||||
|
||||
{/* Top Providers */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Top Routed Providers</h3>
|
||||
<h3 className="text-lg font-semibold mb-4">{t("autoRoutingTopRoutedProviders")}</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Card from "@/shared/components/Card";
|
||||
import Badge from "@/shared/components/Badge";
|
||||
import { Skeleton, Spinner } from "@/shared/components/Loading";
|
||||
@@ -92,6 +93,7 @@ function DistributionBar({ label, value, meta }: { label: string; value: number;
|
||||
}
|
||||
|
||||
function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
|
||||
const t = useTranslations("analytics");
|
||||
const sortedDistribution = useMemo(
|
||||
() =>
|
||||
[...combo.usageSkew.modelDistribution].sort(
|
||||
@@ -119,18 +121,18 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 lg:min-w-[420px]">
|
||||
<MetricBlock
|
||||
icon="battery_status_good"
|
||||
label="Worst quota left"
|
||||
label={t("comboHealthWorstQuotaLeft")}
|
||||
value={formatPercent(combo.quotaHealth.worstRemainingPct)}
|
||||
/>
|
||||
<MetricBlock
|
||||
icon="balance"
|
||||
label="Usage skew"
|
||||
label={t("comboHealthUsageSkew")}
|
||||
value={combo.usageSkew.giniCoefficient.toFixed(2)}
|
||||
subValue="Gini coefficient"
|
||||
/>
|
||||
<MetricBlock
|
||||
icon="bolt"
|
||||
label="Success rate"
|
||||
label={t("comboHealthSuccessRate")}
|
||||
value={formatPercent(combo.performance.successRate * 100, 1)}
|
||||
subValue={`${combo.performance.totalRequests.toLocaleString()} requests`}
|
||||
/>
|
||||
@@ -141,7 +143,9 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
|
||||
<div className="grid gap-6 px-6 py-5 xl:grid-cols-[1.1fr_1fr_0.95fr]">
|
||||
<section className="flex flex-col gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-text-main">Quota health</div>
|
||||
<div className="text-sm font-semibold text-text-main">
|
||||
{t("comboHealthQuotaHealth")}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-text-muted">
|
||||
Lowest remaining quota across providers with short trend signals.
|
||||
</div>
|
||||
@@ -189,7 +193,7 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-text-main">Usage skew</div>
|
||||
<div className="text-sm font-semibold text-text-main">{t("comboHealthUsageSkew")}</div>
|
||||
<div className="mt-1 text-xs text-text-muted">
|
||||
Model request share and token share within this combo.
|
||||
</div>
|
||||
@@ -214,12 +218,12 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
|
||||
|
||||
<div className="mt-3 grid gap-2">
|
||||
<DistributionBar
|
||||
label="Requests"
|
||||
label={t("comboHealthRequests")}
|
||||
value={entry.requestShare}
|
||||
meta={formatShare(entry.requestShare)}
|
||||
/>
|
||||
<DistributionBar
|
||||
label="Tokens"
|
||||
label={t("comboHealthTokens")}
|
||||
value={entry.tokenShare}
|
||||
meta={formatShare(entry.tokenShare)}
|
||||
/>
|
||||
@@ -240,17 +244,17 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
|
||||
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||
<MetricBlock
|
||||
icon="timer"
|
||||
label="Avg latency"
|
||||
label={t("comboHealthAvgLatency")}
|
||||
value={formatLatency(combo.performance.avgLatencyMs)}
|
||||
/>
|
||||
<MetricBlock
|
||||
icon="task_alt"
|
||||
label="Success rate"
|
||||
label={t("comboHealthSuccessRate")}
|
||||
value={formatPercent(combo.performance.successRate * 100, 1)}
|
||||
/>
|
||||
<MetricBlock
|
||||
icon="stacked_line_chart"
|
||||
label="Total requests"
|
||||
label={t("comboHealthTotalRequests")}
|
||||
value={combo.performance.totalRequests.toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
@@ -260,7 +264,9 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
|
||||
{targetHealth.length > 0 ? (
|
||||
<div className="border-t border-black/5 px-6 py-5 dark:border-white/5">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-text-main">Execution targets</div>
|
||||
<div className="text-sm font-semibold text-text-main">
|
||||
{t("comboHealthExecutionTargets")}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-text-muted">
|
||||
Step-level runtime metrics and quota visibility for structured combo targets.
|
||||
</div>
|
||||
@@ -297,17 +303,17 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
|
||||
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-3">
|
||||
<DistributionBar
|
||||
label="Success"
|
||||
label={t("comboHealthSuccess")}
|
||||
value={Math.max(target.successRate, 0) / 100}
|
||||
meta={formatPercent(target.successRate, 0)}
|
||||
/>
|
||||
<DistributionBar
|
||||
label="Latency"
|
||||
label={t("comboHealthLatency")}
|
||||
value={target.avgLatencyMs > 0 ? 1 : 0}
|
||||
meta={formatLatency(target.avgLatencyMs)}
|
||||
/>
|
||||
<DistributionBar
|
||||
label="Quota"
|
||||
label={t("comboHealthQuota")}
|
||||
value={Math.max(target.quotaRemainingPct || 0, 0) / 100}
|
||||
meta={formatPercentOrDash(target.quotaRemainingPct)}
|
||||
/>
|
||||
@@ -361,6 +367,7 @@ function ComboHealthSkeleton() {
|
||||
}
|
||||
|
||||
export default function ComboHealthTab() {
|
||||
const t = useTranslations("analytics");
|
||||
const [range, setRange] = useState<UtilizationTimeRange>("24h");
|
||||
const [data, setData] = useState<ComboHealthResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -421,7 +428,7 @@ export default function ComboHealthTab() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4 rounded-xl border border-black/5 bg-surface p-5 shadow-sm dark:border-white/5 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-main">Combo health</h2>
|
||||
<h2 className="text-lg font-semibold text-text-main">{t("comboHealthTitle")}</h2>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
Monitor quota pressure, skewed model usage, and delivery performance by combo.
|
||||
</p>
|
||||
@@ -436,7 +443,7 @@ export default function ComboHealthTab() {
|
||||
<div className="flex flex-col items-center justify-center gap-4 text-center">
|
||||
<span className="material-symbols-outlined text-[40px] text-error">sync_problem</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-medium text-text-main">Unable to load combo health</div>
|
||||
<div className="font-medium text-text-main">{t("comboHealthUnableToLoad")}</div>
|
||||
<div className="text-sm text-text-muted">{error}</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -477,7 +484,7 @@ export default function ComboHealthTab() {
|
||||
flowing.
|
||||
</div>
|
||||
<div className="rounded-lg border border-black/5 bg-black/[0.02] p-4 dark:border-white/5 dark:bg-white/[0.02]">
|
||||
<p className="text-xs font-medium text-text-main">Getting started</p>
|
||||
<p className="text-xs font-medium text-text-main">{t("comboHealthGettingStarted")}</p>
|
||||
<ul className="mt-2 text-left text-xs text-text-muted">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-[14px] text-primary">
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface CompressionAnalyticsSummary {
|
||||
totalRequests: number;
|
||||
@@ -116,6 +117,7 @@ function ProviderBar({
|
||||
}
|
||||
|
||||
export default function CompressionAnalyticsTab() {
|
||||
const t = useTranslations("analytics");
|
||||
const [stats, setStats] = useState<CompressionAnalyticsSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -192,25 +194,33 @@ export default function CompressionAnalyticsTab() {
|
||||
<div className="grid grid-cols-2 md:grid-cols-6 gap-4">
|
||||
<StatCard
|
||||
icon="compress"
|
||||
label="Total Requests"
|
||||
label={t("compressionAnalyticsTotalRequests")}
|
||||
value={stats.totalRequests.toLocaleString()}
|
||||
/>
|
||||
<StatCard
|
||||
icon="token"
|
||||
label="Tokens Saved"
|
||||
label={t("compressionAnalyticsTokensSaved")}
|
||||
value={stats.totalTokensSaved.toLocaleString()}
|
||||
/>
|
||||
<StatCard icon="percent" label="Avg Savings" value={`${stats.avgSavingsPct}%`} />
|
||||
<StatCard icon="timer" label="Avg Duration" value={`${stats.avgDurationMs}ms`} />
|
||||
<StatCard
|
||||
icon="percent"
|
||||
label={t("compressionAnalyticsAvgSavings")}
|
||||
value={`${stats.avgSavingsPct}%`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="timer"
|
||||
label={t("compressionAnalyticsAvgDuration")}
|
||||
value={`${stats.avgDurationMs}ms`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="receipt_long"
|
||||
label="Receipts"
|
||||
label={t("compressionAnalyticsReceipts")}
|
||||
value={stats.realUsage.requestsWithReceipts.toLocaleString()}
|
||||
sub={`${stats.realUsage.totalTokens.toLocaleString()} real tokens`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="verified"
|
||||
label="Fallbacks"
|
||||
label={t("compressionAnalyticsFallbacks")}
|
||||
value={stats.validationFallbacks.toLocaleString()}
|
||||
sub="validation restores"
|
||||
/>
|
||||
@@ -224,25 +234,25 @@ export default function CompressionAnalyticsTab() {
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-text-muted">Prompt tokens</div>
|
||||
<div className="text-text-muted">{t("compressionAnalyticsPromptTokens")}</div>
|
||||
<div className="text-lg font-semibold text-text">
|
||||
{stats.realUsage.promptTokens.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-text-muted">Completion tokens</div>
|
||||
<div className="text-text-muted">{t("compressionAnalyticsCompletionTokens")}</div>
|
||||
<div className="text-lg font-semibold text-text">
|
||||
{stats.realUsage.completionTokens.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-text-muted">Total tokens</div>
|
||||
<div className="text-text-muted">{t("compressionAnalyticsTotalTokens")}</div>
|
||||
<div className="text-lg font-semibold text-text">
|
||||
{stats.realUsage.totalTokens.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-text-muted">Cache tokens</div>
|
||||
<div className="text-text-muted">{t("compressionAnalyticsCacheTokens")}</div>
|
||||
<div className="text-lg font-semibold text-text">
|
||||
{(
|
||||
(stats.realUsage.cacheReadTokens ?? 0) + (stats.realUsage.cacheWriteTokens ?? 0)
|
||||
@@ -345,7 +355,7 @@ export default function CompressionAnalyticsTab() {
|
||||
<span className="material-symbols-outlined text-[48px] mb-3 block text-primary opacity-50">
|
||||
compress
|
||||
</span>
|
||||
<p className="font-medium text-text">No compression data yet</p>
|
||||
<p className="font-medium text-text">{t("compressionAnalyticsNoDataYet")}</p>
|
||||
<p className="text-sm mt-1">
|
||||
Use <code className="bg-bg-muted px-1 rounded">POST /v1/chat/completions</code> with
|
||||
compression configuration to start tracking compression analytics.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
CartesianGrid,
|
||||
@@ -89,6 +90,7 @@ function getLatestPoints(points: ProviderUtilizationPoint[]) {
|
||||
}
|
||||
|
||||
export default function ProviderUtilizationTab() {
|
||||
const t = useTranslations("analytics");
|
||||
const [range, setRange] = useState<UtilizationTimeRange>("24h");
|
||||
const [aggregateBy, setAggregateBy] = useState<"provider" | "connection">("provider");
|
||||
const [data, setData] = useState<ProviderUtilizationResponse | null>(null);
|
||||
@@ -191,7 +193,7 @@ export default function ProviderUtilizationTab() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card
|
||||
title="Provider utilization"
|
||||
title={t("providerUtilizationTitle")}
|
||||
subtitle={RANGE_LABELS[range]}
|
||||
icon="monitoring"
|
||||
action={
|
||||
@@ -236,7 +238,9 @@ export default function ProviderUtilizationTab() {
|
||||
<div className="flex min-h-80 flex-col items-center justify-center gap-4 text-center">
|
||||
<span className="material-symbols-outlined text-[32px] text-error">error</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">Failed to load utilization data</p>
|
||||
<p className="text-sm font-medium text-text-main">
|
||||
{t("providerUtilizationFailedToLoad")}
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">{error}</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -266,13 +270,15 @@ export default function ProviderUtilizationTab() {
|
||||
timeline
|
||||
</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium text-text-main">No utilization data available</p>
|
||||
<p className="text-sm font-medium text-text-main">{t("providerUtilizationNoData")}</p>
|
||||
<p className="max-w-md text-sm text-text-muted">
|
||||
Provider quota snapshots will appear here after utilization data is collected.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-black/5 bg-black/[0.02] p-4 dark:border-white/5 dark:bg-white/[0.02]">
|
||||
<p className="text-xs font-medium text-text-main">Getting started</p>
|
||||
<p className="text-xs font-medium text-text-main">
|
||||
{t("providerUtilizationGettingStarted")}
|
||||
</p>
|
||||
<ul className="mt-2 text-left text-xs text-text-muted">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-[14px] text-primary">
|
||||
@@ -369,7 +375,9 @@ export default function ProviderUtilizationTab() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-text-main">{point.provider}</p>
|
||||
<p className="text-xs text-text-muted">Latest quota snapshot</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
{t("providerUtilizationLatestSnapshot")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
@@ -390,7 +398,9 @@ export default function ProviderUtilizationTab() {
|
||||
<p className="text-3xl font-bold text-text-main">
|
||||
{point.remainingPct.toFixed(point.remainingPct < 10 ? 1 : 0)}%
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-text-muted">Remaining capacity</p>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
{t("providerUtilizationRemainingCapacity")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right text-xs text-text-muted">
|
||||
<p>{formatTooltipTimestamp(point.timestamp, range)}</p>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface SearchStats {
|
||||
@@ -76,6 +77,7 @@ function ProviderBar({
|
||||
}
|
||||
|
||||
export default function SearchAnalyticsTab() {
|
||||
const t = useTranslations("analytics");
|
||||
const [stats, setStats] = useState<SearchStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -122,25 +124,25 @@ export default function SearchAnalyticsTab() {
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
icon="manage_search"
|
||||
label="Total Searches"
|
||||
label={t("searchAnalyticsTotalSearches")}
|
||||
value={stats.total.toLocaleString()}
|
||||
sub={`${stats.today} today`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="cached"
|
||||
label="Cache Hit Rate"
|
||||
label={t("searchAnalyticsCacheHitRate")}
|
||||
value={`${stats.cacheHitRate}%`}
|
||||
sub={`${stats.cached} cached requests`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="attach_money"
|
||||
label="Total Cost"
|
||||
label={t("searchAnalyticsTotalCost")}
|
||||
value={`$${stats.totalCostUsd.toFixed(4)}`}
|
||||
sub="search API costs"
|
||||
/>
|
||||
<StatCard
|
||||
icon="timer"
|
||||
label="Avg Response"
|
||||
label={t("searchAnalyticsAvgResponse")}
|
||||
value={`${stats.avgDurationMs}ms`}
|
||||
sub={stats.errors > 0 ? `${stats.errors} errors` : "No errors"}
|
||||
/>
|
||||
@@ -173,7 +175,7 @@ export default function SearchAnalyticsTab() {
|
||||
<span className="material-symbols-outlined text-[48px] mb-3 block text-primary opacity-50">
|
||||
travel_explore
|
||||
</span>
|
||||
<p className="font-medium text-text">No searches yet</p>
|
||||
<p className="font-medium text-text">{t("searchAnalyticsNoSearchesYet")}</p>
|
||||
<p className="text-sm mt-1">
|
||||
Use <code className="bg-bg-muted px-1 rounded">POST /v1/search</code> to start routing
|
||||
web searches.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
|
||||
@@ -15,6 +16,7 @@ interface DiversityReport {
|
||||
}
|
||||
|
||||
export default function DiversityScoreCard() {
|
||||
const t = useTranslations("analytics");
|
||||
const [data, setData] = useState<DiversityReport | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -82,7 +84,7 @@ export default function DiversityScoreCard() {
|
||||
<div className="flex items-center justify-between gap-3 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px] text-primary">pie_chart</span>
|
||||
<h3 className="font-semibold text-text-main">Provider Diversity</h3>
|
||||
<h3 className="font-semibold text-text-main">{t("diversityScoreTitle")}</h3>
|
||||
<span className="text-xs text-text-muted hidden sm:inline">
|
||||
— Provider concentration snapshot for the recent traffic window.
|
||||
</span>
|
||||
|
||||
@@ -1279,7 +1279,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
{/* Max Sessions Limit (T08) */}
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">Max Active Sessions</p>
|
||||
<p className="text-sm font-medium text-text-main">{t("maxActiveSessions")}</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
0 = unlimited. Return 429 when this key exceeds concurrent sticky sessions.
|
||||
</p>
|
||||
@@ -1302,10 +1302,10 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">Custom Rate Limits</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Override global default limits. Leave empty to use defaults.
|
||||
<p className="text-sm font-medium text-text-main">
|
||||
{t("apiManagerCustomRateLimits")}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">{t("apiManagerCustomRateLimitsDesc")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1332,9 +1332,11 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
placeholder="Requests"
|
||||
placeholder={t("apiManagerRateLimitRequestsPlaceholder")}
|
||||
/>
|
||||
<span className="text-sm text-text-muted shrink-0">req /</span>
|
||||
<span className="text-sm text-text-muted shrink-0">
|
||||
{t("apiManagerRateLimitReqPer")}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -1347,14 +1349,14 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
placeholder="Seconds"
|
||||
placeholder={t("apiManagerRateLimitSecondsPlaceholder")}
|
||||
/>
|
||||
<span className="text-sm text-text-muted shrink-0">sec</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRateLimits((prev) => prev.filter((_, i) => i !== index))}
|
||||
className="p-2 text-red-500 hover:bg-red-500/10 rounded transition-colors shrink-0"
|
||||
title="Remove limit"
|
||||
title={t("apiManagerRemoveLimitTitle")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">delete</span>
|
||||
</button>
|
||||
@@ -1454,7 +1456,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
type="text"
|
||||
value={scheduleTz}
|
||||
onChange={(e) => setScheduleTz(e.target.value)}
|
||||
placeholder="America/Sao_Paulo"
|
||||
placeholder={t("apiManagerTimezonePlaceholder")}
|
||||
className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-text-muted mt-1">{t("scheduleTimezoneHint")}</p>
|
||||
@@ -1466,7 +1468,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
{/* Privacy Toggle */}
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">No-Log Payload Privacy</p>
|
||||
<p className="text-sm font-medium text-text-main">{t("noLogPayloadPrivacy")}</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Disable request/response payload persistence for this API key.
|
||||
</p>
|
||||
@@ -1516,7 +1518,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
{/* Ban Toggle (SECURITY) */}
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-red-500/20 bg-red-500/5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-bold text-red-700 dark:text-red-400">Banned Status</p>
|
||||
<p className="text-sm font-bold text-red-700 dark:text-red-400">{t("bannedStatus")}</p>
|
||||
<p className="text-xs text-red-600 dark:text-red-300">
|
||||
Immediately revoke all access. Used for suspected abuse or compromised keys.
|
||||
</p>
|
||||
@@ -1540,7 +1542,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
{/* Management API Access Toggle */}
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">Management API Access</p>
|
||||
<p className="text-sm font-medium text-text-main">{t("managementApiAccess")}</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Allow this key to call management routes (providers, combos, settings) via{" "}
|
||||
<code className="font-mono">Authorization: Bearer</code>. Use for LLM agents only.
|
||||
@@ -1565,7 +1567,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
{/* Expiration Date */}
|
||||
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">Expiration Date</p>
|
||||
<p className="text-sm font-medium text-text-main">{t("expirationDate")}</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Key will automatically stop working after this date.
|
||||
</p>
|
||||
@@ -1583,7 +1585,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
{/* Management Access */}
|
||||
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">Management Access</p>
|
||||
<p className="text-sm font-medium text-text-main">{t("managementAccess")}</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Allow this API key to manage OmniRoute configuration.
|
||||
</p>
|
||||
@@ -1772,7 +1774,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
{allConnections.length > 0 && (
|
||||
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-text-main">Allowed Connections</p>
|
||||
<p className="text-sm font-medium text-text-main">{t("allowedConnections")}</p>
|
||||
<div className="flex gap-1 p-0.5 bg-surface rounded-md">
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
const diffMs = Date.now() - ts * 1000;
|
||||
@@ -135,6 +136,7 @@ function formatTs(ts: number | null | undefined): string {
|
||||
}
|
||||
|
||||
export default function BatchDetailModal({ batch, files, onClose }: BatchDetailModalProps) {
|
||||
const t = useTranslations("common");
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
@@ -182,7 +184,7 @@ export default function BatchDetailModal({ batch, files, onClose }: BatchDetailM
|
||||
navigator.clipboard.writeText(batch.id);
|
||||
}}
|
||||
className="text-[var(--color-text-muted)] hover:text-[var(--color-text-main)] transition-colors"
|
||||
title="Copy ID"
|
||||
title={t("batchDetailCopyId")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">content_copy</span>
|
||||
</button>
|
||||
@@ -191,7 +193,7 @@ export default function BatchDetailModal({ batch, files, onClose }: BatchDetailM
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
aria-label={t("batchDetailClose")}
|
||||
className="p-1.5 rounded-lg text-[var(--color-text-muted)] hover:bg-[var(--color-bg-alt)] transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">close</span>
|
||||
@@ -208,11 +210,11 @@ export default function BatchDetailModal({ batch, files, onClose }: BatchDetailM
|
||||
</span>
|
||||
<StatusBadge batch={batch} />
|
||||
</div>
|
||||
<Field label="Endpoint" value={batch.endpoint} />
|
||||
{batch.model && <Field label="Model" value={batch.model} />}
|
||||
<Field label="Window" value={batch.completionWindow} />
|
||||
<Field label={t("batchDetailEndpoint")} value={batch.endpoint} />
|
||||
{batch.model && <Field label={t("batchDetailModel")} value={batch.model} />}
|
||||
<Field label={t("batchDetailWindow")} value={batch.completionWindow} />
|
||||
<Field
|
||||
label="Created"
|
||||
label={t("batchDetailCreated")}
|
||||
value={<span title={formatTs(batch.createdAt)}>{relativeTime(batch.createdAt)}</span>}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import BatchDetailModal from "./BatchDetailModal";
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
@@ -131,6 +132,7 @@ export default function BatchListTab({
|
||||
loading,
|
||||
onRefresh,
|
||||
}: Readonly<BatchListTabProps>) {
|
||||
const t = useTranslations("common");
|
||||
const [selectedBatch, setSelectedBatch] = useState<BatchRecord | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -199,7 +201,7 @@ export default function BatchListTab({
|
||||
<div className="flex flex-wrap gap-3 p-4 rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)]">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by ID, endpoint, model…"
|
||||
placeholder={t("batchListSearchPlaceholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="flex-1 min-w-[200px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
|
||||
@@ -219,7 +221,7 @@ export default function BatchListTab({
|
||||
onClick={handleRemoveCompleted}
|
||||
disabled={removingCompleted}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-red-500/10 border border-red-500/25 text-red-400 hover:text-red-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
||||
title="Delete all completed batches"
|
||||
title={t("batchListDeleteAllCompletedTitle")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">
|
||||
{removingCompleted ? "hourglass_empty" : "delete_sweep"}
|
||||
@@ -230,7 +232,7 @@ export default function BatchListTab({
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto overflow-y-hidden rounded-xl border border-[var(--color-border)]">
|
||||
<table className="w-full text-sm" role="table" aria-label="Batches">
|
||||
<table className="w-full text-sm" role="table" aria-label={t("batchListBatchesTable")}>
|
||||
<thead>
|
||||
<tr className="bg-[var(--color-bg-alt)] border-b border-[var(--color-border)]">
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
|
||||
@@ -338,7 +340,7 @@ export default function BatchListTab({
|
||||
onClick={(e) => handleDeleteBatch(e, batch)}
|
||||
disabled={deletingId === batch.id}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-red-500/10 border border-red-500/25 text-red-400 hover:text-red-300 transition-colors whitespace-nowrap disabled:opacity-50"
|
||||
title="Delete batch and its files"
|
||||
title={t("batchListDeleteBatchTitle")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">
|
||||
{deletingId === batch.id ? "hourglass_empty" : "delete"}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/shared/components";
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
@@ -69,6 +70,7 @@ export default function FileDetailModal({
|
||||
batches,
|
||||
onClose,
|
||||
}: Readonly<FileDetailModalProps>) {
|
||||
const t = useTranslations("common");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const relatedBatches = (batches ?? []).filter(
|
||||
@@ -140,7 +142,7 @@ export default function FileDetailModal({
|
||||
navigator.clipboard.writeText(file.id);
|
||||
}}
|
||||
className="text-[var(--color-text-muted)] hover:text-[var(--color-text-main)] transition-colors"
|
||||
title="Copy ID"
|
||||
title={t("batchFileDetailCopyId")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">content_copy</span>
|
||||
</button>
|
||||
@@ -149,7 +151,7 @@ export default function FileDetailModal({
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
aria-label={t("batchFileDetailClose")}
|
||||
className="p-1.5 rounded-lg text-[var(--color-text-muted)] hover:bg-[var(--color-bg-alt)] transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">close</span>
|
||||
@@ -266,7 +268,7 @@ export default function FileDetailModal({
|
||||
<span className="material-symbols-outlined text-[40px] mb-2 opacity-20">
|
||||
find_in_page
|
||||
</span>
|
||||
<p className="text-sm">Failed to load file contents</p>
|
||||
<p className="text-sm">{t("batchFileDetailFailedToLoad")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import FileDetailModal from "./FileDetailModal";
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
@@ -84,6 +85,7 @@ export default function FilesListTab({
|
||||
onRefresh,
|
||||
batches,
|
||||
}: Readonly<FilesListTabProps>) {
|
||||
const t = useTranslations("common");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [purposeFilter, setPurposeFilter] = useState("all");
|
||||
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
|
||||
@@ -129,7 +131,7 @@ export default function FilesListTab({
|
||||
<div className="flex flex-wrap gap-3 p-4 rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)]">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by ID or filename…"
|
||||
placeholder={t("batchFilesListSearchPlaceholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="flex-1 min-w-[200px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
|
||||
@@ -149,7 +151,7 @@ export default function FilesListTab({
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto overflow-y-hidden rounded-xl border border-[var(--color-border)]">
|
||||
<table className="w-full text-sm" role="table" aria-label="Files">
|
||||
<table className="w-full text-sm" role="table" aria-label={t("batchFilesListFilesTable")}>
|
||||
<thead>
|
||||
<tr className="bg-[var(--color-bg-alt)] border-b border-[var(--color-border)]">
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import BatchListTab from "./BatchListTab";
|
||||
import { FileRecord } from "@/lib/db/files";
|
||||
import { BatchRecord } from "@/lib/db/batches";
|
||||
import { mapBatchApiToRecord, mapFileApiToRecord } from "./batch-utils";
|
||||
|
||||
export default function BatchPage() {
|
||||
const t = useTranslations("common");
|
||||
const [batches, setBatches] = useState<BatchRecord[]>([]);
|
||||
const [files, setFiles] = useState<FileRecord[]>([]);
|
||||
const [batchesTotal, setBatchesTotal] = useState(0);
|
||||
@@ -174,7 +176,7 @@ export default function BatchPage() {
|
||||
onRefresh={() => fetchData(false)}
|
||||
/>
|
||||
{loadingMore && batchesCount > 0 && (
|
||||
<div className="text-center text-sm">Loading more…</div>
|
||||
<div className="text-center text-sm">{t("batchPageLoadingMore")}</div>
|
||||
)}
|
||||
<div ref={bottomRefBatches} className="h-10" />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/shared/components";
|
||||
|
||||
interface CachePerformanceProps {
|
||||
@@ -68,6 +69,7 @@ export default function CachePerformance({
|
||||
onRetry,
|
||||
stats,
|
||||
}: CachePerformanceProps) {
|
||||
const t = useTranslations("cache");
|
||||
// Parse hitRate string (e.g. "85.0%") to number for the bar
|
||||
const hitRateNum = hitRate ? parseFloat(hitRate) : 0;
|
||||
|
||||
@@ -87,7 +89,7 @@ export default function CachePerformance({
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="self-start text-xs px-3 py-1.5 rounded bg-surface border border-border/50 hover:bg-surface/80 transition-colors"
|
||||
aria-label="Retry"
|
||||
aria-label={t("cachePerformanceRetry")}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
@@ -117,7 +119,9 @@ export default function CachePerformance({
|
||||
{!loading && !error && stats !== null && (
|
||||
<>
|
||||
{/* Hit rate bar */}
|
||||
{hitRate !== undefined && <HitRateBar hitRate={hitRateNum} label="Hit Rate" />}
|
||||
{hitRate !== undefined && (
|
||||
<HitRateBar hitRate={hitRateNum} label={t("cachePerformanceHitRate")} />
|
||||
)}
|
||||
|
||||
{/* Hit / Miss / Total breakdown */}
|
||||
<div className="grid grid-cols-3 gap-4 pt-3 border-t border-border/30 text-center">
|
||||
@@ -141,13 +145,17 @@ export default function CachePerformance({
|
||||
{avgLatencyMs !== undefined && (
|
||||
<div>
|
||||
<div className="text-lg font-semibold tabular-nums">{avgLatencyMs}</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">Avg Latency (ms)</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">
|
||||
{t("cachePerformanceAvgLatency")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{p95LatencyMs !== undefined && (
|
||||
<div>
|
||||
<div className="text-lg font-semibold tabular-nums">{p95LatencyMs}</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">P95 Latency (ms)</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">
|
||||
{t("cachePerformanceP95Latency")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function IdempotencyLayer({
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="self-start text-sm px-3 py-1 rounded bg-surface/50 hover:bg-surface/80 transition-colors"
|
||||
aria-label="Retry"
|
||||
aria-label={t("retry")}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
|
||||
@@ -336,7 +336,7 @@ export default function ReasoningCacheTab() {
|
||||
<tr className="border-b border-border/20 text-left text-[11px] uppercase tracking-[0.12em] text-text-muted">
|
||||
<th className="px-4 py-3">Model</th>
|
||||
<th className="px-4 py-3">{t("reasoningEntries")}</th>
|
||||
<th className="px-4 py-3">Avg Chars</th>
|
||||
<th className="px-4 py-3">{t("reasoningAvgChars")}</th>
|
||||
<th className="px-4 py-3">{t("reasoningChars")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import { Button } from "@/shared/components";
|
||||
import {
|
||||
@@ -80,6 +81,7 @@ const markdownComponents: Components = {
|
||||
};
|
||||
|
||||
export default function ChangelogViewer() {
|
||||
const t = useTranslations("common");
|
||||
const [markdown, setMarkdown] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
@@ -109,7 +111,7 @@ export default function ChangelogViewer() {
|
||||
<span className="material-symbols-outlined animate-spin text-[32px] text-text-muted/50">
|
||||
sync
|
||||
</span>
|
||||
<p className="text-sm text-text-muted">Loading changelog from GitHub...</p>
|
||||
<p className="text-sm text-text-muted">{t("changelogViewerLoading")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -180,8 +180,9 @@ export default function CLIToolsPageClient({ machineId: _machineId }) {
|
||||
activeProviders.map((c) => PROVIDER_ID_TO_ALIAS[c.provider] || c.provider)
|
||||
);
|
||||
dynamicModels.forEach((dm) => {
|
||||
const modelId = dm.id || dm;
|
||||
if (seenModels.has(modelId)) return;
|
||||
const rawId = dm?.id ?? dm;
|
||||
const modelId = typeof rawId === "string" ? rawId : "";
|
||||
if (!modelId || seenModels.has(modelId)) return;
|
||||
// Parse alias/model format
|
||||
const slashIdx = modelId.indexOf("/");
|
||||
if (slashIdx === -1) return;
|
||||
|
||||
@@ -649,8 +649,8 @@ openai_base_url = "${getEffectiveBaseUrl()}"
|
||||
onChange={(e) => setWireApi(e.target.value)}
|
||||
className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
>
|
||||
<option value="chat">Chat Completions (/chat/completions)</option>
|
||||
<option value="responses">Responses API (/responses)</option>
|
||||
<option value="chat">{t("wireApiChatCompletions")}</option>
|
||||
<option value="responses">{t("wireApiResponses")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -193,7 +193,7 @@ export default function CopilotToolCard({
|
||||
<div className="flex items-start gap-3 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
|
||||
<span className="material-symbols-outlined text-blue-500 text-lg">info</span>
|
||||
<div className="text-sm text-blue-700 dark:text-blue-300">
|
||||
<p className="font-medium">GitHub Copilot Config Generator</p>
|
||||
<p className="font-medium">{t("copilotConfigGenerator")}</p>
|
||||
<p className="mt-1 text-xs opacity-80">
|
||||
Generates the{" "}
|
||||
<code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10">
|
||||
@@ -226,7 +226,7 @@ export default function CopilotToolCard({
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<span className="font-medium text-sm">API Key</span>
|
||||
<span className="font-medium text-sm">{t("copilotApiKey")}</span>
|
||||
</div>
|
||||
<select
|
||||
value={selectedApiKeyId}
|
||||
@@ -278,7 +278,7 @@ export default function CopilotToolCard({
|
||||
type="text"
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
placeholder="Filter models..."
|
||||
placeholder={t("copilotFilterModelsPlaceholder")}
|
||||
className="w-full px-3 py-1.5 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
@@ -327,7 +327,9 @@ export default function CopilotToolCard({
|
||||
</summary>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 pl-6">
|
||||
<div>
|
||||
<label className="text-xs text-text-muted block mb-1">Max Input Tokens</label>
|
||||
<label className="text-xs text-text-muted block mb-1">
|
||||
{t("copilotMaxInputTokens")}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={maxInputTokens}
|
||||
@@ -336,7 +338,9 @@ export default function CopilotToolCard({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted block mb-1">Max Output Tokens</label>
|
||||
<label className="text-xs text-text-muted block mb-1">
|
||||
{t("copilotMaxOutputTokens")}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={maxOutputTokens}
|
||||
@@ -351,7 +355,7 @@ export default function CopilotToolCard({
|
||||
onChange={(e) => setToolCalling(e.target.checked)}
|
||||
className="rounded border-border accent-[#1F6FEB]"
|
||||
/>
|
||||
<span className="text-sm">Tool Calling</span>
|
||||
<span className="text-sm">{t("copilotToolCalling")}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
@@ -401,7 +405,7 @@ export default function CopilotToolCard({
|
||||
{/* Usage instructions */}
|
||||
<div className="mt-3 p-3 bg-bg-secondary rounded-lg border border-border">
|
||||
<p className="text-xs text-text-muted">
|
||||
<span className="font-medium text-text-main">Paste into: </span>
|
||||
<span className="font-medium text-text-main">{t("copilotPasteInto")} </span>
|
||||
<code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10">
|
||||
~/.config/Code/User/chatLanguageModels.json
|
||||
</code>
|
||||
|
||||
@@ -328,14 +328,14 @@ export default function CloudAgentsPage() {
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Repository name"
|
||||
label={t("repositoryName")}
|
||||
placeholder="omniroute"
|
||||
value={newTask.repoName}
|
||||
onChange={(e) => setNewTask({ ...newTask, repoName: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Repository URL"
|
||||
label={t("repositoryUrl")}
|
||||
placeholder="https://github.com/owner/repo"
|
||||
value={newTask.repoUrl}
|
||||
onChange={(e) => setNewTask({ ...newTask, repoUrl: e.target.value })}
|
||||
@@ -344,7 +344,7 @@ export default function CloudAgentsPage() {
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Branch"
|
||||
label={t("branch")}
|
||||
placeholder="main"
|
||||
value={newTask.branch}
|
||||
onChange={(e) => setNewTask({ ...newTask, branch: e.target.value })}
|
||||
|
||||
@@ -287,7 +287,9 @@ export default function IntelligentComboPanel({
|
||||
|
||||
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<div className="rounded-lg border border-black/8 bg-white/60 p-3 dark:border-white/8 dark:bg-white/[0.03]">
|
||||
<p className="text-[11px] uppercase tracking-wide text-text-muted">Mode Pack</p>
|
||||
<p className="text-[11px] uppercase tracking-wide text-text-muted">
|
||||
{t("modePack")}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-semibold text-text-main">
|
||||
{normalizedConfig.modePack}
|
||||
</p>
|
||||
|
||||
@@ -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>
|
||||
@@ -1732,7 +1738,7 @@ function ComboCard({
|
||||
onChange={(e) => handleCompressionOverrideChange(e.target.value)}
|
||||
disabled={isSavingCompression}
|
||||
className="text-xs py-1 px-2 rounded border border-black/10 dark:border-white/10 bg-white dark:bg-bg-main text-text-main focus:border-primary focus:outline-none transition-colors disabled:opacity-50 max-w-[130px] md:max-w-none"
|
||||
title="Compression Override"
|
||||
title={t("compressionOverride")}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
<option value="off">Off</option>
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface BadgeUnlockEvent {
|
||||
badgeId: string;
|
||||
@@ -20,6 +21,7 @@ const RECONNECT_BASE_MS = 1000;
|
||||
const RECONNECT_MAX_MS = 30000;
|
||||
|
||||
export function BadgeToast({ apiKeyId }: { apiKeyId: string }) {
|
||||
const t = useTranslations("common");
|
||||
const [toasts, setToasts] = useState<BadgeUnlockEvent[]>([]);
|
||||
const timeoutIds = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
|
||||
|
||||
@@ -92,7 +94,7 @@ export function BadgeToast({ apiKeyId }: { apiKeyId: string }) {
|
||||
>
|
||||
<span className="text-2xl">🏆</span>
|
||||
<div>
|
||||
<div className="font-semibold text-white">Badge Unlocked!</div>
|
||||
<div className="font-semibold text-white">{t("badgeToastUnlocked")}</div>
|
||||
<div className="text-sm text-text-muted">{toast.badgeName}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -339,11 +339,8 @@ export default function QuotaSharePageClient() {
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-xs text-amber-700 dark:text-amber-300 flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] shrink-0">science</span>
|
||||
<div>
|
||||
<strong>Beta — UI preview.</strong> A configuração é salva em <code>localStorage</code>{" "}
|
||||
(não persiste no servidor ainda). A aplicação dos caps por request ainda não está
|
||||
conectada ao pipeline da proxy. Esta tela permite desenhar e visualizar a divisão de cota;
|
||||
a aplicação real virá em uma próxima iteração com persistência no banco e interceptação na
|
||||
chamada upstream.
|
||||
<strong>{t("betaPreviewLabel")}</strong> {t("betaConfigSavedPrefix")}{" "}
|
||||
<code>localStorage</code> {t("betaConfigSavedSuffix")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -598,7 +595,9 @@ function PoolCard({
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-2 flex-wrap text-[11px]">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-text-muted font-semibold uppercase tracking-wide">Policy:</span>
|
||||
<span className="text-text-muted font-semibold uppercase tracking-wide">
|
||||
{t("policyLabel")}
|
||||
</span>
|
||||
{(["hard", "soft", "burst"] as PoolPolicy[]).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/shared/components";
|
||||
import { useDisplayBaseUrl } from "@/shared/hooks";
|
||||
|
||||
@@ -44,6 +45,7 @@ const METHOD_COLORS: Record<string, string> = {
|
||||
|
||||
/* ─── Main Component ─────────────────────────────────── */
|
||||
export default function ApiEndpointsTab() {
|
||||
const t = useTranslations("endpoint");
|
||||
const baseUrl = useDisplayBaseUrl();
|
||||
const [catalog, setCatalog] = useState<CatalogData | null>(null);
|
||||
const [catalogError, setCatalogError] = useState<string | null>(null);
|
||||
@@ -224,7 +226,9 @@ export default function ApiEndpointsTab() {
|
||||
<span className="material-symbols-outlined text-[20px] text-red-500">error</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-main">API catalog unavailable</h3>
|
||||
<h3 className="text-sm font-semibold text-text-main">
|
||||
{t("apiEndpointsCatalogUnavailable")}
|
||||
</h3>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
{catalogError || "The OpenAPI specification could not be loaded."}
|
||||
</p>
|
||||
@@ -254,7 +258,7 @@ export default function ApiEndpointsTab() {
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search endpoints..."
|
||||
placeholder={t("apiEndpointsSearchPlaceholder")}
|
||||
className="w-full pl-9 pr-3 py-2 text-xs rounded-lg border border-black/10 dark:border-white/10
|
||||
bg-white dark:bg-black/20 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
@@ -334,7 +338,7 @@ export default function ApiEndpointsTab() {
|
||||
{ep.security && (
|
||||
<span
|
||||
className="material-symbols-outlined text-[12px] text-amber-500"
|
||||
title="Requires auth"
|
||||
title={t("apiEndpointsRequiresAuth")}
|
||||
>
|
||||
lock
|
||||
</span>
|
||||
@@ -482,7 +486,7 @@ export default function ApiEndpointsTab() {
|
||||
<span className="material-symbols-outlined text-[32px] text-text-muted">
|
||||
search_off
|
||||
</span>
|
||||
<p className="text-sm text-text-muted mt-2">No endpoints match your filter</p>
|
||||
<p className="text-sm text-text-muted mt-2">{t("apiEndpointsNoMatch")}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1281,7 +1281,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-1 flex-wrap">
|
||||
<span className="text-sm font-medium">Local Server</span>
|
||||
<span className="text-sm font-medium">{t("localServer")}</span>
|
||||
{resolvedMachineId && (
|
||||
<span className="text-xs text-text-muted">· {resolvedMachineId.slice(0, 8)}</span>
|
||||
)}
|
||||
@@ -1335,7 +1335,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
cloud
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">Cloud OmniRoute</span>
|
||||
<span className="text-sm font-medium">{t("cloudOmniroute")}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium border shrink-0 ${
|
||||
@@ -2387,7 +2387,7 @@ function EndpointCard({
|
||||
<button
|
||||
onClick={() => void copy(fullUrl, copyId)}
|
||||
className="shrink-0 flex items-center justify-center size-6 rounded hover:bg-sidebar transition-colors"
|
||||
title="Copy URL"
|
||||
title={t("copyUrl")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px] text-text-muted">
|
||||
{copied === copyId ? "check" : "content_copy"}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, Toggle } from "@/shared/components";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
|
||||
@@ -111,6 +112,7 @@ function EngineRow({
|
||||
}
|
||||
|
||||
export default function TokenSaverCard() {
|
||||
const t = useTranslations("endpoint");
|
||||
const notify = useNotificationStore();
|
||||
const [config, setConfig] = useState<CompressionConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -182,14 +184,14 @@ export default function TokenSaverCard() {
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-sm text-text-muted mt-1">Spend less tokens on every request.</p>
|
||||
<p className="text-sm text-text-muted mt-1">{t("tokenSaverSubtitle")}</p>
|
||||
</div>
|
||||
<Toggle size="md" checked={masterEnabled} onChange={(v) => save({ enabled: v })} />
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border mt-4">
|
||||
<EngineRow
|
||||
title="Tool output"
|
||||
title={t("tokenSaverToolOutput")}
|
||||
badge="RTK"
|
||||
href="/dashboard/context/rtk"
|
||||
description="git/grep/ls/tree/logs cleaner → 60-90% fewer input tokens"
|
||||
@@ -206,7 +208,7 @@ export default function TokenSaverCard() {
|
||||
}
|
||||
/>
|
||||
<EngineRow
|
||||
title="LLM output"
|
||||
title={t("tokenSaverLlmOutput")}
|
||||
badge="Caveman"
|
||||
href="/dashboard/context/caveman"
|
||||
description="Terse-style system prompt → ~65% fewer output tokens (up to 87%)"
|
||||
@@ -223,7 +225,7 @@ export default function TokenSaverCard() {
|
||||
}
|
||||
/>
|
||||
<EngineRow
|
||||
title="Input compression"
|
||||
title={t("tokenSaverInputCompression")}
|
||||
badge="Caveman"
|
||||
href="/dashboard/context/caveman"
|
||||
description="Rewrite chat history → ~50% fewer input tokens"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/shared/components";
|
||||
|
||||
interface Anomaly {
|
||||
@@ -10,6 +11,7 @@ interface Anomaly {
|
||||
}
|
||||
|
||||
export default function GamificationAdminPage() {
|
||||
const t = useTranslations("common");
|
||||
const [anomalies, setAnomalies] = useState<Anomaly[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -33,24 +35,24 @@ export default function GamificationAdminPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Gamification Admin</h1>
|
||||
<p className="text-sm text-text-muted mt-1">Monitor anomalies and system health</p>
|
||||
<h1 className="text-2xl font-bold">{t("gamificationAdmin")}</h1>
|
||||
<p className="text-sm text-text-muted mt-1">{t("monitorAnomaliesAndHealth")}</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold mb-3">Flagged Anomalies</h2>
|
||||
<h2 className="text-lg font-semibold mb-3">{t("flaggedAnomalies")}</h2>
|
||||
{loading ? (
|
||||
<div className="text-text-muted">Loading...</div>
|
||||
) : anomalies.length === 0 ? (
|
||||
<div className="text-text-muted">No anomalies detected</div>
|
||||
<div className="text-text-muted">{t("noAnomaliesDetected")}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left p-2 font-medium text-text-muted">API Key</th>
|
||||
<th className="text-right p-2 font-medium text-text-muted">XP (1h)</th>
|
||||
<th className="text-right p-2 font-medium text-text-muted">Z-Score</th>
|
||||
<th className="text-left p-2 font-medium text-text-muted">{t("apiKey")}</th>
|
||||
<th className="text-right p-2 font-medium text-text-muted">{t("xpLastHour")}</th>
|
||||
<th className="text-right p-2 font-medium text-text-muted">{t("zScore")}</th>
|
||||
<th className="text-center p-2 font-medium text-text-muted">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -255,7 +255,7 @@ export default function HealthPage() {
|
||||
<span className="material-symbols-outlined text-[18px]">database</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-main">Database Health</h2>
|
||||
<h2 className="text-lg font-semibold text-text-main">{t("databaseHealth")}</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
Diagnose and repair stale quota/domain rows and broken combo references.
|
||||
</p>
|
||||
@@ -417,13 +417,13 @@ export default function HealthPage() {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
<div className="rounded-xl border border-border/40 bg-surface/30 p-3">
|
||||
<div className="text-xs text-text-muted">Sticky-bound sessions</div>
|
||||
<div className="text-xs text-text-muted">{t("stickyBoundSessions")}</div>
|
||||
<div className="text-2xl font-semibold text-text-main mt-1">
|
||||
{sessions?.stickyBoundCount ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/40 bg-surface/30 p-3">
|
||||
<div className="text-xs text-text-muted">Sessions by API key</div>
|
||||
<div className="text-xs text-text-muted">{t("sessionsByApiKey")}</div>
|
||||
<div className="text-2xl font-semibold text-text-main mt-1">
|
||||
{Object.keys(sessions?.byApiKey || {}).length}
|
||||
</div>
|
||||
@@ -453,7 +453,7 @@ export default function HealthPage() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">No active sessions tracked yet.</p>
|
||||
<p className="text-sm text-text-muted">{t("noActiveSessionsTracked")}</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -532,14 +532,14 @@ export default function HealthPage() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">No session quota monitors active.</p>
|
||||
<p className="text-sm text-text-muted">{t("noSessionQuotaMonitorsActive")}</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Graceful Degradation Status */}
|
||||
{degradation && degradation.features && degradation.features.length > 0 && (
|
||||
<Card className="p-5" role="region" aria-label="Graceful Degradation Status">
|
||||
<Card className="p-5" role="region" aria-label={t("gracefulDegradationStatus")}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-text-main flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px] text-primary">healing</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/shared/components";
|
||||
|
||||
type LeaderboardScope = "global" | "weekly" | "monthly" | "tokens_shared";
|
||||
@@ -26,6 +27,7 @@ const MEDAL_COLORS = [
|
||||
const MEDAL_EMOJI = ["🥇", "🥈", "🥉"];
|
||||
|
||||
export default function LeaderboardPage() {
|
||||
const t = useTranslations("common");
|
||||
const [scope, setScope] = useState<LeaderboardScope>("global");
|
||||
const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
|
||||
const [myRank, setMyRank] = useState<number | null>(null);
|
||||
@@ -110,7 +112,7 @@ export default function LeaderboardPage() {
|
||||
<Card>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-text-muted">Your Rank</p>
|
||||
<p className="text-sm text-text-muted">{t("leaderboardYourRank")}</p>
|
||||
<p className="text-3xl font-bold mt-1">#{myRank}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
@@ -125,7 +127,7 @@ export default function LeaderboardPage() {
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center min-h-[200px]">
|
||||
<div className="text-text-muted">Loading leaderboard...</div>
|
||||
<div className="text-text-muted">{t("leaderboardLoading")}</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/shared/components";
|
||||
import { copyToClipboard } from "@/shared/utils/clipboard";
|
||||
import McpDashboardPage from "../endpoint/components/MCPDashboard";
|
||||
@@ -98,6 +99,7 @@ function TransportSelector({
|
||||
disabled: boolean;
|
||||
baseUrl: string;
|
||||
}) {
|
||||
const t = useTranslations("mcpDashboard");
|
||||
const options: { value: McpTransport; label: string; desc: string }[] = [
|
||||
{ value: "stdio", label: "stdio", desc: "Local — IDE spawns process via omniroute --mcp" },
|
||||
{ value: "sse", label: "SSE", desc: "Remote — Server-Sent Events over HTTP" },
|
||||
@@ -181,7 +183,7 @@ function TransportSelector({
|
||||
className="ml-auto text-xs px-2 py-0.5 rounded border hover:opacity-80 transition-opacity"
|
||||
style={{ borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
|
||||
onClick={() => void copyToClipboard(urlMap[value])}
|
||||
title="Copy URL"
|
||||
title={t("mcpDashboardCopyUrl")}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Image from "next/image";
|
||||
|
||||
export function TierFlowDiagram() {
|
||||
const t = useTranslations("onboarding");
|
||||
const { resolvedTheme } = useTheme();
|
||||
const src =
|
||||
resolvedTheme === "dark" ? "/images/tier-flow-dark.svg" : "/images/tier-flow-light.svg";
|
||||
@@ -12,7 +14,7 @@ export function TierFlowDiagram() {
|
||||
<div className="flex flex-col items-center gap-3 my-4">
|
||||
<Image
|
||||
src={src}
|
||||
alt="OmniRoute 3-tier fallback diagram"
|
||||
alt={t("tierFlowDiagramAlt")}
|
||||
width={800}
|
||||
height={420}
|
||||
priority
|
||||
|
||||
@@ -48,9 +48,18 @@ export default function ChatPlayground({
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const filteredModels = models
|
||||
.filter((m) => !selectedProvider || m.id.startsWith(selectedProvider + "/"))
|
||||
.map((m) => ({ value: m.id, label: m.id }));
|
||||
const filteredModels = (() => {
|
||||
const seen = new Set<string>();
|
||||
const out: Array<{ value: string; label: string }> = [];
|
||||
for (const m of models) {
|
||||
if (typeof m?.id !== "string") continue;
|
||||
if (selectedProvider && !m.id.startsWith(selectedProvider + "/")) continue;
|
||||
if (seen.has(m.id)) continue;
|
||||
seen.add(m.id);
|
||||
out.push({ value: m.id, label: m.id });
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
@@ -222,7 +231,7 @@ export default function ChatPlayground({
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">chat</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">Conversational Chat</h3>
|
||||
<h3 className="text-sm font-semibold text-text-main">{t("conversationalChat")}</h3>
|
||||
{responseStatus !== null && (
|
||||
<Badge variant={responseStatus < 400 ? "success" : "error"} size="sm">
|
||||
{responseStatus}
|
||||
@@ -235,7 +244,7 @@ export default function ChatPlayground({
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="p-1.5 rounded hover:bg-red-500/10 text-text-muted hover:text-red-500 transition-colors"
|
||||
title="Clear chat"
|
||||
title={t("clearChat")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">delete</span>
|
||||
</button>
|
||||
@@ -292,7 +301,7 @@ export default function ChatPlayground({
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message... (Shift+Enter for new line)"
|
||||
placeholder={t("typeMessagePlaceholder")}
|
||||
className="flex-1 min-h-[44px] max-h-[120px] bg-surface border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary resize-y"
|
||||
rows={1}
|
||||
disabled={loading || noModels}
|
||||
|
||||
@@ -175,7 +175,7 @@ export default function SearchPlayground() {
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(requestBody)}
|
||||
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
|
||||
title="Copy"
|
||||
title={t("copy")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">content_copy</span>
|
||||
</button>
|
||||
@@ -194,7 +194,7 @@ export default function SearchPlayground() {
|
||||
)
|
||||
}
|
||||
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
|
||||
title="Reset to default"
|
||||
title={t("resetToDefault")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
|
||||
</button>
|
||||
|
||||
@@ -259,6 +259,7 @@ export default function PlaygroundPage() {
|
||||
|
||||
const providerSet = new Set<string>();
|
||||
modelList.forEach((m) => {
|
||||
if (typeof m?.id !== "string") return;
|
||||
const parts = m.id.split("/");
|
||||
if (parts.length >= 2) providerSet.add(parts[0]);
|
||||
});
|
||||
@@ -270,7 +271,9 @@ export default function PlaygroundPage() {
|
||||
setSelectedProvider(providerOpts[0].value);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch((err) => {
|
||||
console.error("[playground] Failed to load models:", err);
|
||||
});
|
||||
|
||||
// Fetch ALL connections (once)
|
||||
fetch("/api/providers/client")
|
||||
@@ -291,9 +294,18 @@ export default function PlaygroundPage() {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const filteredModels = models
|
||||
.filter((m) => !selectedProvider || m.id.startsWith(selectedProvider + "/"))
|
||||
.map((m) => ({ value: m.id, label: m.id }));
|
||||
const filteredModels = (() => {
|
||||
const seen = new Set<string>();
|
||||
const out: Array<{ value: string; label: string }> = [];
|
||||
for (const m of models) {
|
||||
if (typeof m?.id !== "string") continue;
|
||||
if (selectedProvider && !m.id.startsWith(selectedProvider + "/")) continue;
|
||||
if (seen.has(m.id)) continue;
|
||||
seen.add(m.id);
|
||||
out.push({ value: m.id, label: m.id });
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
|
||||
const generateDefaultBody = (endpoint: string, model: string) => {
|
||||
const template = { ...DEFAULT_BODIES[endpoint] };
|
||||
@@ -307,7 +319,9 @@ export default function PlaygroundPage() {
|
||||
setSelectedProvider(newProvider);
|
||||
setSelectedConnection("");
|
||||
const providerModels = models
|
||||
.filter((m) => !newProvider || m.id.startsWith(newProvider + "/"))
|
||||
.filter(
|
||||
(m) => typeof m?.id === "string" && (!newProvider || m.id.startsWith(newProvider + "/"))
|
||||
)
|
||||
.map((m) => m.id);
|
||||
const firstModel = providerModels[0] || "";
|
||||
setSelectedModel(firstModel);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, Badge } from "@/shared/components";
|
||||
import {
|
||||
xpForLevel,
|
||||
@@ -57,6 +58,7 @@ const RARITY_COLORS: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function ProfilePage() {
|
||||
const t = useTranslations("common");
|
||||
const [userLevel, setUserLevel] = useState<UserLevel | null>(null);
|
||||
const [allBadges, setAllBadges] = useState<BadgeDef[]>([]);
|
||||
const [earnedBadges, setEarnedBadges] = useState<UserBadge[]>([]);
|
||||
@@ -99,7 +101,7 @@ export default function ProfilePage() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-text-muted">Loading profile...</div>
|
||||
<div className="text-text-muted">{t("profileLoading")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -269,7 +271,7 @@ export default function ProfilePage() {
|
||||
|
||||
{selectedBadge.criteria && (
|
||||
<div className="p-3 rounded-lg bg-surface/50 border border-border/50">
|
||||
<p className="text-xs font-medium text-text-muted mb-1">How to earn</p>
|
||||
<p className="text-xs font-medium text-text-muted mb-1">{t("profileHowToEarn")}</p>
|
||||
<p className="text-sm">{selectedBadge.criteria}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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 && (
|
||||
@@ -3440,13 +3530,13 @@ export default function ProviderDetailPage() {
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">{t("connections")}</h2>
|
||||
{providerId === "codex" && (
|
||||
<div title="Apply Codex Fast tier to all Codex connections by default">
|
||||
<div title={t("providerDetailFastTierTooltip")}>
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={codexGlobalFastServiceTier}
|
||||
onChange={handleToggleCodexGlobalFastServiceTier}
|
||||
disabled={savingCodexGlobalFastServiceTier}
|
||||
label="Fast default"
|
||||
label={t("providerDetailFastDefaultLabel")}
|
||||
ariaLabel="Toggle Codex Fast default"
|
||||
className="rounded-lg border border-sky-500/20 bg-sky-500/5 px-2 py-1"
|
||||
/>
|
||||
@@ -7184,7 +7274,9 @@ function AddApiKeyModal({
|
||||
open_in_new
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-text-main">Browser/manual connect</p>
|
||||
<p className="font-medium text-text-main">
|
||||
{t("providerDetailBrowserManualConnect")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
Open Command Code Studio, then paste the returned key/JSON/URL into the API
|
||||
key field below.
|
||||
@@ -7197,7 +7289,9 @@ function AddApiKeyModal({
|
||||
{commandCodeAuthState?.authUrl && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-text-main">Auth URL</p>
|
||||
<p className="mb-1 text-xs font-medium text-text-main">
|
||||
{t("providerDetailAuthUrl")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={commandCodeAuthState.authUrl}
|
||||
@@ -7216,7 +7310,9 @@ function AddApiKeyModal({
|
||||
</div>
|
||||
{commandCodeAuthState.callbackUrl && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-text-main">Callback URL</p>
|
||||
<p className="mb-1 text-xs font-medium text-text-main">
|
||||
{t("providerDetailCallbackUrl")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={commandCodeAuthState.callbackUrl}
|
||||
@@ -8163,9 +8259,7 @@ function ApplyCodexAuthModal({
|
||||
<code className="block rounded bg-sidebar px-2 py-1.5 text-xs font-mono text-text-main">
|
||||
~/.codex/auth.json
|
||||
</code>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
Path is auto-detected per OS (Linux/Mac/Windows).
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-text-muted">{t("providerDetailPathAutoDetectedAllOs")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase text-text-muted mb-1">{backupLabel}</div>
|
||||
@@ -8619,7 +8713,9 @@ function ImportClaudeAuthModal({ onClose, onSuccess }: ImportClaudeAuthModalProp
|
||||
className="block w-full text-sm"
|
||||
/>
|
||||
{singleJson && previewClaudeJson(singleJson).valid && (
|
||||
<p className="mt-1 text-xs text-emerald-500">Valid Claude credentials file</p>
|
||||
<p className="mt-1 text-xs text-emerald-500">
|
||||
{t("providerDetailValidClaudeCredentialsFile")}
|
||||
</p>
|
||||
)}
|
||||
{singleJson && !previewClaudeJson(singleJson).valid && (
|
||||
<p className="mt-1 text-xs text-red-500">
|
||||
|
||||
@@ -492,7 +492,7 @@ export default function AppearanceTab() {
|
||||
{(settings.customLogoUrl || settings.customLogoBase64) && (
|
||||
<img
|
||||
src={settings.customLogoBase64 || settings.customLogoUrl}
|
||||
alt="Logo preview"
|
||||
alt={t("appearanceLogoPreviewAlt")}
|
||||
className="h-10 w-10 rounded border border-border object-contain bg-surface"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none";
|
||||
@@ -562,7 +562,7 @@ export default function AppearanceTab() {
|
||||
<p className="text-xs text-text-muted mb-2">{t("logoPreview")}</p>
|
||||
<img
|
||||
src={settings.customLogoBase64 || settings.customLogoUrl}
|
||||
alt="Logo preview"
|
||||
alt={t("appearanceLogoPreviewAlt")}
|
||||
className="h-12 w-auto max-w-full rounded"
|
||||
/>
|
||||
</div>
|
||||
@@ -586,7 +586,7 @@ export default function AppearanceTab() {
|
||||
{(settings.customFaviconUrl || settings.customFaviconBase64) && (
|
||||
<img
|
||||
src={settings.customFaviconBase64 || settings.customFaviconUrl}
|
||||
alt="Favicon preview"
|
||||
alt={t("appearanceFaviconPreviewAlt")}
|
||||
className="h-10 w-10 rounded border border-border object-contain bg-surface"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none";
|
||||
@@ -658,7 +658,7 @@ export default function AppearanceTab() {
|
||||
<p className="text-xs text-text-muted mb-2">{t("faviconPreview")}</p>
|
||||
<img
|
||||
src={settings.customFaviconBase64 || settings.customFaviconUrl}
|
||||
alt="Favicon preview"
|
||||
alt={t("appearanceFaviconPreviewAlt")}
|
||||
className="h-8 w-8 rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, Button, Input, Toggle } from "@/shared/components";
|
||||
|
||||
interface Settings {
|
||||
@@ -28,6 +29,7 @@ function isValidUrl(value: string): boolean {
|
||||
}
|
||||
|
||||
export default function CliproxyapiSettingsTab() {
|
||||
const t = useTranslations("settings");
|
||||
const [settings, setSettings] = useState<Settings>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -139,7 +141,7 @@ export default function CliproxyapiSettingsTab() {
|
||||
<span className="material-symbols-outlined text-indigo-500 text-xl">swap_horiz</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-sm">CLIProxyAPI Fallback</h3>
|
||||
<h3 className="font-medium text-sm">{t("cliproxyapiFallback")}</h3>
|
||||
<p className="text-xs text-text-muted">
|
||||
When enabled, failed requests are retried through CLIProxyAPI (localhost:8317)
|
||||
</p>
|
||||
@@ -148,7 +150,7 @@ export default function CliproxyapiSettingsTab() {
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm text-text-main">Enable CLIProxyAPI Fallback</label>
|
||||
<label className="text-sm text-text-main">{t("cliproxyapiEnableFallback")}</label>
|
||||
<Toggle
|
||||
checked={cpaEnabled}
|
||||
onChange={(checked) => updateSetting("cliproxyapi_fallback_enabled", checked)}
|
||||
@@ -158,7 +160,9 @@ export default function CliproxyapiSettingsTab() {
|
||||
{cpaEnabled && (
|
||||
<>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1.5 block">CLIProxyAPI URL</label>
|
||||
<label className="text-xs text-text-muted mb-1.5 block">
|
||||
{t("cliproxyapiUrl")}
|
||||
</label>
|
||||
<Input
|
||||
value={cpaUrl}
|
||||
onChange={(e) => updateSetting("cliproxyapi_url", e.target.value)}
|
||||
@@ -184,7 +188,7 @@ export default function CliproxyapiSettingsTab() {
|
||||
</Card>
|
||||
|
||||
<Card padding="md">
|
||||
<h3 className="font-medium text-sm mb-4">CLIProxyAPI Status</h3>
|
||||
<h3 className="font-medium text-sm mb-4">{t("cliproxyapiStatus")}</h3>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-text-muted text-sm">
|
||||
<span className="material-symbols-outlined animate-spin text-base">
|
||||
@@ -237,7 +241,7 @@ export default function CliproxyapiSettingsTab() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">CLIProxyAPI not detected</p>
|
||||
<p className="text-sm text-text-muted">{t("cliproxyapiNotDetected")}</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -340,7 +340,9 @@ export default function CompressionSettingsTab() {
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-muted">Auto trigger mode</span>
|
||||
<span className="text-sm text-text-muted">
|
||||
{t("compressionSettingsAutoTriggerMode")}
|
||||
</span>
|
||||
<select
|
||||
value={config.autoTriggerMode ?? "lite"}
|
||||
onChange={(e) => save({ autoTriggerMode: e.target.value as CompressionMode })}
|
||||
@@ -386,7 +388,9 @@ export default function CompressionSettingsTab() {
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-muted">MCP description compression</span>
|
||||
<span className="text-sm text-text-muted">
|
||||
{t("compressionSettingsMcpDescriptionCompression")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
save({
|
||||
@@ -484,7 +488,9 @@ export default function CompressionSettingsTab() {
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-muted">Caveman intensity</span>
|
||||
<span className="text-sm text-text-muted">
|
||||
{t("compressionSettingsCavemanIntensity")}
|
||||
</span>
|
||||
<select
|
||||
value={config.cavemanConfig.intensity}
|
||||
onChange={(e) =>
|
||||
@@ -556,7 +562,9 @@ export default function CompressionSettingsTab() {
|
||||
<div className="space-y-3 pt-4 border-t border-border/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-text-main">Caveman output mode</h4>
|
||||
<h4 className="text-sm font-medium text-text-main">
|
||||
{t("compressionSettingsCavemanOutputMode")}
|
||||
</h4>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Injects terse response instructions without rewriting provider output.
|
||||
</p>
|
||||
@@ -583,7 +591,9 @@ export default function CompressionSettingsTab() {
|
||||
</div>
|
||||
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-muted">Output intensity</span>
|
||||
<span className="text-sm text-text-muted">
|
||||
{t("compressionSettingsOutputIntensity")}
|
||||
</span>
|
||||
<select
|
||||
value={config.cavemanOutputMode.intensity}
|
||||
onChange={(e) =>
|
||||
@@ -603,7 +613,9 @@ export default function CompressionSettingsTab() {
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-muted">Auto clarity bypass</span>
|
||||
<span className="text-sm text-text-muted">
|
||||
{t("compressionSettingsAutoClarityBypass")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
save({
|
||||
|
||||
@@ -757,7 +757,7 @@ export default function MemorySkillsTab() {
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">SkillsMP Marketplace</h3>
|
||||
<h3 className="text-lg font-semibold">{t("memorySkillsSkillsmpMarketplace")}</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Connect to SkillsMP to discover and install skills from the marketplace.
|
||||
</p>
|
||||
@@ -769,12 +769,14 @@ export default function MemorySkillsTab() {
|
||||
</span>
|
||||
)}
|
||||
{skillsmpStatus === "error" && (
|
||||
<span className="ml-auto text-xs font-medium text-red-500">Failed to save</span>
|
||||
<span className="ml-auto text-xs font-medium text-red-500">
|
||||
{t("memorySkillsFailedToSave")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-surface/30 border border-border/30">
|
||||
<label className="text-sm font-medium block mb-2">API Key</label>
|
||||
<label className="text-sm font-medium block mb-2">{t("memorySkillsApiKey")}</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
@@ -807,7 +809,7 @@ export default function MemorySkillsTab() {
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Active Skills Provider</h3>
|
||||
<h3 className="text-lg font-semibold">{t("memorySkillsActiveSkillsProvider")}</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Choose which provider the Skills page uses for search and install.
|
||||
</p>
|
||||
@@ -819,7 +821,9 @@ export default function MemorySkillsTab() {
|
||||
</span>
|
||||
)}
|
||||
{skillsProviderStatus === "error" && (
|
||||
<span className="ml-auto text-xs font-medium text-red-500">Failed to save</span>
|
||||
<span className="ml-auto text-xs font-medium text-red-500">
|
||||
{t("memorySkillsFailedToSave")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Card } from "@/shared/components";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
|
||||
@@ -20,6 +21,7 @@ function formatRemaining(ms: number): string {
|
||||
}
|
||||
|
||||
export default function ModelCooldownsCard() {
|
||||
const t = useTranslations("settings");
|
||||
const notify = useNotificationStore();
|
||||
const [items, setItems] = useState<CooldownItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -95,7 +97,7 @@ export default function ModelCooldownsCard() {
|
||||
<Card className="p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-text-main">Models in cooldown</h2>
|
||||
<h2 className="text-lg font-bold text-text-main">{t("modelCooldownsTitle")}</h2>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
Models temporarily isolated after a failure. When the cooldown expires they come back
|
||||
automatically.
|
||||
@@ -120,7 +122,7 @@ export default function ModelCooldownsCard() {
|
||||
{loading ? (
|
||||
<p className="text-sm text-text-muted">Loading...</p>
|
||||
) : !hasItems ? (
|
||||
<p className="text-sm text-text-muted">No models in cooldown right now.</p>
|
||||
<p className="text-sm text-text-muted">{t("modelCooldownsEmpty")}</p>
|
||||
) : (
|
||||
sorted.map((item) => {
|
||||
const rowKey = `${item.provider}::${item.model}`;
|
||||
|
||||
@@ -140,7 +140,7 @@ export default function OneproxyTab() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-main">1proxy Free Proxy Marketplace</h2>
|
||||
<h2 className="text-lg font-semibold text-text-main">{t("oneproxyTitle")}</h2>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Fetch and rotate free validated proxies from the 1proxy community platform
|
||||
</p>
|
||||
@@ -173,7 +173,7 @@ export default function OneproxyTab() {
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="text-2xl font-bold text-text-main">{stats.total}</div>
|
||||
<div className="text-sm text-text-muted">Total Proxies</div>
|
||||
<div className="text-sm text-text-muted">{t("oneproxyTotalProxies")}</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="text-2xl font-bold text-green-600">{stats.active}</div>
|
||||
@@ -183,13 +183,15 @@ export default function OneproxyTab() {
|
||||
<div className="text-2xl font-bold text-text-main">
|
||||
{stats.avgQuality != null ? `${stats.avgQuality}` : "—"}
|
||||
</div>
|
||||
<div className="text-sm text-text-muted">Avg Quality</div>
|
||||
<div className="text-sm text-text-muted">{t("oneproxyAvgQuality")}</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="text-2xl font-bold text-text-main">
|
||||
{status?.lastSyncAt ? new Date(status.lastSyncAt).toLocaleTimeString() : "Never"}
|
||||
{status?.lastSyncAt
|
||||
? new Date(status.lastSyncAt).toLocaleTimeString()
|
||||
: t("oneproxyNever")}
|
||||
</div>
|
||||
<div className="text-sm text-text-muted">Last Sync</div>
|
||||
<div className="text-sm text-text-muted">{t("oneproxyLastSync")}</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
@@ -201,7 +203,7 @@ export default function OneproxyTab() {
|
||||
onChange={(e) => setFilterProtocol(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 text-text-main text-sm border border-border"
|
||||
>
|
||||
<option value="">All Protocols</option>
|
||||
<option value="">{t("oneproxyAllProtocols")}</option>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
<option value="socks4">SOCKS4</option>
|
||||
@@ -209,14 +211,14 @@ export default function OneproxyTab() {
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Country code (e.g. US)"
|
||||
placeholder={t("oneproxyCountryCodePlaceholder")}
|
||||
value={filterCountry}
|
||||
onChange={(e) => setFilterCountry(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 text-text-main text-sm border border-border w-40"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Min quality"
|
||||
placeholder={t("oneproxyMinQualityPlaceholder")}
|
||||
value={minQuality}
|
||||
onChange={(e) => setMinQuality(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 text-text-main text-sm border border-border w-32"
|
||||
@@ -226,7 +228,7 @@ export default function OneproxyTab() {
|
||||
|
||||
<Card className="p-4">
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-text-muted">Loading proxies...</div>
|
||||
<div className="text-center py-8 text-text-muted">{t("oneproxyLoadingProxies")}</div>
|
||||
) : proxies.length === 0 ? (
|
||||
<div className="text-center py-8 text-text-muted">
|
||||
No 1proxy proxies found. Click "Sync Now" to fetch free proxies.
|
||||
@@ -300,25 +302,27 @@ export default function OneproxyTab() {
|
||||
|
||||
{status && (
|
||||
<Card className="p-4">
|
||||
<h3 className="text-sm font-semibold text-text-main mb-2">Sync Status</h3>
|
||||
<h3 className="text-sm font-semibold text-text-main mb-2">
|
||||
{t("oneproxySyncStatusTitle")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-text-muted">Last sync: </span>
|
||||
<span className="text-text-muted">{t("oneproxyLastSyncLabel")} </span>
|
||||
<span className={status.lastSyncSuccess ? "text-green-600" : "text-red-600"}>
|
||||
{status.lastSyncSuccess ? "Success" : "Failed"}
|
||||
{status.lastSyncSuccess ? t("oneproxySuccess") : t("oneproxyFailed")}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted">Proxies fetched: </span>
|
||||
<span className="text-text-muted">{t("oneproxyProxiesFetched")} </span>
|
||||
<span className="text-text-main">{status.lastSyncCount}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted">Consecutive failures: </span>
|
||||
<span className="text-text-muted">{t("oneproxyConsecutiveFailures")} </span>
|
||||
<span className="text-text-main">{status.consecutiveFailures}</span>
|
||||
</div>
|
||||
{status.lastSyncError && (
|
||||
<div className="col-span-full">
|
||||
<span className="text-text-muted">Error: </span>
|
||||
<span className="text-text-muted">{t("oneproxyErrorLabel")} </span>
|
||||
<span className="text-red-600 text-xs">{status.lastSyncError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, Button } from "@/shared/components";
|
||||
|
||||
const EMPTY_PAYLOAD_RULES_TEMPLATE = {
|
||||
@@ -55,6 +56,7 @@ function getErrorMessage(payload: unknown): string {
|
||||
}
|
||||
|
||||
export default function PayloadRulesTab() {
|
||||
const t = useTranslations("settings");
|
||||
const [editorValue, setEditorValue] = useState(EMPTY_PAYLOAD_RULES_TEXT);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -162,7 +164,7 @@ export default function PayloadRulesTab() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold">Payload Rules</h3>
|
||||
<h3 className="text-lg font-semibold">{t("payloadRulesTitle")}</h3>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Configure request payload mutations by model and protocol. Changes are persisted in
|
||||
settings and hot reloaded into the runtime immediately after save.
|
||||
|
||||
@@ -849,7 +849,7 @@ export default function ProxyRegistryManager() {
|
||||
onClose={() => {
|
||||
if (!bulkSaving) setBulkOpen(false);
|
||||
}}
|
||||
title="Bulk Proxy Assignment"
|
||||
title={t("bulkProxyAssignment")}
|
||||
maxWidth="lg"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -874,7 +874,7 @@ export default function ProxyRegistryManager() {
|
||||
value={bulkProxyId}
|
||||
onChange={(e) => setBulkProxyId(e.target.value)}
|
||||
>
|
||||
<option value="">(clear assignment)</option>
|
||||
<option value="">{t("clearAssignment")}</option>
|
||||
{items.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name} ({item.type}://{item.host}:{item.port})
|
||||
|
||||
@@ -62,16 +62,17 @@ function SectionDescription({
|
||||
trigger: string;
|
||||
effect: string;
|
||||
}) {
|
||||
const t = useTranslations("settings");
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-2 text-xs text-text-muted sm:grid-cols-3">
|
||||
<div>
|
||||
<span className="font-semibold text-text-main">Scope:</span> {scope}
|
||||
<span className="font-semibold text-text-main">{t("resilienceScope")}</span> {scope}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-text-main">Trigger:</span> {trigger}
|
||||
<span className="font-semibold text-text-main">{t("resilienceTrigger")}</span> {trigger}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-text-main">Effect:</span> {effect}
|
||||
<span className="font-semibold text-text-main">{t("resilienceEffect")}</span> {effect}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -203,6 +204,7 @@ function RequestQueueCard({
|
||||
onSave: (next: RequestQueueSettings) => Promise<void>;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const t = useTranslations("settings");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
|
||||
@@ -216,7 +218,7 @@ function RequestQueueCard({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-xl text-primary">speed</span>
|
||||
<h2 className="text-lg font-bold">Request Queue & Rate</h2>
|
||||
<h2 className="text-lg font-bold">{t("resilienceRequestQueueTitle")}</h2>
|
||||
</div>
|
||||
<SectionDescription
|
||||
scope="Per request queue"
|
||||
@@ -248,7 +250,7 @@ function RequestQueueCard({
|
||||
{editing ? (
|
||||
<>
|
||||
<BooleanField
|
||||
label="Auto-enable for API-key providers"
|
||||
label={t("resilienceAutoEnableApiKeyProviders")}
|
||||
description="Enable queue protection by default for active API-key connections."
|
||||
checked={draft.autoEnableApiKeyProviders}
|
||||
onChange={(autoEnableApiKeyProviders) =>
|
||||
@@ -256,13 +258,13 @@ function RequestQueueCard({
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
label="Requests per minute"
|
||||
label={t("resilienceRequestsPerMinute")}
|
||||
value={draft.requestsPerMinute}
|
||||
min={1}
|
||||
onChange={(requestsPerMinute) => setDraft((prev) => ({ ...prev, requestsPerMinute }))}
|
||||
/>
|
||||
<NumberField
|
||||
label="Minimum time between requests"
|
||||
label={t("resilienceMinTimeBetweenRequests")}
|
||||
value={draft.minTimeBetweenRequestsMs}
|
||||
suffix="ms"
|
||||
onChange={(minTimeBetweenRequestsMs) =>
|
||||
@@ -270,7 +272,7 @@ function RequestQueueCard({
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
label="Concurrent requests"
|
||||
label={t("resilienceConcurrentRequests")}
|
||||
value={draft.concurrentRequests}
|
||||
min={1}
|
||||
onChange={(concurrentRequests) =>
|
||||
@@ -278,7 +280,7 @@ function RequestQueueCard({
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
label="Maximum queue wait time"
|
||||
label={t("resilienceMaxQueueWaitTime")}
|
||||
value={draft.maxWaitMs}
|
||||
min={1}
|
||||
suffix="ms"
|
||||
@@ -288,31 +290,33 @@ function RequestQueueCard({
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Auto-enable for API-key providers</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
{t("resilienceAutoEnableApiKeyProviders")}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{value.autoEnableApiKeyProviders ? "Enabled" : "Disabled"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Requests per minute</div>
|
||||
<div className="text-xs text-text-muted">{t("resilienceRequestsPerMinute")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{value.requestsPerMinute}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Minimum time between requests</div>
|
||||
<div className="text-xs text-text-muted">{t("resilienceMinTimeBetweenRequests")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{formatMs(value.minTimeBetweenRequestsMs)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Concurrent requests</div>
|
||||
<div className="text-xs text-text-muted">{t("resilienceConcurrentRequests")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{value.concurrentRequests}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Maximum queue wait time</div>
|
||||
<div className="text-xs text-text-muted">{t("resilienceMaxQueueWaitTime")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{formatMs(value.maxWaitMs)}
|
||||
</div>
|
||||
@@ -333,6 +337,7 @@ function ConnectionCooldownCard({
|
||||
onSave: (next: ResilienceResponse["connectionCooldown"]) => Promise<void>;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const t = useTranslations("settings");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
|
||||
@@ -347,7 +352,7 @@ function ConnectionCooldownCard({
|
||||
{editing ? (
|
||||
<>
|
||||
<NumberField
|
||||
label="Base cooldown"
|
||||
label={t("resilienceBaseCooldown")}
|
||||
value={current.baseCooldownMs}
|
||||
min={0}
|
||||
suffix="ms"
|
||||
@@ -356,7 +361,7 @@ function ConnectionCooldownCard({
|
||||
}
|
||||
/>
|
||||
<BooleanField
|
||||
label="Use upstream retry hints"
|
||||
label={t("resilienceUseUpstreamRetryHints")}
|
||||
description="Use upstream retry-after/reset values when available."
|
||||
checked={current.useUpstreamRetryHints}
|
||||
onChange={(useUpstreamRetryHints) =>
|
||||
@@ -368,7 +373,9 @@ function ConnectionCooldownCard({
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="text-text-muted">Use upstream 429 hints for breaker cooldown</span>
|
||||
<span className="text-text-muted">
|
||||
{t("resilienceUseUpstream429HintsForBreaker")}
|
||||
</span>
|
||||
<select
|
||||
className="rounded border border-border-default bg-surface-1 px-2 py-1 text-sm font-mono"
|
||||
value={
|
||||
@@ -396,9 +403,9 @@ function ConnectionCooldownCard({
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="default">Default (per provider)</option>
|
||||
<option value="on">Always on</option>
|
||||
<option value="off">Always off</option>
|
||||
<option value="default">{t("resilienceDefaultPerProvider")}</option>
|
||||
<option value="on">{t("resilienceAlwaysOn")}</option>
|
||||
<option value="off">{t("resilienceAlwaysOff")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-xs text-text-muted">
|
||||
@@ -409,7 +416,7 @@ function ConnectionCooldownCard({
|
||||
</p>
|
||||
</div>
|
||||
<NumberField
|
||||
label="Max backoff steps"
|
||||
label={t("resilienceMaxBackoffSteps")}
|
||||
value={current.maxBackoffSteps}
|
||||
min={0}
|
||||
onChange={(maxBackoffSteps) =>
|
||||
@@ -420,27 +427,27 @@ function ConnectionCooldownCard({
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-muted">Base cooldown</span>
|
||||
<span className="text-text-muted">{t("resilienceBaseCooldownLabel")}</span>
|
||||
<span className="font-mono text-text-main">{formatMs(current.baseCooldownMs)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-muted">Use upstream retry hints</span>
|
||||
<span className="text-text-muted">{t("resilienceUseUpstreamRetryHintsLabel")}</span>
|
||||
<span className="font-mono text-text-main">
|
||||
{current.useUpstreamRetryHints ? "Yes" : "No"}
|
||||
{current.useUpstreamRetryHints ? t("resilienceYes") : t("resilienceNo")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-muted">Use upstream 429 hints (breaker)</span>
|
||||
<span className="text-text-muted">{t("resilienceUseUpstream429BreakerLabel")}</span>
|
||||
<span className="font-mono text-text-main">
|
||||
{current.useUpstream429BreakerHints === true
|
||||
? "Yes"
|
||||
? t("resilienceYes")
|
||||
: current.useUpstream429BreakerHints === false
|
||||
? "No"
|
||||
: "Default"}
|
||||
? t("resilienceNo")
|
||||
: t("resilienceDefault")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-muted">Max backoff steps</span>
|
||||
<span className="text-text-muted">{t("resilienceMaxBackoffStepsLabel")}</span>
|
||||
<span className="font-mono text-text-main">{current.maxBackoffSteps}</span>
|
||||
</div>
|
||||
</>
|
||||
@@ -455,7 +462,7 @@ function ConnectionCooldownCard({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-xl text-primary">timer_off</span>
|
||||
<h2 className="text-lg font-bold">Connection Cooldown</h2>
|
||||
<h2 className="text-lg font-bold">{t("resilienceConnectionCooldownTitle")}</h2>
|
||||
</div>
|
||||
<SectionDescription
|
||||
scope="Individual connection"
|
||||
@@ -519,6 +526,7 @@ function ProviderBreakerCard({
|
||||
onSave: (next: ResilienceResponse["providerBreaker"]) => Promise<void>;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const t = useTranslations("settings");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
|
||||
@@ -533,7 +541,7 @@ function ProviderBreakerCard({
|
||||
{editing ? (
|
||||
<>
|
||||
<NumberField
|
||||
label="Failure threshold"
|
||||
label={t("resilienceFailureThreshold")}
|
||||
value={current.failureThreshold}
|
||||
min={1}
|
||||
onChange={(failureThreshold) =>
|
||||
@@ -541,7 +549,7 @@ function ProviderBreakerCard({
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
label="Reset timeout"
|
||||
label={t("resilienceResetTimeout")}
|
||||
value={current.resetTimeoutMs}
|
||||
min={1000}
|
||||
suffix="ms"
|
||||
@@ -553,11 +561,11 @@ function ProviderBreakerCard({
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-muted">Failure threshold</span>
|
||||
<span className="text-text-muted">{t("resilienceFailureThresholdLabel")}</span>
|
||||
<span className="font-mono text-text-main">{current.failureThreshold}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-muted">Reset timeout</span>
|
||||
<span className="text-text-muted">{t("resilienceResetTimeoutLabel")}</span>
|
||||
<span className="font-mono text-text-main">{formatMs(current.resetTimeoutMs)}</span>
|
||||
</div>
|
||||
</>
|
||||
@@ -574,7 +582,7 @@ function ProviderBreakerCard({
|
||||
<span className="material-symbols-outlined text-xl text-primary">
|
||||
electrical_services
|
||||
</span>
|
||||
<h2 className="text-lg font-bold">Circuit Breaker per Provider</h2>
|
||||
<h2 className="text-lg font-bold">{t("resilienceProviderBreakerTitle")}</h2>
|
||||
</div>
|
||||
<SectionDescription
|
||||
scope="Entire provider"
|
||||
@@ -619,6 +627,7 @@ function WaitForCooldownCard({
|
||||
onSave: (next: WaitForCooldownSettings) => Promise<void>;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const t = useTranslations("settings");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
|
||||
@@ -632,7 +641,7 @@ function WaitForCooldownCard({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-xl text-primary">hourglass_top</span>
|
||||
<h2 className="text-lg font-bold">Wait for Cooldown</h2>
|
||||
<h2 className="text-lg font-bold">{t("resilienceWaitForCooldown")}</h2>
|
||||
</div>
|
||||
<SectionDescription
|
||||
scope="Current client request"
|
||||
@@ -663,19 +672,19 @@ function WaitForCooldownCard({
|
||||
{editing ? (
|
||||
<>
|
||||
<BooleanField
|
||||
label="Enable server-side wait"
|
||||
label={t("resilienceEnableServerSideWait")}
|
||||
description="When enabled, OmniRoute waits for the first cooldown to expire and retries automatically."
|
||||
checked={draft.enabled}
|
||||
onChange={(enabled) => setDraft((prev) => ({ ...prev, enabled }))}
|
||||
/>
|
||||
<NumberField
|
||||
label="Maximum retries"
|
||||
label={t("resilienceMaximumRetries")}
|
||||
value={draft.maxRetries}
|
||||
min={0}
|
||||
onChange={(maxRetries) => setDraft((prev) => ({ ...prev, maxRetries }))}
|
||||
/>
|
||||
<NumberField
|
||||
label="Maximum wait per retry"
|
||||
label={t("resilienceMaximumWaitPerRetry")}
|
||||
value={draft.maxRetryWaitSec}
|
||||
min={0}
|
||||
suffix="sec"
|
||||
@@ -685,17 +694,17 @@ function WaitForCooldownCard({
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Enable server-side wait</div>
|
||||
<div className="text-xs text-text-muted">{t("resilienceEnableServerSideWait")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{value.enabled ? "Enabled" : "Disabled"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Maximum retries</div>
|
||||
<div className="text-xs text-text-muted">{t("resilienceMaximumRetries")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">{value.maxRetries}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Maximum wait per retry</div>
|
||||
<div className="text-xs text-text-muted">{t("resilienceMaximumWaitPerRetry")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{value.maxRetryWaitSec}s
|
||||
</div>
|
||||
|
||||
@@ -303,6 +303,7 @@ function StringListEditor({
|
||||
onChange: (next: string[]) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("settings");
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium text-text-main">{label}</span>
|
||||
@@ -324,7 +325,7 @@ function StringListEditor({
|
||||
size="sm"
|
||||
icon="close"
|
||||
disabled={disabled}
|
||||
aria-label="Remove entry"
|
||||
aria-label={t("routingRemoveEntry")}
|
||||
onClick={() => {
|
||||
const next = [...items];
|
||||
next.splice(idx, 1);
|
||||
@@ -356,6 +357,7 @@ function OpEditor({
|
||||
onChange: (next: any) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("settings");
|
||||
const updateField = (field: string, value: any) => onChange({ ...op, [field]: value });
|
||||
const kind = op?.kind as TransformOpKind | undefined;
|
||||
const opDescription = kind ? OP_KIND_DESCRIPTIONS[kind] : null;
|
||||
@@ -376,14 +378,14 @@ function OpEditor({
|
||||
return wrap(
|
||||
<div className="flex flex-col gap-2">
|
||||
<StringListEditor
|
||||
label="Needles (substrings to match)"
|
||||
label={t("routingNeedlesSubstrings")}
|
||||
hint={FIELD_HINTS.needles}
|
||||
items={op.needles || []}
|
||||
onChange={(next) => updateField("needles", next)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Toggle
|
||||
label="Case sensitive"
|
||||
label={t("routingCaseSensitive")}
|
||||
description={FIELD_HINTS.caseSensitive}
|
||||
checked={op.caseSensitive !== false}
|
||||
onChange={(c) => updateField("caseSensitive", c)}
|
||||
@@ -396,14 +398,14 @@ function OpEditor({
|
||||
return wrap(
|
||||
<div className="flex flex-col gap-2">
|
||||
<StringListEditor
|
||||
label="Prefixes"
|
||||
label={t("routingPrefixes")}
|
||||
hint={FIELD_HINTS.prefixes}
|
||||
items={op.prefixes || []}
|
||||
onChange={(next) => updateField("prefixes", next)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Toggle
|
||||
label="Case sensitive"
|
||||
label={t("routingCaseSensitive")}
|
||||
description={FIELD_HINTS.caseSensitive}
|
||||
checked={op.caseSensitive !== false}
|
||||
onChange={(c) => updateField("caseSensitive", c)}
|
||||
@@ -416,21 +418,21 @@ function OpEditor({
|
||||
return wrap(
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
label="Match"
|
||||
label={t("routingMatch")}
|
||||
hint={FIELD_HINTS.matchLiteral}
|
||||
value={op.match || ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateField("match", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Replacement"
|
||||
label={t("routingReplacement")}
|
||||
hint={FIELD_HINTS.replacementText}
|
||||
value={op.replacement || ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateField("replacement", e.target.value)}
|
||||
/>
|
||||
<Toggle
|
||||
label="Replace all occurrences"
|
||||
label={t("routingReplaceAllOccurrences")}
|
||||
description={FIELD_HINTS.allOccurrences}
|
||||
checked={op.allOccurrences !== false}
|
||||
onChange={(c) => updateField("allOccurrences", c)}
|
||||
@@ -443,21 +445,21 @@ function OpEditor({
|
||||
return wrap(
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
label="Pattern (regex)"
|
||||
label={t("routingPatternRegex")}
|
||||
hint={FIELD_HINTS.pattern}
|
||||
value={op.pattern || ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateField("pattern", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Flags"
|
||||
label={t("routingFlags")}
|
||||
hint={FIELD_HINTS.regexFlags}
|
||||
value={op.flags || "g"}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateField("flags", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Replacement"
|
||||
label={t("routingReplacement")}
|
||||
hint={FIELD_HINTS.replacementText}
|
||||
value={op.replacement || ""}
|
||||
disabled={disabled}
|
||||
@@ -468,7 +470,7 @@ function OpEditor({
|
||||
case "drop_block_if_contains":
|
||||
return wrap(
|
||||
<StringListEditor
|
||||
label="Needles"
|
||||
label={t("routingNeedles")}
|
||||
hint={FIELD_HINTS.needles}
|
||||
items={op.needles || []}
|
||||
onChange={(next) => updateField("needles", next)}
|
||||
@@ -480,7 +482,7 @@ function OpEditor({
|
||||
return wrap(
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-text-main">Block text</label>
|
||||
<label className="text-sm font-medium text-text-main">{t("routingBlockText")}</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={op.text || ""}
|
||||
@@ -491,7 +493,7 @@ function OpEditor({
|
||||
<p className="text-xs text-text-muted">{FIELD_HINTS.blockText}</p>
|
||||
</div>
|
||||
<Input
|
||||
label="Idempotency key"
|
||||
label={t("routingIdempotencyKey")}
|
||||
hint={FIELD_HINTS.idempotencyKey}
|
||||
value={op.idempotencyKey || ""}
|
||||
disabled={disabled}
|
||||
@@ -503,14 +505,14 @@ function OpEditor({
|
||||
return wrap(
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
label="Entrypoint"
|
||||
label={t("routingEntrypoint")}
|
||||
hint={FIELD_HINTS.billingEntrypoint}
|
||||
value={op.entrypoint || "sdk-cli"}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateField("entrypoint", e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Version format"
|
||||
label={t("routingVersionFormat")}
|
||||
hint={FIELD_HINTS.billingVersionFormat}
|
||||
value={op.versionFormat || "ex-machina"}
|
||||
disabled={disabled}
|
||||
@@ -521,7 +523,7 @@ function OpEditor({
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="CCH algorithm"
|
||||
label={t("routingCchAlgorithm")}
|
||||
hint={FIELD_HINTS.billingCchAlgo}
|
||||
value={op.cchAlgo || "sha256-first-user"}
|
||||
disabled={disabled}
|
||||
@@ -538,7 +540,7 @@ function OpEditor({
|
||||
return wrap(
|
||||
<div className="flex flex-col gap-2">
|
||||
<StringListEditor
|
||||
label="Words to obfuscate (ZWJ inserted after first char)"
|
||||
label={t("routingWordsToObfuscate")}
|
||||
hint={FIELD_HINTS.obfuscateWords}
|
||||
items={op.words || []}
|
||||
onChange={(next) => updateField("words", next)}
|
||||
@@ -994,7 +996,7 @@ export default function RoutingTab() {
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Antigravity Signature Cache Mode</h3>
|
||||
<h3 className="text-lg font-semibold">{t("routingAntigravitySignatureTitle")}</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Control whether OmniRoute reuses only stored Gemini thought signatures or accepts
|
||||
validated client-provided signatures in Antigravity-compatible tool-call flows.
|
||||
@@ -1068,7 +1070,7 @@ export default function RoutingTab() {
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<h4 className="text-sm font-semibold mb-2">Header fingerprint (per provider)</h4>
|
||||
<h4 className="text-sm font-semibold mb-2">{t("routingHeaderFingerprintTitle")}</h4>
|
||||
<p className="text-xs text-text-muted mb-2">
|
||||
{t("cliFingerprintEnabled", { count: cliCompatProviderSet.size })}
|
||||
</p>
|
||||
@@ -1228,7 +1230,7 @@ export default function RoutingTab() {
|
||||
role="alert"
|
||||
className="mb-3 rounded border border-red-500/40 bg-red-500/10 p-2 text-xs text-red-300"
|
||||
>
|
||||
<span className="font-medium">⚠ Server rejected save:</span>{" "}
|
||||
<span className="font-medium">{t("routingServerRejectedSave")}</span>{" "}
|
||||
<span className="break-words font-mono">{providerSaveErrors[providerId]}</span>
|
||||
<p className="mt-1 text-[11px] text-red-200/80">
|
||||
Your local edits are kept. Fix the field above and the next change will
|
||||
@@ -1299,7 +1301,7 @@ export default function RoutingTab() {
|
||||
{/* Add op row */}
|
||||
<div className="flex items-end gap-2 mb-3">
|
||||
<Select
|
||||
label="Add a transform op"
|
||||
label={t("routingAddTransformOp")}
|
||||
className="flex-1"
|
||||
value={selectedKind}
|
||||
onChange={(e) =>
|
||||
@@ -1398,7 +1400,7 @@ export default function RoutingTab() {
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Client Cache Control</h3>
|
||||
<h3 className="text-lg font-semibold">{t("routingClientCacheControlTitle")}</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Configure whether OmniRoute preserves client-provided cache_control markers
|
||||
</p>
|
||||
@@ -1466,7 +1468,7 @@ export default function RoutingTab() {
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Zero-Config Auto-Routing</h3>
|
||||
<h3 className="text-lg font-semibold">{t("routingZeroConfigTitle")}</h3>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Enable automatic provider selection using the auto/ prefix. When enabled, requests
|
||||
to auto, auto/coding, auto/fast, etc. will dynamically route across all connected
|
||||
@@ -1488,7 +1490,7 @@ export default function RoutingTab() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-border/30">
|
||||
<label className="block text-sm font-medium mb-2">Default Auto Variant</label>
|
||||
<label className="block text-sm font-medium mb-2">{t("routingDefaultAutoVariant")}</label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{[
|
||||
{ value: "lkgp", label: "LKGP", desc: "Last Known Good Provider" },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user