Compare commits

...

63 Commits

Author SHA1 Message Date
diegosouzapw
476ef48fa5 test: avoid url substring assertion 2026-05-02 05:01:44 -03:00
diegosouzapw
4b9c129e1c fix: harden security scan findings 2026-05-02 04:51:38 -03:00
diegosouzapw
b75b52eefb fix(migrations): resolve NaN issue in gap reconciliation logic
fix(bundle): exclude os module from browser bundle
2026-05-02 02:19:36 -03:00
Diego Rodrigues de Sa e Souza
230f0410f7 Merge pull request #1851 from diegosouzapw/release/v3.7.8
Release v3.7.8
2026-05-02 01:58:50 -03:00
diegosouzapw
59f4d42795 chore(test): sync open-mode runners and assertions
Update ecosystem and Playwright test environments to boot in open mode
with CLI tools enabled and empty auth defaults when local credentials
are not required.

Refresh A2A, provider, token-count, and circuit-breaker expectations to
match current response semantics and profile-driven thresholds. Remove
obsolete migration regression coverage tied to retired upgrade paths and
add a scratch migration probe for manual verification.
2026-05-02 01:48:58 -03:00
diegosouzapw
ebbd7c34c8 Merge remote-tracking branch 'origin/release/v3.7.8' into release/v3.7.8 2026-05-02 01:18:37 -03:00
Raxxoor
1e3c08565c fix(codex): sanitize responses replay state (#1868)
Integrated into release/v3.7.8
2026-05-02 01:17:48 -03:00
diegosouzapw
e4beb49c6d fix(db): implement robust idempotent schema validation for migration gaps 2026-05-02 01:13:57 -03:00
diegosouzapw
8400dbea7d chore(release): update changelog for PR 1866 2026-05-02 00:52:02 -03:00
Raxxoor
c12b7546a8 fix(cli): add Gemini CLI fingerprint (#1866)
Integrated into release/v3.7.8
2026-05-02 00:48:54 -03:00
diegosouzapw
01092fa349 fix: resolve runtime metadata and migration directory lookup
Export shared runtime platform and architecture helpers so provider
registry header generation uses the same process-based detection logic
as header profiles instead of direct os module calls.

Improve migration directory resolution by searching upward through
common project layouts and checking multiple candidate paths before
falling back to OMNIROUTE_MIGRATIONS_DIR errors.
2026-05-01 22:00:04 -03:00
diegosouzapw
41ff569260 fix: resolve maxConcurrent display and binding bug (#1859) 2026-05-01 21:48:20 -03:00
dependabot[bot]
1b2a4fd659 build(deps): bump SonarSource/sonarqube-scan-action from 7 to 8 (#1864)
Integrated into release/v3.7.8
2026-05-01 21:29:16 -03:00
Aculeasis
00182afb2f feat(usage): add NanoGPT subscription quota tracking to Limits & Quotas dashboard (#1865)
Integrated into release/v3.7.8
2026-05-01 21:29:13 -03:00
Raxxoor
a10a4bf491 fix(settings): restore CLI fingerprint toggles (#1863)
Integrated into release/v3.7.8
2026-05-01 20:24:16 -03:00
Diego Rodrigues de Sa e Souza
399a911675 fix(tests): update quota exhaustion thresholds 2026-05-01 20:23:31 -03:00
Antigravity Assistant
7ec572ca47 fix(auth): prevent token refresh race conditions causing refresh_token_reused errors 2026-05-01 17:59:20 -03:00
Antigravity Assistant
66193a2ac8 chore(release): include PR #1861 in changelog 2026-05-01 17:45:31 -03:00
Paijo
95a43597c9 fix(executor): fix urlSuffix and authHeader bugs causing auth failures (Issue #1846) (#1861)
Integrated into release/v3.7.8
2026-05-01 17:44:24 -03:00
Antigravity Assistant
3d78cd6848 docs: add PRs 1856 and 1857 to changelog 2026-05-01 16:12:33 -03:00
Raxxoor
e21ebaa389 fix(grok-web): stabilize tool calling and response parsing (#1857)
Integrated into release/v3.7.8
2026-05-01 16:11:32 -03:00
backryun
d2ce20dc5c fix(maritalk):Update Model list & Implementation of the latest API (#1856)
Integrated into release/v3.7.8
2026-05-01 16:11:13 -03:00
Antigravity Assistant
c766aa5403 fix(tooling): strip empty arrays from WebSearch and fix Codex prompt body propagation (#1852, #1853)
- Strips empty arrays from streaming tool arguments in responsesTransformer and openai-to-claude
- Fixes zero-results issue when Claude Code invokes WebSearch with allowed_domains: []
- Resolves prompt body propagation failures by ensuring messages-to-input mapping happens before system role conversion in codex executor
2026-05-01 15:45:54 -03:00
Antigravity Assistant
35d56358b8 docs(readme): highlight supported tools and core platform benefits
Refresh the README marketing and onboarding content to better explain
why OmniRoute is useful in day-to-day AI coding workflows.

Add a supported CLI tools section and expand the benefits overview to
cover prompt compression, format translation, proxy routing, and
multi-modal capabilities.
2026-05-01 15:26:01 -03:00
Antigravity Assistant
29c170f83a docs(guides): add dedicated setup, docker, compression, and resilience manuals
Break out long-form README content into focused documentation pages for
core onboarding and operations topics.

Refresh the README navigation to point readers to the new guides while
keeping high-level product sections easier to scan.
2026-05-01 14:51:46 -03:00
Antigravity Assistant
64b8194210 chore(release): update CHANGELOG for PR 1855 2026-05-01 13:52:00 -03:00
Antigravity Assistant
f8a23f41a6 docs(readme): document prompt compression savings and routing flow
Highlight automatic prompt compression as a core capability with
estimated token savings, supported compression modes, and a visual
request pipeline.

Update the architecture overview to reflect broader format support,
rate limit handling, and the cost-saving impact alongside fallback
routing.
2026-05-01 13:48:46 -03:00
Antigravity Assistant
cf703138f2 docs(readme): refresh project overview and multilingual navigation
Highlight the broader platform capabilities in the README intro with
updated positioning, feature coverage, and access links.

Rework the top-level layout to emphasize quick navigation, language
availability, and discovery of installation and deployment paths.
2026-05-01 13:48:46 -03:00
Antigravity Assistant
abe8cae083 docs(guides): reorganize documentation into dedicated usage manuals
Expand the documentation set with standalone guides for free-tier
providers, proxy configuration, and PWA installation while refreshing
the README badge layout and navigation structure.

Remove the docs ignore allowlist so the full documentation tree can be
tracked directly, and drop the outdated context-relay feature page.
2026-05-01 13:48:46 -03:00
backryun
fa950a8a49 fix(Upstage):Update Model list (#1855)
Integrated into release/v3.7.8
2026-05-01 13:48:13 -03:00
Antigravity Assistant
a906bda7fc fix(tool-remapper): selectively remap tool names based on request context (#1852) 2026-05-01 12:56:29 -03:00
Antigravity Assistant
8f14452614 docs: add PR #1854 to changelog 2026-05-01 12:39:21 -03:00
Antigravity Assistant
2666043daf docs(readme): add Android Termux deployment guide
Document running OmniRoute on Android via Termux and add README
navigation links for proxy/geo, PWA, and Android sections.

Highlight Android support in the project overview and include
installation steps, use cases, auto-start instructions, LAN access,
and production recommendations.
2026-05-01 12:37:30 -03:00
Paijo
20358bc8bf fix(combo): stabilize provider routing at 500+ connections (Issue #1846) (#1854)
Integrated into release/v3.7.8
2026-05-01 12:37:09 -03:00
Antigravity Assistant
b44d1d9b90 Merge branch 'main' into release/v3.7.8 2026-05-01 10:04:07 -03:00
Antigravity Assistant
eadcf43ca0 fix(types): resolve TypeScript strictness errors in validation 2026-05-01 10:02:03 -03:00
Antigravity Assistant
4e79d4708c fix(providers): update securityBlocked check to use 503 instead of 400 2026-05-01 09:59:00 -03:00
Antigravity Assistant
a7df2b0b55 fix(tests): expect 503 instead of 400 for guardrail validation 2026-05-01 09:53:51 -03:00
Antigravity Assistant
7b575f38aa chore(release): finalize v3.7.8 stabilization, fix a2a status codes and open issues 2026-05-01 09:53:51 -03:00
Antigravity Assistant
f7d45ef31f chore(release): v3.7.8 — rate limit watchdog, 1proxy marketplace, grok 4.3 2026-05-01 09:53:51 -03:00
Antigravity Assistant
85f2f8d8d8 chore: bump version to 3.7.9-pre 2026-05-01 09:53:51 -03:00
Antigravity Assistant
1f9b340d13 chore(release): finalize v3.7.8 stabilization, fix a2a status codes and open issues 2026-05-01 09:30:21 -03:00
Antigravity Assistant
fb95689601 chore(release): v3.7.8 — rate limit watchdog, 1proxy marketplace, grok 4.3 2026-05-01 08:49:08 -03:00
backryun
d8e977cb42 Add Grok 4.3 + Add Xiaomi Mimo TTS provider (#1837)
Integrated into release/v3.7.8
2026-05-01 08:41:19 -03:00
Randi
89886e69a9 fix(workflow): build docker images on version tags (#1838)
Integrated into release/v3.7.8
2026-05-01 08:41:19 -03:00
Randi
8ad5f0dbcc Hide combo compression controls when disabled (#1840)
Integrated into release/v3.7.8
2026-05-01 08:41:19 -03:00
janeza2
e9bb874ddc feat(providerRegistry): add muse-spark-web provider with multiple models and reasoning support (#1843)
Integrated into release/v3.7.8
2026-05-01 08:41:19 -03:00
Paijo
9042d5049d feat: integrate 1proxy free proxy marketplace (closes #1788) (#1847)
Integrated into release/v3.7.8
2026-05-01 08:41:19 -03:00
bambuvnn
3a8defed09 fix: tolerate missing request detail logs table (#1848)
Integrated into release/v3.7.8
2026-05-01 08:41:19 -03:00
Antigravity Assistant
0428943d84 feat: merge PR 1839 manually (rate limit watchdog and env) 2026-05-01 08:38:42 -03:00
Antigravity Assistant
373181dade chore: resolve conflicts 2026-05-01 08:38:05 -03:00
Antigravity Assistant
f44fb3d9b7 chore: bump version to 3.7.9-pre 2026-05-01 08:36:51 -03:00
backryun
32e0a7cb16 Add Grok 4.3 + Add Xiaomi Mimo TTS provider (#1837)
Integrated into release/v3.7.8
2026-05-01 08:36:24 -03:00
Randi
4ac29c4d9b fix(workflow): build docker images on version tags (#1838)
Integrated into release/v3.7.8
2026-05-01 08:36:20 -03:00
Randi
3d70ad414e Hide combo compression controls when disabled (#1840)
Integrated into release/v3.7.8
2026-05-01 08:36:15 -03:00
janeza2
21f4f8ddce feat(providerRegistry): add muse-spark-web provider with multiple models and reasoning support (#1843)
Integrated into release/v3.7.8
2026-05-01 08:36:12 -03:00
Paijo
f3226b7f8d feat: integrate 1proxy free proxy marketplace (closes #1788) (#1847)
Integrated into release/v3.7.8
2026-05-01 08:36:07 -03:00
bambuvnn
ea9d9e09ba fix: tolerate missing request detail logs table (#1848)
Integrated into release/v3.7.8
2026-05-01 08:35:52 -03:00
Antigravity Assistant
ea6f556ed2 chore(workflow): fix generate-release github action collision for releases and remove any types 2026-05-01 01:15:28 -03:00
Antigravity Assistant
902d0b9f41 Merge release/v3.7.7 into PR branch 2026-04-30 15:47:11 -03:00
payne0420
1b26018534 fix(rate-limit): wire auto-enabled limiters through getLimiter so heartbeat is registered
Addresses chatgpt-codex-connector review on #1828: reconcileEnabledConnections
created auto-enabled limiters via direct \`new Bottleneck(...)\`, bypassing
the \`executing\` event listener that updates lastDispatchAt. The watchdog
then read \`lastDispatchAt.get(key) ?? 0\` and computed \`stalledMs = now - 0\`
≈ Unix time, force-resetting healthy idle limiters during normal reservoir-
refresh waits.

- Reconcile path now calls getLimiter(provider, connectionId), which registers
  both queued/executing listeners and seeds lastDispatchAt to now.
- Watchdog also hardened: if lastDispatch is undefined for any reason, seed
  it on the current tick and skip — never compute stall against epoch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:49:29 +00:00
payne0420
3963575529 fix(rate-limit): raise wedge threshold from 5s to 120s
Addresses gemini-code-assist review on #1828: at 5s, the watchdog can
false-trigger on a healthy limiter that's correctly waiting on its
reservoir refresh (60s default) or adaptive minTime (up to ~60s for
1-RPM providers). 120s gives a 2× margin against both while still
catching the actual wedge case (observed at 3+ minutes stalled).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:30:05 +00:00
payne0420
294751d8a3 fix(rate-limit): watchdog, env override, and stage tracing to prevent silent wedges
The auto-enabled Bottleneck safety net for API-key providers can desync
its internal state and silently stop dispatching jobs (queued > 0,
running == 0, executing == 0). Once that happens nothing recovers without
a process restart. This change adds a self-heal layer plus operator
visibility, and keeps existing behavior intact by default.

- Watchdog (30s tick) detects wedged limiters and force-resets them with
  stop({dropWaitingJobs:true}) so queued callers actually fail rather than
  stall forever.
- RATE_LIMIT_AUTO_ENABLE env var overrides the dashboard auto-enable
  toggle (highest precedence). Lets operators flip the safety net off
  during an incident without needing dashboard access.
- disableRateLimitProtection now uses stop({dropWaitingJobs:true})
  instead of disconnect(); disconnect leaks queued promises (observed
  while debugging this).
- STAGE_TRACE checkpoints in chatCore (post_injection, post_translation,
  pre/post_semaphore, pre/inside/post_rate_limit, pre/post_executor)
  pinpoint which await a hung request was stuck on.
- New /api/admin/concurrency endpoint exposes per-limiter counts and
  semaphore stats so operators can see liveness in real time.
- Test coverage for the env-var override.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:18:25 +00:00
168 changed files with 15072 additions and 3117 deletions

View File

@@ -263,8 +263,8 @@ NOTES=$(awk "/^## \\[$VERSION\\]/{flag=1; next} /^---/{if(flag) {flag=0; exit}}
if [ -z "$NOTES" ]; then NOTES="OmniRoute v$VERSION Release"; fi
git tag -a "v$VERSION" -m "Release v$VERSION"
git push origin --tags
gh release create "v$VERSION" --title "v$VERSION" --notes "$NOTES" --target main
git push origin "v$VERSION"
gh release create "v$VERSION" --repo diegosouzapw/OmniRoute --title "v$VERSION" --notes "$NOTES" --target main || gh release edit "v$VERSION" --repo diegosouzapw/OmniRoute --title "v$VERSION" --notes "$NOTES"
```
### 14. 🐳 Trigger Docker Hub build (MANDATORY — keep npm and Docker in sync)

View File

@@ -82,7 +82,7 @@ For each bug issue, perform the full analysis:
2. **Read ALL comments** — including bot triage comments (Kilo, etc.) and owner/community responses. Pay attention to:
- Whether someone already responded with a fix
- Whether a community member confirmed the issue is resolved
- Whether the issue was marked as duplicate by a bot
- Whether the issue was marked as duplicate by a bot. **WARNING: DO NOT blindly trust bot duplicate labels (e.g., kilo-duplicate). Bots make mistakes. You MUST read the full conversation and do your own independent analysis to determine if it is truly a duplicate or a distinct bug.**
3. **Identify the claimed error** — extract the exact error message, status code, and provider/model involved
#### 5b. Check Information Sufficiency
@@ -98,14 +98,14 @@ Verify the issue contains enough to act on:
For each bug, classify into one of 5 actions:
| Disposition | When to Apply | Action |
| ---------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| **✅ CLOSE — Already Fixed** | Owner responded with fix + no user follow-up, OR community confirmed fix | Close with comment citing which version fixed it |
| **✅ CLOSE — Duplicate** | Bot flagged >85% similarity + user provides no new info | Close referencing the original issue |
| **✅ CLOSE — Stale** | We requested logs/info > 7 days ago with no reply | Close thanking the user, invite to reopen if needed |
| **📝 RESPOND — Needs Info** | Issue is real but missing critical reproduction details | Comment asking for specifics per `/issue-triage` |
| **📝 RESPOND — User Config** | Error is caused by unsupported env (Node version, wrong model path, missing API enablement) | Comment explaining the user-side fix |
| **🔧 FIX — Code Change** | Root cause is confirmed in the codebase | Research, propose solution in report, wait for approval |
| Disposition | When to Apply | Action |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| **✅ CLOSE — Already Fixed** | Owner responded with fix + no user follow-up, OR community confirmed fix | Close with comment citing which version fixed it |
| **✅ CLOSE — Duplicate** | You have independently verified the issue is a duplicate (do NOT rely solely on bot flags) + user provides no new info | Close referencing the original issue |
| **✅ CLOSE — Stale** | We requested logs/info > 7 days ago with no reply | Close thanking the user, invite to reopen if needed |
| **📝 RESPOND — Needs Info** | Issue is real but missing critical reproduction details | Comment asking for specifics per `/issue-triage` |
| **📝 RESPOND — User Config** | Error is caused by unsupported env (Node version, wrong model path, missing API enablement) | Comment explaining the user-side fix |
| **🔧 FIX — Code Change** | Root cause is confirmed in the codebase | Research, propose solution in report, wait for approval |
#### 5d. For "FIX — Code Change" Issues

View File

@@ -31,6 +31,7 @@ jobs:
cache: npm
- run: npm ci
- run: npm run check:node-runtime
- run: npm run audit:deps
- run: npm run lint
- run: npm run check:cycles
- run: npm run check:route-validation:t06
@@ -274,7 +275,7 @@ jobs:
echo "SonarQube scan skipped because SONAR_TOKEN or SONAR_HOST_URL is not configured." >> "$GITHUB_STEP_SUMMARY"
- name: SonarQube Scan
if: ${{ env.SONAR_TOKEN != '' && env.SONAR_HOST_URL != '' }}
uses: SonarSource/sonarqube-scan-action@v7
uses: SonarSource/sonarqube-scan-action@v8
env:
SONAR_TOKEN: ${{ env.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ env.SONAR_HOST_URL }}

View File

@@ -4,6 +4,8 @@ on:
push:
branches:
- main
tags:
- "v*"
release:
types: [published]
workflow_dispatch:

43
.gitignore vendored
View File

@@ -68,46 +68,6 @@ antigravity-manager-analysis/
.sisyphus/
.plans/
# docs (allow specific tracked files)
docs/*
!docs/ARCHITECTURE.md
!docs/CODEBASE_DOCUMENTATION.md
!docs/CONTRIBUTING.md
!docs/USER_GUIDE.md
!docs/API_REFERENCE.md
!docs/TERMUX_GUIDE.md
!docs/TROUBLESHOOTING.md
!docs/EXECUTION_CONTEXT_PROVIDER_SYNC.md
!docs/TASK_NEBIUS_BACKEND_ENABLEMENT.md
!docs/frontend-backend-provider-gap-report.md
!docs/openapi.yaml
!docs/RELEASE_CHECKLIST.md
!docs/PLANO-IMPLANTACAO.md
!docs/TASKS.md
!docs/FASE-*.md
!docs/adr/
!docs/cli-tools/
!docs/planning/
!docs/improvement-plans/
!docs/api/
!docs/VM_DEPLOYMENT_GUIDE.md
!docs/FEATURES.md
!docs/screenshots/
!docs/i18n/
!docs/i18n/**
!docs/features/
!docs/features/**
!docs/A2A-SERVER.md
!docs/AUTO-COMBO.md
!docs/MCP-SERVER.md
!docs/CLI-TOOLS.md
!docs/COVERAGE_PLAN.md
!docs/ENVIRONMENT.md
!docs/UNINSTALL.md
!docs/I18N.md
!docs/FLY_IO_DEPLOYMENT_GUIDE.md
# open-sse tests
open-sse/test/*
@@ -139,8 +99,7 @@ app.__qa_backup/
# npm publish still includes it via package.json "files" field
/app/
# Electron (subproject dependency lock and build artifacts)
electron/package-lock.json
# Electron
electron/dist-electron/
electron/node_modules/
icon.iconset/

View File

@@ -10,7 +10,7 @@ with **MCP Server** (29 tools), **A2A v0.3 Protocol**, and **Electron desktop ap
## Stack
- **Runtime**: Next.js 16 (App Router), Node.js ≥18 <24, ES Modules (`"type": "module"`)
- **Runtime**: Next.js 16 (App Router), Node.js `>=20.20.2 <21`, `>=22.22.2 <23`, or `>=24.0.0 <25`, ES Modules (`"type": "module"`)
- **Language**: TypeScript 5.9 (`src/`) + JavaScript (`open-sse/`, `electron/`)
- **Database**: better-sqlite3 (SQLite) — `DATA_DIR` configurable, default `~/.omniroute/`
- **Streaming**: SSE via `open-sse` internal workspace package

View File

@@ -4,6 +4,38 @@
---
## [3.7.8] — 2026-05-01
### ✨ New Features
- **feat(providers):** add Grok 4.3 and Xiaomi Mimo TTS provider (#1837)
- **feat(core):** implement Rate Limit Watchdog with environment override capability to detect and reset stalled queues (#1839)
- **feat(providers):** add muse-spark-web provider with multiple models and reasoning support (#1843)
- **feat(1proxy):** integrate 1proxy free proxy marketplace with dashboard management and new MCP tools (closes #1788) (#1847)
### 🐛 Bug Fixes
- **fix(codex):** sanitize Responses replay state to prevent internal assistant commentary from leaking (#1868 — thanks @dhaern)
- **fix(cli):** add capture-backed Gemini CLI fingerprint (#1866)
- **fix(ui):** hide combo compression controls when the global setting is disabled (#1840)
- **fix(db):** tolerate missing request_detail_logs table for legacy deployments (#1848)
- **fix(core):** remove unneeded \`store\` payload parameter for providers lacking support (closes #1841)
- **fix(core):** ensure safeOutboundFetch and A2A routers return 503 Service Unavailable when security guardrails are triggered
- **fix(usage):** correct Unix seconds vs milliseconds parsing logic for Kiro AI quota reset (closes #1849)
- **fix(ui):** apply robust NaN handling, ensure 24h consistency, and fix missing hour slots in Compression Analytics (closes #1844)
- **fix(ui):** implement short number formatting for token consumption metrics on cache pages to prevent overflow (closes #1842)
- **fix(combo):** stabilize provider routing at 500+ connections by bounding semaphore queues and adjusting circuit breaker tracking (closes #1846) (#1854)
- **fix(maritalk):** update Maritalk model list, use Authorization Key header, and align with latest API endpoints (#1856)
- **fix(grok-web):** stabilize tool calling (bash, readFile, webSearch) and response parsing by mapping native Grok intents to standard OpenAI payloads (#1857)
- **fix(providers):** correctly map and expose the Upstage embedding and chat model catalogs (#1855)
- **fix(executor):** apply proper urlSuffix and custom authHeaders for unknown registry-based providers in DefaultExecutor (closes #1846) (#1861)
### 🛠️ Maintenance
- **fix(workflow):** build docker images on version tags (#1838)
---
## [3.7.7] — 2026-04-30
### ✨ New Features

View File

@@ -8,7 +8,7 @@ Thank you for your interest in contributing! This guide covers everything you ne
### Prerequisites
- **Node.js** >= 18 < 24 (recommended: 22 LTS)
- **Node.js** `>=20.20.2 <21`, `>=22.22.2 <23`, or `>=24.0.0 <25` (recommended: 24 LTS)
- **npm** 10+
- **Git**

2589
README.md

File diff suppressed because it is too large Load Diff

183
docs/COMPRESSION_GUIDE.md Normal file
View File

@@ -0,0 +1,183 @@
# 🗜️ Prompt Compression Guide — OmniRoute
> Save 15-75% on token costs automatically. For a quick overview, see the [README Compression section](../README.md#%EF%B8%8F-prompt-compression--save-15-75-tokens-automatically).
## Overview
OmniRoute implements a modular prompt compression pipeline that runs **proactively** before requests hit upstream providers. This means your token savings happen transparently — no changes needed to your workflow.
```
Client Request
→ Compression Strategy Selector
→ Combo override? → Use combo setting
→ Auto-trigger threshold? → Use auto mode
→ Default mode? → Use global setting
→ Off? → Skip compression
→ Selected Compression Mode
→ Off: No compression
→ Lite: Safe whitespace/formatting cleanup (~15%)
→ Standard: Caveman-speak filler removal (~30%)
→ Aggressive: History aging + summarization (~50%)
→ Ultra: Heuristic pruning + code-block thinning (~75%)
→ Compressed Request → Provider
```
---
## Compression Modes
### Off
No compression applied. All messages pass through unchanged.
### Lite Mode (~15% savings, <1ms latency)
The safest mode — zero semantic change, only formatting cleanup:
| Technique | Description |
| ------------------------ | ------------------------------------------------- |
| `collapseWhitespace` | Merge consecutive blank lines and trailing spaces |
| `dedupSystemPrompt` | Remove duplicate system messages |
| `compressToolResults` | Compress verbose tool/function outputs |
| `removeRedundantContent` | Strip repeated instructions |
| `replaceImageUrls` | Shorten base64 image data URIs |
**Best for:** Always-on usage, safety-critical workflows.
### Standard Mode (~30% savings)
Inspired by [Caveman](https://github.com/JuliusBrussee/caveman) — removes filler words and verbose phrasing while preserving meaning:
- Removes filler words ("please", "I think", "basically", "actually")
- Condenses verbose phrases ("in order to" → "to", "as a result of" → "because")
- Strips polite hedging ("Would you mind...", "If you could possibly...")
- 30+ regex rules tuned for coding prompts
**Best for:** Daily coding workflows, cost-conscious teams.
### Aggressive Mode (~50% savings)
Smart history management for long sessions:
- **Message Aging** — older messages get progressively compressed
- **Tool Result Summarization** — long tool outputs replaced with summaries
- **Structural Integrity Guards** — ensures `tool_use` + `tool_result` pairs stay consistent
- **Context Window Awareness** — respects per-model token limits
**Best for:** Extended debugging sessions, large codebases.
### Ultra Mode (~75% savings)
Maximum compression for token-critical scenarios:
- **Heuristic Pruning** — removes messages below relevance threshold
- **Code Block Thinning** — compresses repetitive code examples
- **Binary Search Truncation** — finds optimal cut point for context window
- All Aggressive mode features included
**Best for:** When you're hitting context limits repeatedly.
---
## Token Savings Visualization
```
Without compression: 47K tokens sent to LLM
With Lite: 40K tokens sent (15% saved — safe, always-on)
With Standard: 33K tokens sent (30% saved — caveman-speak rules)
With Aggressive: 24K tokens sent (50% saved — aging + summarization)
With Ultra: 12K tokens sent (75% saved — heuristic pruning)
```
---
## Configuration
### Dashboard
Navigate to `Dashboard → Settings → Compression`:
- **Default Mode** — sets the system-wide compression mode
- **Auto-Trigger Threshold** — automatically engage compression when token count exceeds threshold
- **Per-Combo Override** — each combo can have its own compression mode
### Per-Combo Override
In `Dashboard → Combos → [Your Combo] → Advanced`, set compression mode per combo:
```txt
Combo: "free-forever"
Mode: Standard
Targets:
1. gc/gemini-3-flash
2. if/kimi-k2-thinking
```
This lets you use aggressive compression on free providers while keeping lite mode on paid subscriptions.
### API
```bash
# Get compression settings
curl http://localhost:20128/api/settings/compression
# Update compression settings
curl -X PUT http://localhost:20128/api/settings/compression \
-H "Content-Type: application/json" \
-d '{"defaultMode":"lite","autoTriggerThreshold":32000}'
```
---
## What Gets Protected
The compression engine **always preserves:**
- ✅ Code blocks (fenced and inline)
- ✅ URLs and file paths
- ✅ JSON structures and structured data
- ✅ API keys, tokens, and identifiers
- ✅ Mathematical expressions
- ✅ Tool/function call definitions
- ✅ System prompts (in lite mode)
---
## Compression Stats
Every compressed request includes stats in the server logs:
```json
{
"originalTokens": 47200,
"compressedTokens": 40120,
"savingsPercent": 15.0,
"techniquesUsed": ["collapseWhitespace", "dedupSystemPrompt"],
"mode": "lite",
"latencyMs": 0.8
}
```
---
## Phase Roadmap
| Phase | Modes | Status |
| ------- | ------------------------------------ | ---------- |
| Phase 1 | Off, Lite | ✅ Shipped |
| Phase 2 | Standard, Aggressive, Ultra | ✅ Shipped |
| Phase 3 | Per-model adaptive, ML-based pruning | 🗓️ Planned |
---
## Acknowledgments
Standard mode compression rules are inspired by **[Caveman](https://github.com/JuliusBrussee/caveman)** by **[JuliusBrussee](https://github.com/JuliusBrussee)** (⭐ 51K+) — the viral "why use many token when few token do trick" project.
---
## See Also
- [Environment Config](ENVIRONMENT.md) — Compression environment variables
- [Architecture Guide](ARCHITECTURE.md) — Compression pipeline internals
- [User Guide](USER_GUIDE.md) — Getting started with compression

119
docs/DOCKER_GUIDE.md Normal file
View File

@@ -0,0 +1,119 @@
# 🐳 Docker Guide — OmniRoute
> Complete Docker deployment reference. For a quick start, see the [README Docker section](../README.md#-docker).
## Table of Contents
- [Quick Run](#quick-run)
- [With Environment File](#with-environment-file)
- [Docker Compose](#docker-compose)
- [Docker Compose with Caddy (HTTPS)](#docker-compose-with-caddy-https-auto-tls)
- [Cloudflare Quick Tunnel](#cloudflare-quick-tunnel)
- [Image Tags](#image-tags)
- [Important Notes](#important-notes)
---
## Quick Run
```bash
docker run -d \
--name omniroute \
--restart unless-stopped \
--stop-timeout 40 \
-p 20128:20128 \
-v omniroute-data:/app/data \
diegosouzapw/omniroute:latest
```
## With Environment File
```bash
# Copy and edit .env first
cp .env.example .env
docker run -d \
--name omniroute \
--restart unless-stopped \
--stop-timeout 40 \
--env-file .env \
-p 20128:20128 \
-v omniroute-data:/app/data \
diegosouzapw/omniroute:latest
```
## Docker Compose
```bash
# Base profile (no CLI tools)
docker compose --profile base up -d
# CLI profile (Claude Code, Codex, OpenClaw built-in)
docker compose --profile cli up -d
```
## Docker Compose with Caddy (HTTPS Auto-TLS)
OmniRoute can be securely exposed using Caddy's automatic SSL provisioning. Ensure your domain's DNS A record points to your server's IP.
```yaml
services:
omniroute:
image: diegosouzapw/omniroute:latest
container_name: omniroute
restart: unless-stopped
volumes:
- omniroute-data:/app/data
environment:
- PORT=20128
- NEXT_PUBLIC_BASE_URL=https://your-domain.com
caddy:
image: caddy:latest
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
command: caddy reverse-proxy --from https://your-domain.com --to http://omniroute:20128
volumes:
omniroute-data:
```
## Cloudflare Quick Tunnel
Dashboard support for Docker deployments includes a one-click **Cloudflare Quick Tunnel** on `Dashboard → Endpoints`. The first enable downloads `cloudflared` only when needed, starts a temporary tunnel to your current `/v1` endpoint, and shows the generated `https://*.trycloudflare.com/v1` URL directly below your normal public URL.
Endpoint tunnel panels (Cloudflare, Tailscale, ngrok) can be shown or hidden from `Settings → Appearance` without changing active tunnel state.
### Tunnel Notes
- Quick Tunnel URLs are temporary and change after every restart.
- Quick Tunnels are not auto-restored after an OmniRoute or container restart. Re-enable them from the dashboard when needed.
- Managed install currently supports Linux, macOS, and Windows on `x64` / `arm64`.
- Managed Quick Tunnels default to HTTP/2 transport to avoid noisy QUIC UDP buffer warnings in constrained container environments. Set `CLOUDFLARED_PROTOCOL=quic` or `auto` if you want a different transport.
- Docker images bundle system CA roots and pass them to managed `cloudflared`, which avoids TLS trust failures when the tunnel bootstraps inside the container.
- Set `CLOUDFLARED_BIN=/absolute/path/to/cloudflared` if you want OmniRoute to use an existing binary instead of downloading one.
## Image Tags
| Image | Tag | Size | Description |
| ------------------------ | -------- | ------ | --------------------- |
| `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release |
| `diegosouzapw/omniroute` | `3.7.8` | ~250MB | Current version |
Multi-platform: AMD64 + ARM64 native (Apple Silicon, AWS Graviton, Raspberry Pi).
## Important Notes
- **SQLite WAL Mode:** `docker stop` should be allowed to finish so OmniRoute can checkpoint the latest changes back into `storage.sqlite`. The bundled Compose files already set a 40s stop grace period. If you run the image directly, keep `--stop-timeout 40`.
- **`DISABLE_SQLITE_AUTO_BACKUP`:** Set to `true` if backups are managed externally.
- **Data Persistence:** Always mount a volume to `/app/data` to persist your database, keys, and configurations across container restarts.
- **Port Configuration:** Override `PORT` environment variable to change the default `20128` port.
## See Also
- [VM Deployment Guide](VM_DEPLOYMENT_GUIDE.md) — VM + nginx + Cloudflare setup
- [Fly.io Deployment Guide](FLY_IO_DEPLOYMENT_GUIDE.md) — Deploy to Fly.io
- [Environment Config](ENVIRONMENT.md) — Complete `.env` reference

407
docs/FREE_TIERS.md Normal file
View File

@@ -0,0 +1,407 @@
# 🆓 Free LLM API Providers — Consolidated Directory
> **The ultimate aggregated reference for all permanently free LLM API providers.**
> Consolidated from 6 community repositories. Use with OmniRoute to route through 25+ free providers simultaneously.
_Last consolidated: May 2026 · Sources: awesome-free-llm-apis, awesome-free-llm-apis2, free-llm-api-resources, Free-LLM-Collection, FREE-LLM-API-Provider, gpt4free_
---
## Table of Contents
- [Quick Comparison](#quick-comparison)
- [Provider APIs (First-Party)](#provider-apis-first-party)
- [Inference Providers (Third-Party)](#inference-providers-third-party)
- [China-Based Providers](#china-based-providers)
- [Trial Credit Providers](#trial-credit-providers)
- [Using with OmniRoute](#using-with-omniroute)
- [Glossary](#glossary)
---
## Quick Comparison
All free providers at a glance, sorted by generosity of free tier:
| Provider | Type | Best Free Model | RPM | RPD | Tokens | OpenAI Compat | Speed |
| ----------------- | --------- | ---------------------- | ------- | ------------ | ---------------- | --------------- | --------- |
| **Groq** | Inference | Llama 3.3 70B | 30 | 14,400 | 6K TPM | ✅ | 🟢 Fast |
| **Cerebras** | Inference | Qwen3 235B | 30 | 14,400 | 1M TPD | ✅ | 🟢 Fast |
| **Mistral AI** | Provider | Mistral Large 3 | 60 | Unlimited | 1B/month | ✅ | 🟡 Medium |
| **Google Gemini** | Provider | Gemini 2.5 Flash | 515 | 201,500 | 250K TPM | ✅ | 🟢 Fast |
| **NVIDIA NIM** | Inference | 129 models | 40 | — | — | ✅ | 🟡 Medium |
| **Ollama Cloud** | Inference | 400+ models | — | — | Session limits | ❌ (Ollama API) | 🟡 Medium |
| **OpenRouter** | Inference | 35+ free models | 20 | 501,000 | — | ✅ | 🟡 Medium |
| **GitHub Models** | Inference | GPT-4.1, GPT-5 | 1015 | 50150 | 8K in/4K out | ✅ | 🟡 Medium |
| **Cloudflare AI** | Inference | 50+ models | — | 10K neurons | — | ⚠️ Partial | 🟡 Medium |
| **Hugging Face** | Inference | Thousands | — | — | $0.10/mo credits | ✅ | 🔴 Slow |
| **Cohere** | Provider | Command A (111B) | 20 | — | 1K calls/month | ⚠️ Partial | 🟡 Medium |
| **Pollinations** | Inference | Text+Image+Video+Audio | — | Hourly reset | — | ✅ | 🟡 Medium |
| **Z.AI (Zhipu)** | Provider | GLM-4.7-Flash | — | — | Undocumented | ✅ | 🟡 Medium |
| **SiliconFlow** | Inference | Qwen3-8B | 1,000 | — | 50K TPM | ✅ | 🟡 Medium |
| **Kilo Code** | Inference | Free auto-router | ~200/hr | — | — | ✅ | 🟡 Medium |
| **LLM7.io** | Inference | 30+ models | 1530 | — | — | ✅ | 🟡 Medium |
| **Kluster AI** | Inference | DeepSeek-R1 | — | — | Undocumented | ✅ | 🟡 Medium |
| **ModelScope** | Inference | Qwen, DeepSeek | — | 2,000 | ≤500/model/day | ✅ | 🟡 Medium |
| **IBM watsonx** | Provider | Granite models | 2/sec | — | 300K/month | ❌ | 🟡 Medium |
---
## Provider APIs (First-Party)
APIs from the companies that train or fine-tune the models.
### Google Gemini 🇺🇸
🔗 [Get API Key](https://aistudio.google.com/app/apikey) · Base URL: `https://generativelanguage.googleapis.com/v1beta`
> ⚠️ Free tier NOT available in EU/UK/Switzerland. Prompts may be used by Google to improve products.
| Model | Context | Max Output | Modality | RPM | RPD |
| --------------------------------- | ------- | ---------- | ---------------------- | --- | ------ |
| Gemini 2.5 Flash / Gemini 3 Flash | 1M | 65K | Text+Image+Audio+Video | 5 | 20 |
| Gemini 2.5 Flash-Lite | 1M | 65K | Text+Image+Audio+Video | 10 | 20 |
| Gemini 3.1 Flash-Lite | 1M | 65K | Text+Image+Audio+Video | 15 | 1,500 |
| Gemma 4 26B/31B | — | — | Text | 15 | 1,500 |
| Gemma 3 (1B/4B/12B/27B) | — | — | Text | 30 | 14,400 |
### Mistral AI 🇫🇷
🔗 [Get API Key](https://console.mistral.ai/api-keys) · Base URL: `https://api.mistral.ai/v1`
Free "Experiment" plan, no credit card. ~1B tokens/month. Requires phone verification.
| Model | Context | Max Output | Modality | Rate Limit |
| ------------------ | ------- | ---------- | --------------- | --------------- |
| Mistral Small 4 | 256K | 256K | Text+Image+Code | 1 RPS, 500K TPM |
| Mistral Medium 3 | 128K | 128K | Text | 1 RPS, 500K TPM |
| Mistral Large 3 | 256K | 256K | Text | 1 RPS, 500K TPM |
| Mistral Nemo (12B) | 128K | 128K | Text | 1 RPS, 500K TPM |
| Codestral | 256K | 256K | Code | 30 RPM, 2K RPD |
| Pixtral Large | 128K | 128K | Text+Image | 1 RPS, 500K TPM |
### Cohere 🇨🇦
🔗 [Get API Key](https://dashboard.cohere.com/api-keys) · Base URL: `https://api.cohere.com/v2`
Free "Trial" key. 1,000 API calls/month. Non-commercial use only. 20 RPM.
| Model | Context | Max Output | Modality |
| ------------------- | ------- | ---------- | ----------------------- |
| Command A (111B) | 256K | 4K | Text |
| Command A Reasoning | 256K | 4K | Text (reasoning) |
| Command A Vision | 256K | 4K | Text+Image |
| Command A Translate | 256K | 4K | Translation |
| Command R+ | 128K | 4K | Text |
| Command R | 128K | 4K | Text |
| Command R7B | 128K | 4K | Text |
| Embed 4 | — | — | Embeddings (Text+Image) |
| Rerank 3.5 | — | — | Reranking |
### Z.AI (Zhipu AI) 🇨🇳
🔗 [Get API Key](https://open.bigmodel.cn/usercenter/apikeys) · Base URL: `https://open.bigmodel.cn/api/paas/v4`
Permanent free models, no credit card. No published rate limits.
| Model | Context | Max Output | Modality |
| --------------- | ------- | ---------- | ---------- |
| GLM-4.7-Flash | 200K | 128K | Text |
| GLM-4.5-Flash | 128K | ~8K | Text |
| GLM-4.6V-Flash | 128K | ~4K | Text+Image |
| GLM-5 / GLM-5.1 | — | — | Text |
### IBM watsonx 🇺🇸
🔗 [Pricing](https://www.ibm.com/products/watsonx-ai/pricing)
Free tier: 2 RPS, 300K tokens/month. Granite foundation models.
---
## Inference Providers (Third-Party)
### Groq 🇺🇸
🔗 [Get API Key](https://console.groq.com/keys) · Base URL: `https://api.groq.com/openai/v1`
Ultra-fast LPU inference (~300500 tok/s). No credit card required.
| Model | RPM | RPD | TPM | Modality |
| ---------------------------------- | --- | ------ | --- | ---------------- |
| llama-3.3-70b-versatile | 30 | 1,000 | 12K | Text |
| llama-3.1-8b-instant | 30 | 14,400 | 6K | Text |
| llama-4-scout-17b-16e-instruct | 30 | 1,000 | 30K | Text+Vision |
| llama-4-maverick-17b-128e-instruct | 30 | 1,000 | 6K | Text+Vision |
| qwen3-32b | 60 | 1,000 | 6K | Text |
| kimi-k2-instruct | 60 | 1,000 | 10K | Text |
| gpt-oss-120b / gpt-oss-20b | 30 | 1,000 | 8K | Text |
| deepseek-r1-distill-70b | 30 | 14,400 | — | Text (reasoning) |
| whisper-large-v3 / v3-turbo | 20 | 2,000 | — | Audio→Text |
### Cerebras 🇺🇸
🔗 [Get API Key](https://cloud.cerebras.ai/) · Base URL: `https://api.cerebras.ai/v1`
Wafer-scale chip inference (~2,600 tok/s). 1M tokens/day cap.
| Model | RPM | RPH | RPD | TPM | TPD |
| ------------------------------ | --- | --- | ------ | --- | --- |
| gpt-oss-120b | 30 | 900 | 14,400 | 64K | 1M |
| llama3.1-8b | 30 | 900 | 14,400 | 60K | 1M |
| qwen-3-235b-a22b-instruct-2507 | 30 | 900 | 14,400 | 60K | 1M |
| zai-glm-4.7 | 10 | 100 | 100 | 60K | 1M |
### NVIDIA NIM 🇺🇸
🔗 [Explore Models](https://build.nvidia.com/explore/discover) · Base URL: `https://integrate.api.nvidia.com/v1`
Free with NVIDIA Developer Program. **129 models**, 40 RPM. Phone verification required.
**Notable models:** DeepSeek-R1, DeepSeek-V3.2, Nemotron Ultra 253B, Llama 3.1 405B, Qwen3 Coder 480B, Mistral Large 3, Kimi K2, GLM-5.1, MiniMax M2.7, Gemma 4 31B, + 100 more.
### OpenRouter 🇺🇸
🔗 [Get API Key](https://openrouter.ai/keys) · Base URL: `https://openrouter.ai/api/v1`
35+ free models (suffix `:free`). 20 RPM.
| Credits Purchased | RPD |
| ----------------- | ----- |
| < $10 | 50 |
| ≥ $10 (one-time) | 1,000 |
**Notable free models:** DeepSeek R1, DeepSeek V3, Qwen3 Coder 480B, Llama 4 Scout/Maverick, GPT-OSS 120B, Nemotron 3 Super 120B, MiniMax M2.5, Gemma 4 31B, Devstral, + 23 more.
### GitHub Models 🇺🇸
🔗 [Marketplace](https://github.com/marketplace/models) · Base URL: `https://models.inference.ai.azure.com`
Free for all GitHub users. 45+ models including frontier models.
| Tier | RPM | RPD | Tokens/Request |
| ----------------------- | --- | --- | -------------- |
| Low tier models | 15 | 150 | 8K in / 4K out |
| High tier models | 10 | 50 | 8K in / 4K out |
| DeepSeek-R1 / MAI-DS-R1 | 1 | 8 | 4K in / 4K out |
| Grok-3 | 1 | 15 | 4K in / 4K out |
**Notable models:** GPT-4.1, GPT-4o, GPT-5, GPT-5-mini, o3-mini, o4-mini, DeepSeek-R1, Llama 4 Scout/Maverick, Codestral, Mistral Medium 3, Phi-4, Grok-3.
### Cloudflare Workers AI 🇺🇸
🔗 [Get Token](https://dash.cloudflare.com/profile/api-tokens) · 10,000 Neurons/day free. 50+ models.
**Notable models:** Llama 3.3 70B, Llama 4 Scout, Qwen3 30B-A3B, QwQ 32B, DeepSeek R1 Distill, Gemma 4 26B, GLM 4.7 Flash, Nemotron 3 120B, Kimi K2.5/K2.6, Mistral Small 3.1, GPT-OSS 120B/20B, + 40 more.
### Hugging Face 🇺🇸
🔗 [Get Token](https://huggingface.co/settings/tokens) · Base URL: `https://api-inference.huggingface.co/v1`
$0.10/month free credits (auto-replenished). Thousands of models. Serverless limited to <10GB models.
### Ollama Cloud 🇺🇸
🔗 [Get Key](https://ollama.com/settings/keys) · Base URL: `https://api.ollama.com`
400+ models. Session/weekly limits (unpublished). NOT OpenAI SDK-compatible.
**Notable models:** GPT-OSS 120B, DeepSeek V3.2/V4, Kimi K2/K2.5/K2.6, GLM-5/5.1, Qwen3 Coder 480B, Gemini 3 Flash, MiniMax M2.7, Cogito 2.1 671B, Nemotron 3 Super 120B.
### Pollinations AI 🇩🇪
🔗 [Get Key](https://enter.pollinations.ai) · Base URL: `https://gen.pollinations.ai/v1`
No sign-up required for basic use. Unique: **text + image + video + audio** all free.
**Text models:** openai, openai-large, openai-reasoning, gemini, mistral, llama.
**Image models:** flux, gpt-image, seedream, kontext.
**Video:** wan-fast. **Audio:** tts-1, 30+ ElevenLabs voices.
### SiliconFlow 🇨🇳
🔗 [Get Key](https://cloud.siliconflow.cn/account/ak) · Base URL: `https://api.siliconflow.cn/v1`
14 CNY signup credits. Permanently free models: 1,000 RPM, 50K TPM.
| Model | Context | Modality |
| --------------------------- | ------- | ---------------- |
| Qwen/Qwen3-8B | 131K | Text |
| DeepSeek-R1-0528-Qwen3-8B | ~33K | Text (reasoning) |
| DeepSeek-R1-Distill-Qwen-7B | 131K | Text (reasoning) |
| THUDM/glm-4-9b-chat | 32K | Text |
| THUDM/GLM-4.1V-9B-Thinking | 66K | Vision+Text |
| DeepSeek-OCR | — | Vision (OCR) |
### Kilo Code 🇺🇸
🔗 [Get Key](https://kilo.ai) · Base URL: `https://api.kilo.ai/api/gateway`
Free models, no credit card. ~200 req/hr. Auto-router `kilo-auto/free`.
### LLM7.io 🇬🇧
🔗 [Get Token](https://token.llm7.io) · Base URL: `https://api.llm7.io/v1`
30+ models. 15 RPM (30 RPM with free token). No registration for basic access.
### Kluster AI 🇺🇸
🔗 [Get Key](https://platform.kluster.ai/apikeys) · DeepSeek-R1, Llama 4 Maverick, Qwen3-235B + more.
### OpenCode Zen
🔗 [Docs](https://opencode.ai/docs/zen/) · Free models (Big Pickle Stealth, MiniMax M2.5 Free, Arcee Large).
### Vercel AI Gateway
🔗 [Docs](https://vercel.com/docs/ai-gateway) · $5/month free credits. Routes to various providers.
---
## China-Based Providers
### ModelScope (魔搭社区) 🇨🇳
🔗 [Get Token](https://modelscope.cn/my/myaccesstoken) · Base URL: `https://api-inference.modelscope.cn/v1`
2,000 req/day total, ≤500/model/day. Requires Alibaba Cloud account + real-name verification.
**Models:** DeepSeek V4 Pro/Flash, DeepSeek V3.2, GLM-5/5.1, MiniMax M2.5, Qwen3-235B, Qwen3 Coder 480B, Ling-2.6-1T.
### Tencent Hunyuan (腾讯混元)
Hunyuan-Lite: free. Other models: 100M tokens free (1-year expiry).
### Volcengine (火山引擎)
500 resource points/day. Tongyi Qwen free (100 calls/day). Doubao models with tiered pricing.
### ChatAnywhere
🔗 Base URL: `https://api.chatanywhere.tech` · GPT-5.4-mini, DeepSeek-V4, and more.
### InternAI (书生)
🔗 Base URL: `https://chat.intern-ai.org.cn/api/v1` · 10 RPM. Keys valid 6 months.
**Models:** intern-latest, intern-s1-pro, internvl3.5-241b-a28b.
### Bigmodel (智谱)
🔗 Base URL: `https://open.bigmodel.cn/api/paas/v4/` · 30 concurrent requests.
**Models:** GLM-4-Flash, GLM-4V-Flash, GLM-4.1V-Thinking-Flash, GLM-4.6V-Flash, GLM-4.7-Flash.
---
## Trial Credit Providers
These offer one-time or time-limited credits (not permanent free tiers):
| Provider | Credits | Expiry | Notable Models |
| ---------------------------------------------------------- | ---------------- | -------- | ----------------------------- |
| [Baseten](https://app.baseten.co/) | $30 | — | Any model (pay by compute) |
| [NLP Cloud](https://nlpcloud.com) | $15 | — | Various open models |
| [AI21](https://studio.ai21.com/) | $10 | 3 months | Jamba family |
| [Upstage](https://console.upstage.ai/) | $10 | 3 months | Solar Pro/Mini |
| [Modal](https://modal.com) | $5/mo | Monthly | Any model (compute time) |
| [SambaNova](https://cloud.sambanova.ai/) | $5 | 3 months | Llama 3.3, Qwen3, DeepSeek R1 |
| [Scaleway](https://console.scaleway.com/generative-api) | 1M tokens | One-time | Llama 3.3, Gemma 3, GPT-OSS |
| [Alibaba Cloud](https://bailian.console.alibabacloud.com/) | 1M tokens/model | — | Qwen family |
| [Fireworks](https://fireworks.ai/) | $1 | — | Various open models |
| [Nebius](https://tokenfactory.nebius.com/) | $1 | — | Various open models |
| [Inference.net](https://inference.net) | $1 (+$25 survey) | — | Various open models |
| [Hyperbolic](https://app.hyperbolic.ai/) | $1 | — | DeepSeek V3, Llama 3.3 |
| [Novita](https://novita.ai/) | $0.50 | 1 year | Various open models |
---
## Using with OmniRoute
OmniRoute supports **all providers listed above** as connections. Here's how to maximize free usage:
### 1. Add Multiple Free Providers
```
Dashboard → Providers → Add Connection
```
Add API keys for Groq, Cerebras, Mistral, Google Gemini, OpenRouter, GitHub Models, etc.
### 2. Create a Free-Tier Combo
```
Dashboard → Combos → Create Combo → Add all free providers as targets
```
Use the **"priority"** or **"round-robin"** strategy to distribute load across free tiers.
### 3. Recommended Free Combo Strategy
| Priority | Provider | Why |
| -------- | ----------------- | --------------------------------------------- |
| 1 | **Groq** | Fastest inference, 14,400 RPD on small models |
| 2 | **Cerebras** | 1M TPD, fast wafer-scale chips |
| 3 | **Mistral** | 1B tokens/month, large model selection |
| 4 | **Google Gemini** | 1M context, multimodal |
| 5 | **NVIDIA NIM** | 129 models, 40 RPM |
| 6 | **OpenRouter** | 35+ free models as final fallback |
### 4. Environment Variables
```bash
# These providers work out of the box with OmniRoute:
GROQ_API_KEY=your-key
CEREBRAS_API_KEY=your-key
MISTRAL_API_KEY=your-key
GOOGLE_AI_API_KEY=your-key
NVIDIA_API_KEY=your-key
OPENROUTER_API_KEY=your-key
GITHUB_TOKEN=your-token
CLOUDFLARE_API_TOKEN=your-token
COHERE_API_KEY=your-key
SILICONFLOW_API_KEY=your-key
```
### 5. Estimated Free Capacity
With all top-6 providers combined in a combo:
| Metric | Combined Free Capacity |
| -------------------- | ---------------------- |
| **Requests/Day** | ~31,000+ RPD |
| **Tokens/Month** | ~32B+ tokens |
| **Models Available** | 200+ unique models |
| **Cost** | $0.00 |
---
## Glossary
| Term | Meaning |
| ----------- | ------------------------------------------- |
| **RPM** | Requests per minute |
| **RPD** | Requests per day |
| **RPH** | Requests per hour |
| **RPS** | Requests per second |
| **TPM** | Tokens per minute |
| **TPD** | Tokens per day |
| **Neurons** | Cloudflare's compute unit (~1 output token) |
---
## Sources
This document consolidates data from 6 community repositories:
| Repository | Focus |
| -------------------------------------------------------------------------- | ------------------------------------------------ |
| [awesome-free-llm-apis](https://github.com/mnfst/awesome-free-llm-apis) | Curated list with detailed model tables |
| [awesome-free-llm-apis2](https://github.com/) | Extended list with speed tiers and code snippets |
| [free-llm-api-resources](https://github.com/) | Auto-generated model lists with trial credits |
| [Free-LLM-Collection](https://github.com/for-the-zero/Free-LLM-Collection) | Chinese + global providers with rate limits |
| [FREE-LLM-API-Provider](https://github.com/CYBIRD-D/FREE-LLM-API-Provider) | Deep provider analysis with CN platforms |
| [gpt4free](https://github.com/xtekky/gpt4free) | Config-based routing with quota awareness |
> ⚠️ **Disclaimer:** Rate limits change frequently. Always verify with the provider's official documentation before relying on specific limits. Trial credits and time-limited promotions are separated from permanent free tiers.

596
docs/PROXY_GUIDE.md Normal file
View File

@@ -0,0 +1,596 @@
# 🌐 OmniRoute Proxy Guide
> **Bypass geographic blocks, protect your identity, and route AI traffic through any proxy — with zero configuration complexity.**
OmniRoute includes a full-featured proxy management system that lets you route upstream AI provider traffic through HTTP, HTTPS, or SOCKS5 proxies. Whether you're in a blocked region, need IP rotation, or want stealth fingerprinting — this guide covers everything.
---
## Table of Contents
- [Why Use Proxies?](#why-use-proxies)
- [Architecture Overview](#architecture-overview)
- [3-Level Proxy System](#3-level-proxy-system)
- [Proxy Registry (CRUD)](#proxy-registry-crud)
- [1proxy Free Marketplace](#1proxy-free-proxy-marketplace)
- [Proxy Rotation](#proxy-rotation)
- [Anti-Detection & Stealth](#anti-detection--stealth)
- [Upstream Proxy Modes](#upstream-proxy-modes)
- [Dashboard UI](#dashboard-ui)
- [API Reference](#api-reference)
- [Environment Variables](#environment-variables)
- [Troubleshooting](#troubleshooting)
---
## Why Use Proxies?
Many AI providers restrict access by geographic region. Developers in **Russia, China, Iran, Cuba, Turkey**, and other countries encounter errors like:
```
unsupported_country_region_territory
```
Even outside blocked regions, proxies are useful for:
| Use Case | Description |
| --------------------- | --------------------------------------------------------------- |
| **Geographic bypass** | Access OpenAI, Anthropic, Codex, Copilot from blocked countries |
| **IP rotation** | Distribute requests across multiple IPs to avoid rate limiting |
| **Privacy** | Hide your real IP from upstream providers |
| **Compliance** | Route traffic through specific jurisdictions |
| **Testing** | Simulate requests from different regions |
---
## Architecture Overview
```
┌───────────────────────────────────────────────────────────────┐
│ OmniRoute Server │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Proxy │ │ Proxy │ │ Proxy │ │
│ │ Registry │───▶│ Dispatcher │───▶│ Fetch (undici) │ │
│ │ (SQLite) │ │ (cached) │ │ │ │
│ └─────────────┘ └──────────────┘ └────────┬─────────┘ │
│ ▲ │ │
│ │ ▼ │
│ ┌──────┴──────┐ ┌──────────────────┐ │
│ │ 1proxy Sync │ │ Upstream │ │
│ │ (free pool) │ │ Provider API │ │
│ └─────────────┘ └──────────────────┘ │
└───────────────────────────────────────────────────────────────┘
```
### Key Components
| Component | File | Role |
| -------------------- | -------------------------------------------- | ---------------------------------------------------------- |
| **Proxy Registry** | `src/lib/db/proxies.ts` | CRUD for proxy entries + scope assignments |
| **Proxy Dispatcher** | `open-sse/utils/proxyDispatcher.ts` | Creates `undici` ProxyAgent/SOCKS dispatchers with caching |
| **Proxy Fetch** | `open-sse/utils/proxyFetch.ts` | Wraps `fetch()` with proxy dispatcher injection |
| **Settings Route** | `src/app/api/settings/proxy/route.ts` | Legacy proxy config API (GET/PUT/DELETE) |
| **Management Route** | `src/app/api/v1/management/proxies/route.ts` | Registry CRUD API (GET/POST/PATCH/DELETE) |
| **1proxy DB** | `src/lib/db/oneproxy.ts` | Free proxy marketplace persistence |
| **1proxy Sync** | `src/lib/oneproxySync.ts` | Fetches proxies from 1proxy API |
| **1proxy Rotator** | `src/lib/oneproxyRotator.ts` | Rotation strategies (quality/random/sequential) |
---
## 3-Level Proxy System
OmniRoute supports proxy configuration at **four independent scopes**, resolved in priority order:
```
Priority Resolution Order (highest → lowest):
1. 🔵 Account/Connection Proxy → per API key / OAuth connection
2. 🟡 Provider Proxy → per provider (e.g., all OpenAI traffic)
3. 🟠 Combo Proxy → per combo/routing configuration
4. 🟢 Global Proxy → all traffic, all providers
```
### How Resolution Works
When OmniRoute sends a request to an upstream provider, it calls `resolveProxyForConnectionFromRegistry()` which checks each level in order:
1. **Account-level** — Is there a proxy assigned to this specific connection ID?
2. **Provider-level** — Is there a proxy assigned to this provider (e.g., `openai`)?
3. **Global-level** — Is there a global proxy configured?
4. **No proxy** — Direct connection to the provider.
The first match wins. This means you can set a global proxy as a fallback but override it for specific providers or connections.
### What Gets Proxied
| Traffic Type | Proxied? | Notes |
| -------------------- | -------- | --------------------------------------------- |
| Chat completions | ✅ | All `/v1/chat/completions` requests |
| Embeddings | ✅ | `/v1/embeddings` |
| Image generation | ✅ | `/v1/images/generations` |
| Audio (TTS/STT) | ✅ | `/v1/audio/*` |
| OAuth token exchange | ✅ | Solves `unsupported_country_region_territory` |
| Connection tests | ✅ | "Test Connection" button uses proxy |
| Token refresh | ✅ | Background OAuth renewal |
| Model sync | ✅ | Model listing and discovery |
---
## Proxy Registry (CRUD)
The proxy registry is a SQLite table (`proxy_registry`) that stores all your proxies. Each proxy has:
| Field | Type | Description |
| ---------- | ------- | ----------------------------------- |
| `id` | UUID | Unique identifier |
| `name` | String | Human-readable label |
| `type` | String | Protocol: `http`, `https`, `socks5` |
| `host` | String | Proxy hostname or IP |
| `port` | Integer | Port number |
| `username` | String | Auth username (encrypted at rest) |
| `password` | String | Auth password (encrypted at rest) |
| `region` | String | Geographic region label |
| `notes` | String | Free-text notes |
| `status` | String | `active` or `inactive` |
| `source` | String | `manual` or `oneproxy` |
### Creating a Proxy
**Via Dashboard:**
1. Go to **Settings → Proxy**
2. Click **Add Proxy**
3. Fill in the type, host, port, and optional auth credentials
4. Save
**Via API:**
```bash
curl -X POST http://localhost:20128/api/v1/management/proxies \
-H "Content-Type: application/json" \
-d '{
"name": "US Proxy",
"type": "http",
"host": "proxy.example.com",
"port": 8080,
"username": "user",
"password": "pass",
"region": "US"
}'
```
### Updating a Proxy
```bash
curl -X PATCH http://localhost:20128/api/v1/management/proxies \
-H "Content-Type: application/json" \
-d '{
"id": "proxy-uuid-here",
"host": "new-proxy.example.com",
"port": 9090
}'
```
> **Note:** Credentials are preserved unless you explicitly send non-empty replacements. Sending empty strings for `username`/`password` will keep the stored values.
### Deleting a Proxy
```bash
# Fails if proxy is assigned to any scope
curl -X DELETE "http://localhost:20128/api/v1/management/proxies?id=proxy-uuid"
# Force delete (removes assignments too)
curl -X DELETE "http://localhost:20128/api/v1/management/proxies?id=proxy-uuid&force=1"
```
### Listing Proxies
```bash
curl "http://localhost:20128/api/v1/management/proxies?limit=50&offset=0"
```
### Assigning Proxies to Scopes
```bash
# Assign to global scope
curl -X PUT http://localhost:20128/api/settings/proxy \
-H "Content-Type: application/json" \
-d '{"level": "global", "proxy": {"type":"http","host":"proxy.example.com","port":8080}}'
# Assign to a specific provider
curl -X PUT http://localhost:20128/api/settings/proxy \
-H "Content-Type: application/json" \
-d '{"level": "provider", "id": "openai", "proxy": {"type":"socks5","host":"socks.example.com","port":1080}}'
# Assign to a specific connection/key
curl -X PUT http://localhost:20128/api/settings/proxy \
-H "Content-Type: application/json" \
-d '{"level": "key", "id": "connection-uuid", "proxy": {"type":"http","host":"key-proxy.com","port":3128}}'
```
### Resolving Effective Proxy
Check which proxy would be used for a given connection:
```bash
curl "http://localhost:20128/api/settings/proxy?resolve=connection-uuid"
```
Returns the resolved proxy with its level (`account`, `provider`, or `global`) and source.
### Bulk Assignment
Assign one proxy to multiple providers or connections at once:
```bash
curl -X POST http://localhost:20128/api/v1/management/proxies/bulk-assign \
-H "Content-Type: application/json" \
-d '{
"scope": "provider",
"scopeIds": ["openai", "anthropic", "codex"],
"proxyId": "proxy-uuid"
}'
```
### Import/Export
Proxies are included in the **Backup/Restore** system. When you export your OmniRoute configuration:
1. Go to **Dashboard → Settings → Backup**
2. Click **Export** — proxy registry and assignments are included
3. To restore, click **Import** and upload the backup file
The proxy registry also supports **upsert by host+port** — if you import a proxy that already exists (same host and port), it updates instead of creating a duplicate.
### Legacy Migration
If you configured proxies in an older version (pre-registry), OmniRoute automatically migrates them:
```
Legacy key_value store → proxy_registry + proxy_assignments
```
This happens once on first startup after upgrade. Use `migrateLegacyProxyConfigToRegistry({ force: true })` to re-run.
---
## 1proxy Free Proxy Marketplace
> 🆕 **Contributed by [@oyi77](https://github.com/oyi77)** — PR [#1847](https://github.com/diegosouzapw/OmniRoute/pull/1847) (Issue [#1788](https://github.com/diegosouzapw/OmniRoute/issues/1788))
OmniRoute integrates with the **[1proxy](https://1proxy-api.aitradepulse.com)** community platform to provide access to **hundreds of free, validated proxies** from around the world. This is perfect for users who don't have their own proxy infrastructure.
### How It Works
```
┌─────────────┐ Sync ┌─────────────────┐ Rotate ┌──────────┐
│ 1proxy API │ ────────────▶ │ proxy_registry │ ────────────▶ │ Provider │
│ (external) │ up to 500 │ source=oneproxy │ by quality │ API │
└─────────────┘ proxies └─────────────────┘ └──────────┘
```
1. **Sync** — OmniRoute fetches validated proxies from the 1proxy API
2. **Store** — Proxies are saved in the same `proxy_registry` table with `source = 'oneproxy'`
3. **Filter** — Filter by protocol, country, quality score
4. **Rotate** — Pick the best proxy using quality, random, or sequential strategies
5. **Auto-degrade** — Failed proxies get their quality score reduced; below threshold → marked inactive
### Syncing Proxies
**Via Dashboard:**
1. Go to **Settings → 1proxy** tab
2. Click **"Sync Now"**
3. View stats: total proxies, active count, average quality, by-country breakdown
**Via API:**
```bash
# Trigger sync
curl -X POST http://localhost:20128/api/settings/oneproxy \
-H "Content-Type: application/json" \
-d '{}'
# Response:
# { "success": true, "added": 127, "updated": 45, "failed": 2, "total": 172 }
```
### Filtering Proxies
```bash
# Filter by protocol
curl "http://localhost:20128/api/settings/oneproxy?protocol=socks5"
# Filter by country
curl "http://localhost:20128/api/settings/oneproxy?countryCode=US"
# Filter by minimum quality score
curl "http://localhost:20128/api/settings/oneproxy?minQuality=80"
# Combine filters
curl "http://localhost:20128/api/settings/oneproxy?protocol=http&countryCode=DE&minQuality=70"
```
### Proxy Quality Scores
Each 1proxy proxy comes with metadata:
| Field | Description |
| --------------- | -------------------------------------------- |
| `qualityScore` | 0-100 rating from 1proxy validation |
| `latencyMs` | Measured network latency |
| `anonymity` | `transparent`, `anonymous`, or `elite` |
| `googleAccess` | Whether the proxy can access Google services |
| `countryCode` | Two-letter ISO country code |
| `lastValidated` | Timestamp of last validation |
Quality scores are dynamically adjusted:
- **Failed requests** reduce the score by 10 points
- **Score drops to ≤10** → proxy is marked `inactive`
- Inactive proxies are excluded from rotation
### Rotation Strategies
```bash
# Rotate by quality (best proxy first) — default
curl -X POST http://localhost:20128/api/settings/oneproxy/rotate \
-H "Content-Type: application/json" \
-d '{"strategy": "quality"}'
# Random rotation
curl -X POST http://localhost:20128/api/settings/oneproxy/rotate \
-d '{"strategy": "random"}'
# Sequential (least recently validated first)
curl -X POST http://localhost:20128/api/settings/oneproxy/rotate \
-d '{"strategy": "sequential"}'
```
### Circuit Breaker
The 1proxy sync has a built-in circuit breaker:
- After **5 consecutive sync failures**, further sync attempts are blocked
- Reset with: `resetOneproxyCircuitBreaker()` or restart the server
- Sync status is available at `GET /api/settings/oneproxy?action=status`
### Clearing 1proxy Proxies
```bash
# Delete a single 1proxy proxy
curl -X DELETE "http://localhost:20128/api/settings/oneproxy?id=proxy-uuid"
# Clear ALL 1proxy proxies (manual proxies are untouched)
curl -X DELETE "http://localhost:20128/api/settings/oneproxy?clearAll=1"
```
---
## Anti-Detection & Stealth
OmniRoute doesn't just route traffic through a proxy — it makes the traffic look legitimate:
### TLS Fingerprint Spoofing
Uses `wreq-js` to generate browser-like TLS fingerprints, bypassing bot detection systems that flag non-browser TLS handshakes.
### CLI Fingerprint Matching
The **CLI Fingerprint Toggle** (`Settings → Security`) reorders HTTP headers and JSON body fields to match the exact signature of native CLI binaries (Claude Code, Codex, etc.). This works **on top of** the proxy:
```
Your IP (blocked) → Proxy IP (US) → Provider API
+ TLS spoof
+ CLI fingerprint
```
You get both **IP masking** and **request authenticity** simultaneously.
### Proxy IP Preservation
Color-coded badges in the dashboard show which proxy level is active:
| Badge | Level | Meaning |
| ----- | ---------- | ----------------------------------------- |
| 🟢 | Global | All traffic goes through this proxy |
| 🟡 | Provider | Only this provider's traffic is proxied |
| 🔵 | Connection | This specific key/account uses this proxy |
The badge also shows the resolved proxy IP for verification.
---
## Upstream Proxy Modes
For providers that use the CLIProxyAPI pattern, OmniRoute supports three upstream proxy modes:
| Mode | Description |
| ------------- | -------------------------------------------------- |
| `native` | OmniRoute handles proxy routing directly (default) |
| `cliproxyapi` | Delegates to an external CLIProxyAPI instance |
| `fallback` | Tries native first, falls back to CLIProxyAPI |
Configure per-provider:
```bash
curl -X PUT "http://localhost:20128/api/upstream-proxy/openai" \
-H "Content-Type: application/json" \
-d '{"mode": "native", "enabled": true}'
```
---
## Dashboard UI
### Settings → Proxy Tab
- **Global proxy** configuration (set once for all traffic)
- **Per-provider proxy** overrides
- **Per-connection proxy** assignments
- **Connection test** through configured proxy
- **Color-coded badges** showing active proxy level
### Settings → 1proxy Tab
- **Sync Now** button to fetch free proxies
- **Stats cards**: Total, Active, Avg Quality, Last Sync
- **Filters**: Protocol, Country Code, Min Quality
- **Proxy table** with host, protocol, country, quality score, latency, anonymity, Google access
- **Sync status** panel with success/failure tracking and consecutive failure count
- **Clear All** to remove all 1proxy entries
---
## API Reference
### Proxy Settings API
| Method | Endpoint | Description |
| -------- | ---------------------------------------------- | ----------------------- |
| `GET` | `/api/settings/proxy` | Get full proxy config |
| `GET` | `/api/settings/proxy?level=global` | Get global proxy |
| `GET` | `/api/settings/proxy?level=provider&id=openai` | Get provider proxy |
| `GET` | `/api/settings/proxy?resolve=connectionId` | Resolve effective proxy |
| `PUT` | `/api/settings/proxy` | Update proxy config |
| `DELETE` | `/api/settings/proxy?level=provider&id=openai` | Remove proxy at level |
### Proxy Registry API
| Method | Endpoint | Description |
| -------- | ------------------------------------------------- | --------------------- |
| `GET` | `/api/v1/management/proxies` | List all proxies |
| `GET` | `/api/v1/management/proxies?id=uuid` | Get proxy by ID |
| `GET` | `/api/v1/management/proxies?id=uuid&where_used=1` | Get proxy assignments |
| `POST` | `/api/v1/management/proxies` | Create proxy |
| `PATCH` | `/api/v1/management/proxies` | Update proxy |
| `DELETE` | `/api/v1/management/proxies?id=uuid` | Delete proxy |
| `DELETE` | `/api/v1/management/proxies?id=uuid&force=1` | Force delete |
| `POST` | `/api/v1/management/proxies/bulk-assign` | Bulk assign |
| `GET` | `/api/v1/management/proxies/assignments` | List assignments |
| `GET` | `/api/v1/management/proxies/health` | Proxy health stats |
### 1proxy API
| Method | Endpoint | Description |
| -------- | -------------------------------------- | ----------------------- |
| `GET` | `/api/settings/oneproxy` | List 1proxy proxies |
| `GET` | `/api/settings/oneproxy?action=stats` | Get stats + sync status |
| `GET` | `/api/settings/oneproxy?action=status` | Get sync status only |
| `POST` | `/api/settings/oneproxy` | Trigger sync |
| `POST` | `/api/settings/oneproxy/rotate` | Rotate to next proxy |
| `DELETE` | `/api/settings/oneproxy?id=uuid` | Delete one |
| `DELETE` | `/api/settings/oneproxy?clearAll=1` | Clear all |
### Upstream Proxy API
| Method | Endpoint | Description |
| -------- | --------------------------------- | ---------------------------- |
| `GET` | `/api/upstream-proxy/:providerId` | Get upstream proxy config |
| `PUT` | `/api/upstream-proxy/:providerId` | Set upstream proxy mode |
| `DELETE` | `/api/upstream-proxy/:providerId` | Remove upstream proxy config |
---
## Environment Variables
| Variable | Default | Description |
| -------------------------------- | ------------------------------------- | ------------------------------- |
| `ENABLE_SOCKS5_PROXY` | `false` | Enable SOCKS5 proxy support |
| `ONEPROXY_ENABLED` | `true` | Enable 1proxy integration |
| `ONEPROXY_API_URL` | `https://1proxy-api.aitradepulse.com` | 1proxy API endpoint |
| `ONEPROXY_MAX_PROXIES` | `500` | Maximum proxies to sync |
| `ONEPROXY_MIN_QUALITY_THRESHOLD` | `50` | Minimum quality score to import |
---
## Troubleshooting
### "SOCKS5 proxy is disabled"
Set `ENABLE_SOCKS5_PROXY=true` in your `.env` file and restart.
### "socket hang up" errors through proxy
This is normal with cheap proxies that drop idle connections. OmniRoute already handles this by:
- Disabling keep-alive on proxy connections (`keepAliveTimeout: 1`)
- Disabling pipelining (`pipelining: 0`)
- Caching dispatchers to avoid repeated handshakes
If it persists, try a different proxy or use the 1proxy rotation feature.
### "unsupported_country_region_territory" during OAuth
Make sure the proxy is configured **before** starting the OAuth flow. OmniRoute routes OAuth token exchange through the configured proxy. Set a global or provider-level proxy first, then connect.
### Proxy not being used
Check the resolution order:
1. Verify with `GET /api/settings/proxy?resolve=your-connection-id`
2. Check if the proxy `status` is `active` (not `inactive`)
3. Ensure the proxy assignment scope matches your connection
### 1proxy sync failing
Check the sync status:
```bash
curl "http://localhost:20128/api/settings/oneproxy?action=status"
```
If `consecutiveFailures >= 5`, the circuit breaker has tripped. Restart the server to reset, or wait for manual reset.
---
## Database Schema
### `proxy_registry` Table
```sql
CREATE TABLE proxy_registry (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'http',
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT DEFAULT '',
password TEXT DEFAULT '',
region TEXT,
notes TEXT,
status TEXT DEFAULT 'active',
source TEXT NOT NULL DEFAULT 'manual', -- 'manual' or 'oneproxy'
quality_score INTEGER, -- 0-100 (1proxy only)
latency_ms INTEGER, -- milliseconds (1proxy only)
anonymity TEXT, -- transparent/anonymous/elite
google_access INTEGER DEFAULT 0, -- can access Google? (1proxy)
last_validated TEXT, -- ISO timestamp (1proxy)
country_code TEXT, -- ISO 2-letter code (1proxy)
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
```
### `proxy_assignments` Table
```sql
CREATE TABLE proxy_assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
proxy_id TEXT NOT NULL REFERENCES proxy_registry(id),
scope TEXT NOT NULL, -- 'global', 'provider', 'account', 'combo'
scope_id TEXT, -- provider ID, connection ID, or combo ID
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(scope, scope_id)
);
```
---
> 📖 **Related documentation:**
>
> - [User Guide](USER_GUIDE.md) — General setup and configuration
> - [API Reference](API_REFERENCE.md) — Full API documentation
> - [Environment Config](ENVIRONMENT.md) — All environment variables

186
docs/PWA_GUIDE.md Normal file
View File

@@ -0,0 +1,186 @@
# Progressive Web App (PWA) Guide
OmniRoute ships as a fully installable Progressive Web App. When you access the dashboard from any mobile browser — Android (Chrome) or iOS (Safari) — you can "Add to Home Screen" and get a native app-like experience with no app store required.
## What Is a PWA?
A Progressive Web App turns the OmniRoute web dashboard into something that looks and feels like a native mobile app. Once installed, it:
- Launches from your home screen with its own icon
- Opens fullscreen — no browser address bar or tab UI
- Works offline with a dedicated connectivity page
- Caches static assets for faster loading
- Supports both portrait and landscape orientations
## Installation
### Android (Chrome)
1. Open the OmniRoute dashboard in Chrome: `http://YOUR_IP:20128`
2. Chrome will show an **"Add OmniRoute to Home screen"** banner automatically, or:
- Tap the **⋮** menu (three dots) → **"Add to Home screen"** or **"Install app"**
3. Confirm the prompt
4. OmniRoute appears on your home screen as a standalone app
### iOS (Safari)
1. Open the OmniRoute dashboard in Safari: `http://YOUR_IP:20128`
2. Tap the **Share** button (box with arrow)
3. Scroll down and tap **"Add to Home Screen"**
4. Name it (defaults to "OmniRoute") and tap **Add**
5. OmniRoute appears on your home screen with the app icon
### Desktop (Chrome / Edge)
1. Open the OmniRoute dashboard
2. Click the **install icon** in the address bar (or ⋮ → "Install OmniRoute...")
3. Confirm the prompt
4. OmniRoute opens as a standalone window — no tabs, no address bar
## Features
### Fullscreen Experience
The manifest is configured with `display: "fullscreen"`, which means the installed app uses the entire screen — no browser chrome, no status bar overlap. This makes the dashboard feel truly native.
### Offline Support
OmniRoute includes a service worker (`sw.js`) that provides intelligent caching:
| Asset Type | Strategy | Behavior |
| ------------------------------------------------------- | ---------------------------------- | ---------------------------------------------------------------------------- |
| **App Shell** | Cache-first | `/`, `/offline`, manifest, and icons are pre-cached on install |
| **Static assets** (CSS, JS, images, fonts) | Network-first with cache fallback | Fetches fresh from the network; falls back to cache if offline |
| **Next.js bundles** (`/_next/`) | Network-first with cache update | Fetches from network and updates cache; serves cached version if offline |
| **Navigation requests** | Network-only with offline fallback | Always fetches from network; shows `/offline` page if network is unavailable |
| **API routes** (`/api/`, `/a2a`, `/dashboard/endpoint`) | Bypass (never cached) | Always goes directly to the server — never intercepted by the service worker |
### Offline Page
When the network is unavailable and a user navigates to a new page, the service worker serves a dedicated `/offline` page that:
- Displays a clear **"Connectivity Issue"** message
- Shows a live **online/offline status indicator** that updates in real time
- Provides a **"Retry Connection"** button to reload when connectivity returns
- Links to the **Status Page** for diagnostics
### App Icons
OmniRoute provides icons optimized for each platform:
| File | Size | Used By |
| ---------------------- | ---------------- | ------------------------------------- |
| `icon-512.png` | 512×512 | Android install prompt, splash screen |
| `apple-touch-icon.png` | 180×180 | iOS home screen icon |
| `icon-192.svg` | 192×192 (vector) | Android adaptive icon |
| `apple-touch-icon.svg` | 180×180 (vector) | Apple fallback |
| `favicon.svg` | Vector | Browser tabs |
| `favicon.ico` | Multi-size | Legacy browsers |
### Automatic Registration
The service worker is registered automatically via the `<PwaRegister />` component in the root layout. No user action is needed — the app becomes installable as soon as the browser detects the valid manifest and service worker.
## Technical Architecture
### Web App Manifest (`manifest.webmanifest`)
Generated by Next.js via `src/app/manifest.ts`:
```json
{
"name": "OmniRoute",
"short_name": "OmniRoute",
"description": "OmniRoute is an AI gateway for multi-provider LLMs. One endpoint for all your AI providers.",
"start_url": "/",
"scope": "/",
"display": "fullscreen",
"orientation": "any",
"background_color": "#0b0f1a",
"theme_color": "#0b0f1a",
"icons": [
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" },
{ "src": "/apple-touch-icon.png", "sizes": "180x180", "type": "image/png" }
]
}
```
### Service Worker (`public/sw.js`)
A vanilla service worker (no framework dependencies) with:
- **Install phase**: Pre-caches the app shell (root, offline page, manifest, icons)
- **Activate phase**: Cleans up old cache versions and claims all clients
- **Fetch phase**: Intelligent routing based on request type (navigation, static asset, API)
- **Cache versioning**: `omniroute-pwa-v2` — bump this to force a fresh cache on update
### Layout Metadata (`src/app/layout.tsx`)
The root layout provides all the meta tags required for PWA compliance:
- `manifest` link to `/manifest.webmanifest`
- `apple-web-app-capable: true` for iOS standalone mode
- `apple-web-app-status-bar-style: black-translucent`
- `mobile-web-app-capable: yes` for Android Chrome
- `theme-color: #0b0f1a`
- `viewport-fit: cover` for edge-to-edge rendering
### Component: `PwaRegister`
Located at `src/shared/components/PwaRegister.tsx`, this client component:
1. Runs on mount (client-side only)
2. Checks for `serviceWorker` support in the browser
3. Registers `/sw.js` silently (errors are swallowed to avoid blocking the app)
4. Renders nothing (`return null`) — it's a side-effect-only component
## Use With Termux (Android)
When running OmniRoute on Android via Termux, the PWA works seamlessly:
1. Start OmniRoute in Termux: `npx omniroute`
2. Open Chrome on the same phone: `http://localhost:20128`
3. Install the PWA via "Add to Home Screen"
4. The PWA connects to the local Termux server — everything runs on-device
This combination means your Android phone is both the **server** (Termux) and the **client** (PWA) — a complete self-contained AI gateway.
## Use From Other Devices
Install the PWA on any device that has browser access to your OmniRoute server:
- **Another phone/tablet**: Navigate to `http://PHONE_IP:20128` and install the PWA
- **Laptop**: Open Chrome/Edge and install it as a desktop PWA
- **Smart TV with browser**: Access the dashboard fullscreen
## Customization
### Instance Name
The PWA title respects the **Instance Name** setting from `Dashboard → Settings`. If you rename your instance to "My AI Gateway", the installed PWA will show that name.
### Custom Favicon
If you upload a custom favicon via `Dashboard → Settings`, the PWA icon on desktop will reflect the custom icon. Mobile home screen icons use the pre-built `icon-512.png` and `apple-touch-icon.png` files.
## Limitations
- **No push notifications** — The service worker does not implement the Push API. Notifications are handled by the Electron app instead.
- **No background sync** — Offline actions are not queued for replay. The PWA is primarily a dashboard viewer.
- **iOS restrictions** — Safari on iOS does not support all PWA features (e.g., install prompts are manual, and background service workers are limited).
- **Cache size** — The service worker caches static assets only. Large response payloads from `/api/` routes are never cached.
- **Custom icons on mobile** — Changing the favicon in settings does not update the home screen icon on mobile (this requires regenerating the PWA icons).
## Files Reference
| File | Purpose |
| --------------------------------------- | ---------------------------------------------------------------- |
| `src/app/manifest.ts` | Next.js manifest route (generates `manifest.webmanifest`) |
| `public/sw.js` | Service worker with caching logic |
| `src/shared/components/PwaRegister.tsx` | Client component that registers the service worker |
| `src/app/offline/page.tsx` | Offline fallback page with live status indicator |
| `src/app/layout.tsx` | Root layout with PWA metadata (apple-web-app, theme-color, etc.) |
| `public/icon-512.png` | 512×512 PNG icon (Android, splash screen) |
| `public/apple-touch-icon.png` | 180×180 PNG icon (iOS home screen) |
| `public/icon-192.svg` | 192×192 SVG icon (Android adaptive) |
| `public/apple-touch-icon.svg` | 180×180 SVG icon (Apple fallback) |

View File

@@ -21,7 +21,7 @@ Use this checklist before tagging or publishing a new OmniRoute release.
1. Review `docs/ARCHITECTURE.md` for storage/runtime drift.
2. Review `docs/TROUBLESHOOTING.md` for env var and operational drift.
3. Verify the release/runtime Node.js version still satisfies the supported secure floor:
- `>=20.20.2 <21` or `>=22.22.2 <23`
- `>=20.20.2 <21`, `>=22.22.2 <23`, or `>=24.0.0 <25`
- `npm run check:node-runtime`
4. Validate the npm publish artifact after building the standalone package:
- `npm run build:cli`

145
docs/RESILIENCE_GUIDE.md Normal file
View File

@@ -0,0 +1,145 @@
# 🛡️ Resilience Guide — OmniRoute
> How OmniRoute keeps your AI coding workflow running when providers fail.
## Overview
OmniRoute implements a multi-layered resilience system that ensures zero downtime:
```
Client Request
→ Rate Limit Check (per-IP, per-connection)
→ Combo Routing (13 strategies)
→ Connection Selection (P2C, round-robin, etc.)
→ Request Queue & Pacing
→ Execute (provider-specific executor)
→ On Failure:
→ Connection Cooldown (exponential backoff)
→ Circuit Breaker (provider-level)
→ Wait For Cooldown (auto-retry)
→ Next Combo Target (fallback chain)
→ Response
```
---
## Request Queue & Pacing
Per-connection request buckets smooth bursts before they hit upstream rate caps.
Configure in `Dashboard → Settings → Resilience`:
| Setting | Default | Description |
| --------------- | ------- | ------------------------------------ |
| Queue Size | `10` | Max queued requests per connection |
| Pacing Interval | `0ms` | Minimum gap between requests |
| Max Concurrent | `5` | Simultaneous requests per connection |
---
## Connection Cooldown
A single connection cools down after retryable failures. Features:
- **Exponential Backoff** — progressively longer cooldowns after each failure
- **`Retry-After` Header Support** — respects upstream hints
- **Configurable Base/Max** — tune cooldown duration per use case
- **Auto-Recovery** — connection automatically becomes available after cooldown expires
---
## Circuit Breaker
Provider-level protection against cascading failures:
1. **Connection-scoped `429` rate limits** stay in Connection Cooldown (don't trip the breaker)
2. **Provider-wide transient errors** (5xx, network timeouts) increment the failure counter
3. **Breaker trips** only after fallback is exhausted AND the provider still fails
4. **Recovery** — breaker automatically moves to half-open state after timeout, tests with probe request
Configure thresholds in `Dashboard → Settings → Resilience`.
---
## Wait For Cooldown
Instead of immediately failing when all connections are in cooldown, OmniRoute can wait for the earliest connection to expire and retry:
- **Automatic** — server waits for the earliest cooldown to expire
- **Transparent** — client sees a slightly delayed response instead of an error
- **Configurable** — enable/disable per combo or globally
---
## Anti-Thundering Herd
When multiple concurrent requests hit a failing provider simultaneously:
- **Mutex Protection** — only one retry attempt at a time per connection
- **Semaphore** — limits concurrent retry storms across connections
- **Deduplication** — identical requests within 5s window are deduplicated
---
## Combo Fallback Chains
The primary resilience mechanism. Configure in `Dashboard → Combos`:
```txt
Combo: "always-on"
1. cc/claude-opus-4-7 ← Primary (subscription)
2. cx/gpt-5.2-codex ← Secondary (subscription)
3. glm/glm-4.7 ← Cheap backup ($0.5/1M)
4. if/kimi-k2-thinking ← Free fallback (unlimited)
```
When provider #1 fails (quota, rate, or health), OmniRoute automatically routes to #2, then #3, then #4 — with zero manual intervention.
### 13 Routing Strategies
| Strategy | Description |
| ------------------- | ---------------------------------- |
| `priority` | First available in order |
| `weighted` | Weighted distribution |
| `fill-first` | Fill primary before moving |
| `round-robin` | Rotate through all targets |
| `p2c` | Power-of-two choices (quota-aware) |
| `random` | Random selection |
| `least-used` | Least recently used |
| `cost-optimized` | Cheapest available |
| `strict-random` | True random (no tracking) |
| `auto` | OmniRoute selects based on context |
| `lkgp` | Last Known Good Provider |
| `context-optimized` | Best for current context window |
| `context-relay` | Session handoff during rotation |
---
## TLS Fingerprint Spoofing
OmniRoute makes proxied traffic look like legitimate browser/CLI requests:
- **Browser-like TLS** via `wreq-js` — prevents bot detection
- **CLI Fingerprint Matching** — reorders headers and body fields to match native CLI binary signatures (Claude Code, Codex, etc.)
- **Proxy IP Preservation** — stealth features work on top of proxy IP masking
---
## Health Dashboard
Monitor all resilience components in real-time at `Dashboard → Health`:
- **Uptime** — server uptime and last restart
- **Provider Breaker States** — open/closed/half-open per provider
- **Connection Cooldowns** — active cooldowns with expiry times
- **Cache Stats** — signature + semantic cache hit rates
- **Lockouts** — API key lockouts and IP bans
- **Latency** — p50/p95/p99 percentiles
---
## See Also
- [Architecture Guide](ARCHITECTURE.md) — System architecture and internals
- [User Guide](USER_GUIDE.md) — Providers, combos, CLI integration
- [Auto-Combo Engine](AUTO-COMBO.md) — 6-factor scoring, mode packs

311
docs/SETUP_GUIDE.md Normal file
View File

@@ -0,0 +1,311 @@
# 📖 Setup Guide — OmniRoute
> Complete setup reference for OmniRoute. For the quick version, see the [Quick Start in README](../README.md#-quick-start).
## Table of Contents
- [Install Methods](#install-methods)
- [CLI Tool Configuration](#cli-tool-configuration)
- [Protocol Setup (MCP + A2A)](#protocol-setup-mcp--a2a)
- [Timeout Configuration](#timeout-configuration)
- [Split-Port Mode](#split-port-mode)
- [Void Linux (xbps-src)](#void-linux-xbps-src-template)
- [Uninstalling](#uninstalling)
---
## Install Methods
### npm (recommended)
```bash
npm install -g omniroute
omniroute
```
Dashboard opens at `http://localhost:20128` and API base URL is `http://localhost:20128/v1`.
### pnpm
```bash
pnpm install -g omniroute
pnpm approve-builds -g # Select all packages → approve
omniroute
```
> **pnpm users:** `pnpm approve-builds -g` is required to enable native build scripts for `better-sqlite3` and `@swc/core`.
### Arch Linux (AUR)
```bash
yay -S omniroute-bin
systemctl --user enable --now omniroute.service
```
The [AUR package](https://aur.archlinux.org/packages/omniroute-bin) installs OmniRoute and provides a systemd user service.
### From Source
```bash
cp .env.example .env
npm install
PORT=20128 DASHBOARD_PORT=20129 NEXT_PUBLIC_BASE_URL=http://localhost:20129 npm run dev
```
### Docker
See the [Docker Guide](DOCKER_GUIDE.md) for complete Docker setup including Compose profiles and Caddy HTTPS.
### CLI Options
| Command | Description |
| ----------------------- | ----------------------------------------------------------- |
| `omniroute` | Start server (`PORT=20128`, API and dashboard on same port) |
| `omniroute --port 3000` | Set canonical/API port to 3000 |
| `omniroute --mcp` | Start MCP server (stdio transport) |
| `omniroute --no-open` | Don't auto-open browser |
| `omniroute --help` | Show help |
---
## CLI Tool Configuration
### 1) Connect Providers and Create API Key
1. Open Dashboard → `Providers` and connect at least one provider (OAuth or API key).
2. Open Dashboard → `Endpoints` and create an API key.
3. (Optional) Open Dashboard → `Combos` and set your fallback chain.
### 2) Point Your Coding Tool
```txt
Base URL: http://localhost:20128/v1
API Key: [copy from Endpoint page]
Model: if/kimi-k2-thinking (or any provider/model prefix)
```
Works with Claude Code, Codex CLI, Gemini CLI, Cursor, Cline, OpenClaw, OpenCode, and OpenAI-compatible SDKs.
For detailed per-tool configuration (Claude Code, Codex CLI, Cursor, Cline, OpenClaw, Kilo Code, Copilot, and more), see the dedicated **[CLI Tools Guide](CLI-TOOLS.md)**.
---
## Protocol Setup (MCP + A2A)
### MCP Setup (Model Context Protocol)
Start MCP transport in stdio mode:
```bash
omniroute --mcp
```
Recommended validation flow:
```bash
# 1. Start MCP server
omniroute --mcp
# 2. From your MCP client, call:
omniroute_get_health # Should return system health
omniroute_list_combos # Should return active combos
# 3. Or run the full E2E suite:
npm run test:protocols:e2e
```
#### MCP Client Configuration
**Claude Code:**
```bash
claude mcp add-server omniroute --type http --url http://localhost:20128/api/mcp/stream
```
**Cursor / Cline:**
Add to your MCP settings:
```json
{
"mcpServers": {
"omniroute": {
"command": "omniroute",
"args": ["--mcp"],
"env": {}
}
}
}
```
**Full MCP documentation:** [MCP Server README](../open-sse/mcp-server/README.md) — 29 tools, IDE configs, Python/TS/Go clients.
### A2A Setup (Agent-to-Agent Protocol)
Verify the Agent Card:
```bash
curl http://localhost:20128/.well-known/agent.json
```
Send a task:
```bash
curl -X POST http://localhost:20128/a2a \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":"quickstart","method":"message/send","params":{"skill":"quota-management","messages":[{"role":"user","content":"Give me a short quota summary."}]}}'
```
**Full A2A documentation:** [A2A Server README](../src/lib/a2a/README.md) — JSON-RPC 2.0, skills, streaming, task lifecycle.
---
## Timeout Configuration
### Basic Timeouts
For most deployments, you only need these two variables:
| Variable | Default | Purpose |
| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts |
| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream |
Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline.
### Provider-Specific Notes
For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration.
For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default `anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, only forwards client-provided `cache_control` markers.
### Advanced Timeout Overrides
| Variable | Default | Purpose |
| ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- |
| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive |
| `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers |
| `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) |
| `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout |
| `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Undici idle keep-alive socket timeout |
| `TLS_CLIENT_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Timeout for TLS fingerprint requests made through `wreq-js` |
| `API_BRIDGE_PROXY_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` or `30000` | Timeout for `/v1` proxy forwarding from API port to dashboard port |
| `API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS` | `max(API_BRIDGE_PROXY_TIMEOUT_MS, 300000)` | Incoming request timeout on the API bridge server |
| `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | Incoming header timeout on the API bridge server |
| `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server |
| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) |
> **Note:** For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`).
### Reverse Proxy Compatibility
If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts.
---
## Split-Port Mode
Run API and Dashboard on separate ports for advanced scenarios (reverse proxy, container networking):
```bash
PORT=20128 DASHBOARD_PORT=20129 omniroute
# API: http://localhost:20128/v1
# Dashboard: http://localhost:20129
```
---
## Void Linux (xbps-src) Template
For Void Linux users, you can build a native package using `xbps-src`. Save this block as `srcpkgs/omniroute/template`:
```bash
# Template file for 'omniroute'
pkgname=omniroute
version=3.4.1
revision=1
hostmakedepends="nodejs python3 make"
depends="openssl"
short_desc="Universal AI gateway with smart routing for multiple LLM providers"
maintainer="zenobit <zenobit@disroot.org>"
license="MIT"
homepage="https://github.com/diegosouzapw/OmniRoute"
distfiles="https://github.com/diegosouzapw/OmniRoute/archive/refs/tags/v${version}.tar.gz"
checksum=009400afee90a9f32599d8fe734145cfd84098140b7287990183dde45ae2245b
system_accounts="_omniroute"
omniroute_homedir="/var/lib/omniroute"
export NODE_ENV=production
export npm_config_engine_strict=false
export npm_config_loglevel=error
export npm_config_fund=false
export npm_config_audit=false
do_build() {
local _gyp_arch
case "$XBPS_TARGET_MACHINE" in
aarch64*) _gyp_arch=arm64 ;;
armv7*|armv6*) _gyp_arch=arm ;;
i686*) _gyp_arch=ia32 ;;
*) _gyp_arch=x64 ;;
esac
NODE_ENV=development npm ci --ignore-scripts
npm run build
cp -r .next/static .next/standalone/.next/static
[ -d public ] && cp -r public .next/standalone/public || true
local _node_gyp=/usr/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js
(cd node_modules/better-sqlite3 && node "$_node_gyp" rebuild --arch="$_gyp_arch")
local _bs3_release=.next/standalone/node_modules/better-sqlite3/build/Release
mkdir -p "$_bs3_release"
cp node_modules/better-sqlite3/build/Release/better_sqlite3.node "$_bs3_release/"
rm -rf .next/standalone/node_modules/@img
for _mod in pino-abstract-transport split2 process-warning; do
cp -r "node_modules/$_mod" .next/standalone/node_modules/
done
}
do_check() {
npm run test:unit
}
do_install() {
vmkdir usr/lib/omniroute/.next
vcopy .next/standalone/. usr/lib/omniroute/.next/standalone
for _d in \
.next/standalone/.next/server/app/dashboard \
.next/standalone/.next/server/app/dashboard/settings \
.next/standalone/.next/server/app/dashboard/providers; do
touch "${DESTDIR}/usr/lib/omniroute/${_d}/.keep"
done
cat > "${WRKDIR}/omniroute" <<'EOF'
#!/bin/sh
export PORT="${PORT:-20128}"
export DATA_DIR="${DATA_DIR:-${XDG_DATA_HOME:-${HOME}/.local/share}/omniroute}"
export APP_LOG_TO_FILE="${APP_LOG_TO_FILE:-false}"
mkdir -p "${DATA_DIR}"
exec node /usr/lib/omniroute/.next/standalone/server.js "$@"
EOF
vbin "${WRKDIR}/omniroute"
}
post_install() {
vlicense LICENSE
}
```
---
## Uninstalling
| Command | Action |
| ------------------------ | ----------------------------------------------------------------------------------- |
| `npm run uninstall` | Removes the system app but **keeps your DB and configurations** in `~/.omniroute`. |
| `npm run uninstall:full` | Removes the app AND permanently **erases all configurations, keys, and databases**. |
> For detailed uninstall instructions across all methods, see [UNINSTALL.md](UNINSTALL.md).

View File

@@ -1,130 +0,0 @@
# Context Relay
🌐 **Languages:** 🇺🇸 [English](context-relay.md) · 🇪🇸 [es](../i18n/es/docs/features/context-relay.md) · 🇫🇷 [fr](../i18n/fr/docs/features/context-relay.md) · 🇩🇪 [de](../i18n/de/docs/features/context-relay.md) · 🇮🇹 [it](../i18n/it/docs/features/context-relay.md) · 🇷🇺 [ru](../i18n/ru/docs/features/context-relay.md) · 🇨🇳 [zh-CN](../i18n/zh-CN/docs/features/context-relay.md) · 🇯🇵 [ja](../i18n/ja/docs/features/context-relay.md) · 🇰🇷 [ko](../i18n/ko/docs/features/context-relay.md) · 🇸🇦 [ar](../i18n/ar/docs/features/context-relay.md) · 🇮🇳 [hi](../i18n/hi/docs/features/context-relay.md) · 🇮🇳 [in](../i18n/in/docs/features/context-relay.md) · 🇹🇭 [th](../i18n/th/docs/features/context-relay.md) · 🇻🇳 [vi](../i18n/vi/docs/features/context-relay.md) · 🇮🇩 [id](../i18n/id/docs/features/context-relay.md) · 🇲🇾 [ms](../i18n/ms/docs/features/context-relay.md) · 🇳🇱 [nl](../i18n/nl/docs/features/context-relay.md) · 🇵🇱 [pl](../i18n/pl/docs/features/context-relay.md) · 🇸🇪 [sv](../i18n/sv/docs/features/context-relay.md) · 🇳🇴 [no](../i18n/no/docs/features/context-relay.md) · 🇩🇰 [da](../i18n/da/docs/features/context-relay.md) · 🇫🇮 [fi](../i18n/fi/docs/features/context-relay.md) · 🇵🇹 [pt](../i18n/pt/docs/features/context-relay.md) · 🇷🇴 [ro](../i18n/ro/docs/features/context-relay.md) · 🇭🇺 [hu](../i18n/hu/docs/features/context-relay.md) · 🇧🇬 [bg](../i18n/bg/docs/features/context-relay.md) · 🇸🇰 [sk](../i18n/sk/docs/features/context-relay.md) · 🇺🇦 [uk-UA](../i18n/uk-UA/docs/features/context-relay.md) · 🇮🇱 [he](../i18n/he/docs/features/context-relay.md) · 🇵🇭 [phi](../i18n/phi/docs/features/context-relay.md) · 🇧🇷 [pt-BR](../i18n/pt-BR/docs/features/context-relay.md) · 🇨🇿 [cs](../i18n/cs/docs/features/context-relay.md) · 🇹🇷 [tr](../i18n/tr/docs/features/context-relay.md)
---
`context-relay` is a combo strategy that keeps session continuity when the active account
rotates before the conversation is finished.
The current runtime behaves like priority routing for model selection, then adds a
handoff layer on top:
- before the active account is exhausted, OmniRoute generates a compact structured summary
- after authentication selects a different account for the same session, OmniRoute injects
that summary as a system message into the next request
- once the handoff is consumed successfully, it is removed from storage
## When To Use It
Use `context-relay` when all of the following are true:
- the combo is expected to rotate between multiple accounts of the same provider
- losing short-term conversational continuity would hurt task quality
- the provider exposes enough quota information to predict an approaching account limit
This is most useful for long-running coding or research sessions that may outlive a single
account window.
## Runtime Flow
The current behavior is intentionally split across two runtime layers.
### 0% to 84% quota used
No handoff is generated. Requests behave like normal priority routing.
### 85% to 94% quota used
If the active provider is enabled in `handoffProviders`, OmniRoute generates a structured
handoff summary in the background before the account is fully exhausted.
Important details:
- the default warning threshold is `0.85`
- the hard stop for generation is `0.95`
- only one in-flight handoff generation is allowed per `sessionId + comboName`
- if an active handoff already exists for that session/combo, no duplicate summary is generated
### 95% or more quota used
No new handoff is generated. At this point the system is already in or near exhaustion and
the runtime avoids scheduling another summary request.
### After account rotation
When the next request for the same session resolves to a different authenticated account,
OmniRoute prepends the stored handoff as a system message. Injection happens only after the
real account switch is known.
## Handoff Payload
The persisted handoff payload is stored in `context_handoffs` and includes:
- `sessionId`
- `comboName`
- `fromAccount`
- `summary`
- `keyDecisions`
- `taskProgress`
- `activeEntities`
- `messageCount`
- `model`
- `warningThresholdPct`
- `generatedAt`
- `expiresAt`
The summary model is instructed to return a JSON object with this structure:
```json
{
"summary": "Dense summary of what matters for continuity",
"keyDecisions": ["Decision 1", "Decision 2"],
"taskProgress": "What is done, what is pending, and the next step",
"activeEntities": ["fileA.ts", "feature X", "provider Y"]
}
```
At injection time, OmniRoute converts that payload into a `<context_handoff>` system
message so the next account can continue with the correct local context.
## Configuration
`context-relay` supports these config fields:
- `handoffThreshold`: warning threshold for summary generation, default `0.85`
- `handoffModel`: optional model override used only for summary generation
- `handoffProviders`: allowlist of providers allowed to trigger handoff generation
Global defaults can be configured in Settings, and combo-specific values can override them
in the Combos page.
## Architectural Note
The current implementation does not use a standalone `handleContextRelayCombo` handler.
Instead:
- `open-sse/services/combo.ts` decides whether a successful turn should generate a handoff
- `src/sse/handlers/chat.ts` injects the handoff only after authentication resolves the
actual account used for the request
This split is intentional in the current codebase because the combo loop alone does not know
whether the request stayed on the same account or actually switched accounts.
## Limitations
- Effective runtime support is currently centered on `codex` quota rotation.
- `handoffProviders` is already modeled as a config surface, but real handoff generation
still depends on provider-specific quota plumbing.
- The summary is intentionally compact and recent-history based; it is not a full transcript
replay mechanism.
- Handoffs are scoped by `sessionId + comboName` and expire automatically.
- If the session does not switch accounts, the stored handoff is not injected.
## Recommended Usage Pattern
- use multiple accounts from the same provider
- keep stable `sessionId` values across the session
- set `handoffThreshold` early enough to leave room for the background summary request
- treat the feature as continuity assistance, not as a replacement for persistent memory

View File

@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 3.7.7
version: 3.7.8
description: |
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
endpoint that routes requests to multiple AI providers with load balancing,

View File

@@ -281,21 +281,25 @@ function installUpdate() {
function setupContentSecurityPolicy() {
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
const scriptSrc = isDev
? "script-src 'self' 'unsafe-inline' 'unsafe-eval'"
: "script-src 'self' 'unsafe-inline'";
? "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:"
: "script-src 'self' 'unsafe-inline' blob:";
const csp = [
"default-src 'self'",
`connect-src 'self' http://localhost:* ws://localhost:* https://*.omniroute.online https://*.omniroute.dev`,
"base-uri 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
"frame-src 'none'",
"child-src 'none'",
"form-action 'self'",
scriptSrc,
"script-src-attr 'none'",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com data:",
"img-src 'self' data: blob: https:",
"media-src 'self'",
"media-src 'self' data: blob:",
`connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https://*.omniroute.online https://*.omniroute.dev`,
"worker-src 'self' blob:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"manifest-src 'self'",
].join("; ");
callback({
@@ -327,6 +331,7 @@ function createWindow() {
contextIsolation: true,
nodeIntegration: false,
webSecurity: true,
webviewTag: false,
},
show: false,
backgroundColor: "#0a0a0a",

4402
electron/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute-desktop",
"version": "3.7.7",
"version": "3.7.8",
"description": "OmniRoute Desktop Application",
"main": "main.js",
"author": {

View File

@@ -5,6 +5,51 @@ import { fileURLToPath } from "node:url";
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
const distDir = process.env.NEXT_DIST_DIR || ".next";
const projectRoot = dirname(fileURLToPath(import.meta.url));
const scriptSrc =
process.env.NODE_ENV === "development"
? "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:"
: "script-src 'self' 'unsafe-inline' blob:";
const contentSecurityPolicy = [
"default-src 'self'",
"base-uri 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
"form-action 'self'",
scriptSrc,
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com data:",
"img-src 'self' data: blob: https:",
"media-src 'self' data: blob:",
"connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https: wss:",
"worker-src 'self' blob:",
"manifest-src 'self'",
].join("; ");
const securityHeaders = [
{
key: "Content-Security-Policy",
value: contentSecurityPolicy,
},
{
key: "X-Frame-Options",
value: "DENY",
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=()",
},
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
];
/** @type {import('next').NextConfig} */
const nextConfig = {
@@ -102,11 +147,6 @@ const nextConfig = {
})
);
// Mark @ngrok/ngrok as external to prevent webpack from trying to bundle its .node binaries
config.externals = config.externals || [];
config.externals.push({
"@ngrok/ngrok": "commonjs @ngrok/ngrok",
});
// ── Turbopack / Next.js 16 module-hash patch (#394, #396, #398) ────────
//
// Next.js 16 (with or without Turbopack) compiles the instrumentation hook
@@ -128,6 +168,7 @@ const nextConfig = {
const KNOWN_EXTERNALS = new Set([
"better-sqlite3",
"keytar",
"@ngrok/ngrok",
"wreq-js",
"zod",
"pino",
@@ -181,11 +222,21 @@ const nextConfig = {
tls: false,
crypto: false,
process: false,
os: false,
};
}
return config;
},
async headers() {
return [
{
source: "/:path*",
headers: securityHeaders,
},
];
},
async rewrites() {
return [
{

View File

@@ -262,6 +262,18 @@ export const AUDIO_SPEECH_PROVIDERS: Record<string, AudioProvider> = {
{ id: "generative", name: "Polly Generative" },
],
},
"xiaomi-mimo": {
id: "xiaomi-mimo",
baseUrl: "https://api.xiaomimimo.com/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
format: "xiaomi-mimo-tts",
models: [
{ id: "mimo-v2.5-tts", name: "MiMo V2.5 TTS" },
{ id: "mimo-v2.5-tts-voicedesign", name: "MiMo V2.5 Voice Design" },
{ id: "mimo-v2.5-tts-voiceclone", name: "MiMo V2.5 Voice Clone" },
],
},
};
/**

View File

@@ -44,16 +44,19 @@ export const CLI_FINGERPRINTS: Record<string, CliFingerprint> = {
],
bodyFieldOrder: [
"model",
"messages",
"temperature",
"top_p",
"max_tokens",
"stream",
"input",
"instructions",
"store",
"reasoning",
"tools",
"tool_choice",
"response_format",
"n",
"stop",
"include",
"service_tier",
"prompt_cache_key",
"client_metadata",
"parallel_tool_calls",
"metadata",
],
// Codex builds mode-specific client headers in its executor/config. The CLI fingerprint must
// only preserve ordering here; overriding User-Agent with a generic value would erase the
@@ -186,6 +189,19 @@ export const CLI_FINGERPRINTS: Record<string, CliFingerprint> = {
],
userAgent: getAntigravityUserAgent,
},
"gemini-cli": {
headerOrder: [
"Host",
"Content-Type",
"User-Agent",
"X-Goog-Api-Client",
"Accept",
"Accept-Encoding",
"Connection",
"Authorization",
],
bodyFieldOrder: ["model", "project", "user_prompt_id", "request"],
},
qwen: {
headerOrder: [
"Host",

View File

@@ -139,22 +139,22 @@ export const PROVIDER_PROFILES = {
transientCooldown: 5000, // 5s (session tokens — short recovery)
rateLimitCooldown: 60000, // 60s default when no retry-after header
maxBackoffLevel: 8, // Higher ceiling (sessions may stay bad longer)
circuitBreakerThreshold: 3, // Opens fast (low limit providers)
circuitBreakerThreshold: 8, // Scaled for 500+ connections (was 3)
circuitBreakerReset: 60000, // 1min reset
// Provider-level circuit breaker (entire provider cooldown after repeated failures)
providerFailureThreshold: 3, // 3 transient failures trigger provider cooldown
providerFailureWindowMs: 600000, // 10min window for counting failures
providerFailureThreshold: 10, // Scaled for 500+ connections (was 3)
providerFailureWindowMs: 900000, // 15min window (was 10min)
providerCooldownMs: 300000, // 5min cooldown when threshold reached
},
apikey: {
transientCooldown: 3000, // 3s (API providers recover faster)
rateLimitCooldown: 0, // 0 = respect retry-after header from provider
maxBackoffLevel: 5, // Lower ceiling (API quotas reset at known intervals)
circuitBreakerThreshold: 5, // More tolerant (occasional 502 is normal)
circuitBreakerThreshold: 12, // Scaled for 500+ connections (was 5)
circuitBreakerReset: 30000, // 30s reset
// Provider-level circuit breaker (entire provider cooldown after repeated failures)
providerFailureThreshold: 5, // 5 transient failures trigger provider cooldown
providerFailureWindowMs: 1200000, // 20min window for counting failures
providerFailureThreshold: 15, // Scaled for 500+ connections (was 5)
providerFailureWindowMs: 1800000, // 30min window (was 20min)
providerCooldownMs: 600000, // 10min cooldown when threshold reached
},
// Local providers (localhost inference backends like Ollama, LM Studio, oMLX).

View File

@@ -66,6 +66,17 @@ export const EMBEDDING_PROVIDERS: Record<string, EmbeddingProvider> = {
],
},
upstage: {
id: "upstage",
baseUrl: "https://api.upstage.ai/v1/embeddings",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "embedding-query", name: "Embedding Query", dimensions: 4096 },
{ id: "embedding-passage", name: "Embedding Passage", dimensions: 4096 },
],
},
mistral: {
id: "mistral",
baseUrl: "https://api.mistral.ai/v1/embeddings",

View File

@@ -0,0 +1,18 @@
import { stripTrailingSlashes } from "../utils/urlSanitize.ts";
export const MARITALK_DEFAULT_BASE_URL = "https://chat.maritaca.ai/api";
export function normalizeMaritalkBaseUrl(value: string | null | undefined): string {
const normalized = stripTrailingSlashes((value || MARITALK_DEFAULT_BASE_URL).trim());
if (!normalized) return MARITALK_DEFAULT_BASE_URL;
return normalized.replace(/\/chat\/(?:completions|inference)$/i, "");
}
export function buildMaritalkChatUrl(value: string | null | undefined): string {
return `${normalizeMaritalkBaseUrl(value)}/chat/completions`;
}
export function buildMaritalkModelsUrl(value: string | null | undefined): string {
return `${normalizeMaritalkBaseUrl(value)}/models`;
}

View File

@@ -46,25 +46,25 @@ export function getGitHubCopilotChatHeaders(
};
}
function getRuntimePlatform(): string {
export function getRuntimePlatform(): string {
return typeof process !== "undefined" && typeof process.platform === "string"
? process.platform
: "unknown";
}
function getRuntimeArch(): string {
export function getRuntimeArch(): string {
return typeof process !== "undefined" && typeof process.arch === "string"
? process.arch
: "unknown";
}
function getRuntimeVersion(): string {
export function getRuntimeVersion(): string {
return typeof process !== "undefined" && typeof process.version === "string"
? process.version
: "unknown";
}
function normalizeStainlessPlatform(platform: string = getRuntimePlatform()): string {
export function normalizeStainlessPlatform(platform: string = getRuntimePlatform()): string {
const normalized = platform.toLowerCase();
if (normalized.includes("ios")) return "iOS";
if (normalized === "android") return "Android";
@@ -76,7 +76,7 @@ function normalizeStainlessPlatform(platform: string = getRuntimePlatform()): st
return normalized ? `Other:${normalized}` : "Unknown";
}
function normalizeStainlessArch(arch: string = getRuntimeArch()): string {
export function normalizeStainlessArch(arch: string = getRuntimeArch()): string {
if (arch === "x32") return "x32";
if (arch === "x86_64" || arch === "x64") return "x64";
if (arch === "arm") return "arm";

View File

@@ -6,7 +6,6 @@
* is auto-generated from this registry.
*/
import { platform, arch } from "os";
import { ANTIGRAVITY_BASE_URLS } from "./antigravityUpstream.ts";
import { ANTIGRAVITY_PUBLIC_MODELS } from "./antigravityModelAliases.ts";
import {
@@ -24,6 +23,7 @@ import {
GLM_SHARED_HEADERS,
GLM_SHARED_MODELS,
} from "./glmProvider.ts";
import { MARITALK_DEFAULT_BASE_URL } from "./maritalk.ts";
import {
CURSOR_REGISTRY_VERSION,
getAntigravityProviderHeaders,
@@ -32,6 +32,8 @@ import {
getKiroServiceHeaders,
getQoderDefaultHeaders,
getQwenOauthHeaders,
getRuntimePlatform,
getRuntimeArch,
} from "./providerHeaderProfiles.ts";
import type { ProviderRequestDefaults } from "../services/providerRequestDefaults.ts";
@@ -191,7 +193,7 @@ const CHAT_OPENAI_COMPAT_MODELS: Record<string, RegistryModel[]> = {
"aisingapore/Qwen-SEA-LION-v4-32B-IT",
"allenai/Olmo-3-32B-Think",
]),
moonshot: buildModels(["kimi-k2.5", "kimi-latest", "moonshot-v1-auto"]),
moonshot: buildModels(["kimi-k2.6", "kimi-k2.5"]),
"meta-llama": buildModels([
"Llama-3.3-70B-Instruct",
"Llama-4-Maverick-17B-128E-Instruct-FP8",
@@ -226,19 +228,9 @@ const CHAT_OPENAI_COMPAT_MODELS: Record<string, RegistryModel[]> = {
gigachat: buildModels(["GigaChat-2-Max", "GigaChat-2-Pro", "GigaChat-2-Lite"]),
venice: buildModels(["venice-latest"]),
codestral: buildModels(["codestral-2405", "codestral-latest"]),
upstage: buildModels(["solar-pro", "solar-mini", "solar-docvision", "solar-embedding-1-large"]),
maritalk: buildModels(["sabia-3", "sabia-3-small"]),
"xiaomi-mimo": buildModels([
"mimo-v2.5-pro",
"mimo-v2.5",
"mimo-v2.5-tts",
"mimo-v2.5-tts-voiceclone",
"mimo-v2.5-tts-voicedesign",
"mimo-v2-pro",
"mimo-v2-omni",
"mimo-v2-tts",
"mimo-v2-flash",
]),
upstage: buildModels(["solar-pro3", "solar-mini"]),
maritalk: buildModels(["sabia-4", "sabia-3.1", "sabiazinho-4", "sabiazinho-3"]),
"xiaomi-mimo": buildModels(["mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-omni", "mimo-v2-flash"]),
"inference-net": buildModels([
"meta-llama/Llama-3.3-70B-Instruct",
"deepseek-ai/DeepSeek-R1",
@@ -254,7 +246,7 @@ const CHAT_OPENAI_COMPAT_MODELS: Record<string, RegistryModel[]> = {
};
function mapStainlessOs() {
switch (platform()) {
switch (getRuntimePlatform()) {
case "darwin":
return "MacOS";
case "win32":
@@ -262,12 +254,12 @@ function mapStainlessOs() {
case "linux":
return "Linux";
default:
return `Other::${platform()}`;
return `Other::${getRuntimePlatform()}`;
}
}
function mapStainlessArch() {
switch (arch()) {
switch (getRuntimeArch()) {
case "x64":
return "x64";
case "arm64":
@@ -275,7 +267,7 @@ function mapStainlessArch() {
case "ia32":
return "x86";
default:
return `other::${arch()}`;
return `other::${getRuntimeArch()}`;
}
}
@@ -398,7 +390,6 @@ export const REGISTRY: Record<string, RegistryEntry> = {
{ id: "gpt-5.5-medium", name: "GPT 5.5 (Medium)", ...GPT_5_5_CODEX_CAPABILITIES },
{ id: "gpt-5.5", name: "GPT 5.5", ...GPT_5_5_CODEX_CAPABILITIES },
{ id: "gpt-5.5-low", name: "GPT 5.5 (Low)", ...GPT_5_5_CODEX_CAPABILITIES },
{ id: "gpt-5.5-mini", name: "GPT 5.5 Mini", targetFormat: "openai-responses" },
{ id: "gpt-5.4", name: "GPT 5.4", targetFormat: "openai-responses" },
{ id: "gpt-5.4-mini", name: "GPT 5.4 Mini", targetFormat: "openai-responses" },
{ id: "gpt-5.3-codex-spark", name: "GPT 5.3 Codex Spark" },
@@ -1094,6 +1085,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "grok-4.3", name: "Grok 4.3" },
{ id: "grok-4.20-multi-agent-0309", name: "Grok 4.20 Multi Agent" },
{ id: "grok-4.20-0309-reasoning", name: "Grok 4.20 Reasoning" },
{ id: "grok-4.20-0309-non-reasoning", name: "Grok 4.20" },
@@ -1111,19 +1103,18 @@ export const REGISTRY: Record<string, RegistryEntry> = {
authType: "apikey",
authHeader: "cookie",
models: [
{ id: "gpt-5.3-instant", name: "GPT-5.3 Instant" },
{ id: "gpt-5.3", name: "GPT-5.3" },
{ id: "gpt-5.3-mini", name: "GPT-5.3 Mini" },
{ id: "gpt-5.5-thinking", name: "GPT-5.5 Thinking" },
{ id: "gpt-5.4-thinking", name: "GPT-5.4 Thinking" },
{ id: "gpt-5.4-thinking-mini", name: "GPT-5.4 Thinking Mini" },
{ id: "gpt-5.2-instant", name: "GPT-5.2 Instant" },
{ id: "gpt-5.2", name: "GPT-5.2" },
{ id: "gpt-5.2-thinking", name: "GPT-5.2 Thinking" },
{ id: "gpt-5.1", name: "GPT-5.1" },
{ id: "gpt-5", name: "GPT-5" },
{ id: "gpt-5-mini", name: "GPT-5 Mini" },
{ id: "o3", name: "o3" },
{ id: "gpt-5.5-pro", name: "GPT-5.5 Pro" }, //pro tier only
{ id: "gpt-5.5-thinking", name: "GPT-5.5 Thinking" }, //plus, pro tier
{ id: "gpt-5.4-pro", name: "GPT-5.4 Pro" }, //pro tier only
{ id: "gpt-5.4-thinking", name: "GPT-5.4 Thinking" }, //plus, pro tier
{ id: "gpt-5.4-thinking-mini", name: "GPT-5.4 Thinking Mini" }, //free-login only
{ id: "gpt-5.3", name: "GPT-5.3" }, //free, free-login, plus, pro tier
{ id: "gpt-5.3-mini", name: "GPT-5.3 Mini" }, //limit fallback
{ id: "gpt-5.2-pro", name: "GPT-5.2 Pro" }, //pro tier only
{ id: "gpt-5.2-thinking", name: "GPT-5.2 Thinking" }, //plus ~ tier
{ id: "gpt-5.2-instant", name: "GPT-5.2 Instant" }, //plus ~ tier
{ id: "o3", name: "o3" }, //plus ~ tier
{ id: "gpt-4-5", name: "GPT-4.5" }, //pro tier only
],
},
@@ -1137,10 +1128,10 @@ export const REGISTRY: Record<string, RegistryEntry> = {
authHeader: "cookie",
passthroughModels: true,
models: [
{ id: "fast", name: "Grok Fast" },
{ id: "expert", name: "Grok 4.20 Thinking" },
{ id: "heavy", name: "Grok 4.20 Multi Agent" },
{ id: "grok-420-computer-use-sa", name: "Grok 4.3 (Beta)" },
{ id: "fast", name: "Grok Fast", toolCalling: true },
{ id: "expert", name: "Grok 4.20 Thinking", toolCalling: true },
{ id: "heavy", name: "Grok 4.20 Multi Agent", toolCalling: true },
{ id: "grok-420-computer-use-sa", name: "Grok 4.3 (Beta)", toolCalling: true },
],
},
@@ -1197,6 +1188,29 @@ export const REGISTRY: Record<string, RegistryEntry> = {
],
},
"muse-spark-web": {
id: "muse-spark-web",
alias: "ms-web",
format: "openai",
executor: "muse-spark-web",
baseUrl: "https://www.meta.ai/api/graphql",
authType: "apikey",
authHeader: "cookie",
models: [
{ id: "muse-spark", name: "Muse Spark" },
{
id: "muse-spark-thinking",
name: "Muse Spark Thinking",
supportsReasoning: true,
},
{
id: "muse-spark-contemplating",
name: "Muse Spark Contemplating",
supportsReasoning: true,
},
],
},
together: {
id: "together",
alias: "together",
@@ -1953,9 +1967,9 @@ export const REGISTRY: Record<string, RegistryEntry> = {
alias: "maritalk",
format: "openai",
executor: "default",
baseUrl: "https://chat.maritaca.ai/api/chat/inference",
baseUrl: MARITALK_DEFAULT_BASE_URL,
authType: "apikey",
authHeader: "bearer",
authHeader: "key",
models: CHAT_OPENAI_COMPAT_MODELS.maritalk,
},
@@ -2033,6 +2047,20 @@ export const REGISTRY: Record<string, RegistryEntry> = {
],
passthroughModels: true,
},
// Free tier: 50 RPM, 500,000 TPM
"nous-research": {
id: "nous-research",
alias: "nous",
format: "openai",
executor: "default",
baseUrl: "https://inference-api.nousresearch.com/v1",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "Hermes-4-405B", name: "Hermes 4 7B (Nous Research)" },
{ id: "Hermes-4-70B", name: "Hermes 4 70B (Nous Research)" },
],
},
};
// ── Generator Functions ───────────────────────────────────────────────────

View File

@@ -16,7 +16,7 @@ export interface BaseProvider<M extends BaseModel = BaseModel> {
id: string;
baseUrl: string;
authType: string; // "apikey" | "oauth" | "none"
authHeader: string; // "bearer" | "token" | "xi-api-key" | "x-api-key" | "none"
authHeader: string; // "bearer" | "key" | "token" | "xi-api-key" | "x-api-key" | "none"
format?: string;
models: M[];
}
@@ -76,7 +76,7 @@ export function getAllModelsFromRegistry<P extends BaseProvider>(
/**
* Build auth headers for a provider.
* Handles bearer, token, xi-api-key, x-api-key, and none.
* Handles bearer, key, token, xi-api-key, x-api-key, and none.
*/
export function buildAuthHeaders(
provider: BaseProvider,
@@ -87,6 +87,8 @@ export function buildAuthHeaders(
}
switch (provider.authHeader) {
case "key":
return { Authorization: `Key ${token}` };
case "token":
return { Authorization: `Token ${token}` };
case "xi-api-key":

View File

@@ -60,6 +60,15 @@ type AntigravityCollectedStream = {
remainingCredits: Array<{ creditType: string; creditAmount: string }> | null;
};
type AntigravityRequestEnvelope = Record<string, unknown> & {
project: string;
model: string;
userAgent: "antigravity";
requestType: "agent";
requestId: string;
request: Record<string, unknown>;
};
/**
* Per-account GOOGLE_ONE_AI credits-exhausted tracker.
* Key: accountId (OAuth subject / email). Value: expiry timestamp.
@@ -249,7 +258,7 @@ export class AntigravityExecutor extends BaseExecutor {
return scrubProxyAndFingerprintHeaders(raw);
}
transformRequest(model, body, stream, credentials) {
transformRequest(model, body, stream, credentials): AntigravityRequestEnvelope | Response {
// TODO: Consider removing project override like gemini-cli.ts — stored projectId
// can become stale for Cloud Code accounts, causing 403 "has not been used in project X".
// Antigravity accounts may have more stable project IDs, but the risk exists.
@@ -352,14 +361,24 @@ export class AntigravityExecutor extends BaseExecutor {
}
}
const {
project: _project,
model: _model,
userAgent: _userAgent,
requestType: _requestType,
requestId: _requestId,
request: _request,
...passthroughFields
} = normalizedBody;
return {
...normalizedBody,
project: projectId,
model: upstreamModel,
userAgent: "antigravity",
requestType: "agent",
requestId: `agent-${crypto.randomUUID()}`,
request: transformedRequest,
...passthroughFields,
};
}
@@ -572,15 +591,17 @@ export class AntigravityExecutor extends BaseExecutor {
const url = this.buildUrl(model, upstreamStream, urlIndex);
const headers = this.buildHeaders(credentials, upstreamStream);
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders);
let transformedBody = await this.transformRequest(model, body, upstreamStream, credentials);
const transformed = await this.transformRequest(model, body, upstreamStream, credentials);
let requestToolNameMap: Map<string, string> | null = null;
if (transformedBody instanceof Response) {
return { response: transformedBody, url, headers, transformedBody: body };
if (transformed instanceof Response) {
return { response: transformed, url, headers, transformedBody: body };
}
let transformedBody: Record<string, unknown> = transformed;
if (transformedBody && typeof transformedBody === "object") {
const cloaked = cloakAntigravityToolPayload(transformedBody as Record<string, unknown>);
const cloaked = cloakAntigravityToolPayload(transformedBody);
transformedBody = cloaked.body;
requestToolNameMap = cloaked.toolNameMap;
}

View File

@@ -220,9 +220,11 @@ export class BaseExecutor {
buildHeaders(
credentials: ProviderCredentials,
stream = true,
clientHeaders?: Record<string, string> | null
clientHeaders?: Record<string, string> | null,
model?: string
): Record<string, string> {
void clientHeaders;
void model;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...this.config.headers,
@@ -442,7 +444,7 @@ export class BaseExecutor {
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
const url = this.buildUrl(model, stream, urlIndex, activeCredentials);
const headers = this.buildHeaders(activeCredentials, stream, clientHeaders);
const headers = this.buildHeaders(activeCredentials, stream, clientHeaders, model);
applyConfiguredUserAgent(headers, activeCredentials?.providerSpecificData);
const ccRequestDefaults = isClaudeCodeCompatible(this.provider)

View File

@@ -1182,6 +1182,24 @@ export class CodexExecutor extends BaseExecutor {
body.service_tier = requestDefaults.serviceTier;
}
// Issue #1832 & #1853: Map messages to input for clients like Cursor 5.5 that use responses/compact but send messages instead of input.
// This MUST run before convertSystemToDeveloperRole and stripStoredItemReferences.
if (!body.input && Array.isArray(body.messages)) {
body.input = body.messages.map((msg: any) => ({
type: "message",
role: typeof msg.role === "string" ? msg.role : "user",
content:
typeof msg.content === "string"
? [{ type: "input_text", text: msg.content }]
: Array.isArray(msg.content)
? msg.content.map((c: any) => {
if (c && c.type === "text") return { type: "input_text", text: c.text };
return c;
})
: [],
}));
}
// ── Cache-aware system prompt handling (both paths) ──
//
// Convert system → developer role IN-PLACE so system prompts remain in the
@@ -1251,23 +1269,6 @@ export class CodexExecutor extends BaseExecutor {
// so any references to previous response items would cause 404 errors.
stripStoredItemReferences(body);
// Issue #1832: Map messages to input for clients like Cursor 5.5 that use responses/compact but send messages instead of input
if (!body.input && Array.isArray(body.messages)) {
body.input = body.messages.map((msg: any) => ({
type: "message",
role: typeof msg.role === "string" ? msg.role : "user",
content:
typeof msg.content === "string"
? [{ type: "input_text", text: msg.content }]
: Array.isArray(msg.content)
? msg.content.map((c: any) => {
if (c && c.type === "text") return { type: "input_text", text: c.text };
return c;
})
: [],
}));
}
// Issue #806: Even for native passthrough, some clients (purist completions) might indiscriminately inject
// a `messages` or `prompt` array which the strict Codex Responses schema rejects.
delete body.messages;

View File

@@ -8,6 +8,7 @@ import {
joinClaudeCodeCompatibleUrl,
} from "../services/claudeCodeCompatible.ts";
import { getGigachatAccessToken } from "../services/gigachatAuth.ts";
import { getRegistryEntry } from "../config/providerRegistry.ts";
import { applyProviderRequestDefaults } from "../services/providerRequestDefaults.ts";
import {
getOpenAICompatibleType,
@@ -21,6 +22,7 @@ import { buildBedrockChatUrl } from "../config/bedrock.ts";
import { buildWatsonxChatUrl } from "../config/watsonx.ts";
import { buildOciChatUrl } from "../config/oci.ts";
import { buildSapChatUrl, getSapResourceGroup } from "../config/sap.ts";
import { buildMaritalkChatUrl } from "../config/maritalk.ts";
function normalizeBaseUrl(baseUrl) {
return (baseUrl || "").trim().replace(/\/$/, "");
@@ -180,6 +182,10 @@ export class DefaultExecutor extends BaseExecutor {
const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl;
return normalizeGigachatChatUrl(baseUrl);
}
case "maritalk": {
const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl;
return buildMaritalkChatUrl(baseUrl);
}
case "lm-studio":
case "modal":
case "reka":
@@ -193,6 +199,11 @@ export class DefaultExecutor extends BaseExecutor {
const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl;
return normalizeOpenAIChatUrl(baseUrl);
}
case "zai":
case "glm-coding-apikey": {
const zaiBaseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl;
return `${zaiBaseUrl}?beta=true`;
}
case "claude":
case "glm":
case "glmt":
@@ -206,8 +217,11 @@ export class DefaultExecutor extends BaseExecutor {
const resourceUrl = credentials?.providerSpecificData?.resourceUrl;
return `https://${resourceUrl || "portal.qwen.ai"}/v1/chat/completions`;
}
default:
return this.config.baseUrl;
default: {
const url = this.config.baseUrl;
const entry = getRegistryEntry(this.provider);
return entry?.urlSuffix ? `${url}${entry.urlSuffix}` : url;
}
}
}
@@ -284,6 +298,13 @@ export class DefaultExecutor extends BaseExecutor {
}
break;
}
case "maritalk": {
const token = effectiveKey || credentials.accessToken;
if (token) {
headers["Authorization"] = `Key ${token}`;
}
break;
}
case "claude":
case "anthropic":
effectiveKey
@@ -295,6 +316,8 @@ export class DefaultExecutor extends BaseExecutor {
case "kimi-coding":
case "bailian-coding-plan":
case "kimi-coding-apikey":
case "zai":
case "glm-coding-apikey":
headers["x-api-key"] = effectiveKey || credentials.accessToken;
break;
default:
@@ -315,9 +338,18 @@ export class DefaultExecutor extends BaseExecutor {
headers["anthropic-version"] = "2023-06-01";
}
} else {
const bearerToken = effectiveKey || credentials.accessToken;
if (bearerToken) {
headers["Authorization"] = `Bearer ${bearerToken}`;
// Use registry authHeader if available, otherwise default to bearer
const entry = getRegistryEntry(this.provider);
const authHeader = entry?.authHeader || "bearer";
const token = effectiveKey || credentials.accessToken;
if (token) {
if (authHeader === "x-api-key") {
headers["x-api-key"] = token;
} else if (authHeader === "x-goog-api-key") {
headers["x-goog-api-key"] = token;
} else {
headers["Authorization"] = `Bearer ${token}`;
}
}
}
}
@@ -353,7 +385,7 @@ export class DefaultExecutor extends BaseExecutor {
if (typeof withDefaults === "object" && withDefaults !== null && !Array.isArray(withDefaults)) {
if (this.provider?.startsWith?.("anthropic-compatible-")) {
if (Object.prototype.hasOwnProperty.call(withDefaults, "stream_options")) {
const withoutStreamOptions = { ...withDefaults } as Record<string, any>;
const withoutStreamOptions = { ...withDefaults } as Record<string, unknown>;
delete withoutStreamOptions.stream_options;
withDefaults = withoutStreamOptions;
}
@@ -365,12 +397,12 @@ export class DefaultExecutor extends BaseExecutor {
withDefaults = {
...withDefaults,
stream_options: {
...((withDefaults as any).stream_options || {}),
...(((withDefaults as Record<string, unknown>).stream_options as object) || {}),
include_usage: true,
},
};
} else if (Object.prototype.hasOwnProperty.call(withDefaults, "stream_options")) {
const withoutStreamOptions = { ...withDefaults } as Record<string, any>;
const withoutStreamOptions = { ...withDefaults } as Record<string, unknown>;
delete withoutStreamOptions.stream_options;
withDefaults = withoutStreamOptions;
}
@@ -378,7 +410,10 @@ export class DefaultExecutor extends BaseExecutor {
}
if (this.provider === "qwen" && typeof withDefaults === "object" && withDefaults !== null) {
return sanitizeQwenThinkingToolChoice(withDefaults as any, "QwenExecutor");
return sanitizeQwenThinkingToolChoice(
withDefaults as Record<string, unknown>,
"QwenExecutor"
);
}
return withDefaults;
}

View File

@@ -1,6 +1,7 @@
import { BaseExecutor } from "./base.ts";
import { randomUUID } from "crypto";
import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts";
import { geminiCLIUserAgent, googApiClientHeader } from "../services/antigravityHeaders.ts";
import { getGeminiCliHeaders } from "../services/geminiCliHeaders.ts";
import { scrubProxyAndFingerprintHeaders } from "../services/antigravityHeaderScrub.ts";
import { obfuscateSensitiveWords } from "../services/antigravityObfuscation.ts";
import {
@@ -19,15 +20,13 @@ const ONBOARD_DELAY_MS = 5_000;
const DEFAULT_PROJECT_ID = "default-project";
const DEFAULT_ONBOARD_TIER = "free-tier";
const LOAD_CODE_ASSIST_METADATA = Object.freeze({
ideType: "ANTIGRAVITY",
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
duetProject: DEFAULT_PROJECT_ID,
});
const ONBOARD_METADATA = Object.freeze({
ideType: "ANTIGRAVITY",
ideType: "IDE_UNSPECIFIED",
pluginType: "GEMINI",
duetProject: DEFAULT_PROJECT_ID,
});
// Per-account cache: accessToken -> { projectId, expiresAt }
@@ -51,6 +50,22 @@ function normalizeGeminiModel(model: string): string {
: "unknown";
}
function generateGeminiCliRequestId(): string {
return `agent-${randomUUID()}`;
}
function generateGeminiCliSessionId(): string {
return `-${Date.now()}`;
}
function cloneGeminiCliRecord(value: Record<string, any>): Record<string, any> {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return JSON.parse(JSON.stringify(value));
}
function extractProjectId(payload: unknown): string {
if (!payload || typeof payload !== "object") return "";
const data = payload as LoadCodeAssistResponse;
@@ -105,31 +120,27 @@ export class GeminiCLIExecutor extends BaseExecutor {
}
buildUrl(model, stream, urlIndex = 0) {
this._currentModel = normalizeGeminiModel(model);
const action = stream ? "streamGenerateContent?alt=sse" : "generateContent";
return `${this.config.baseUrl}:${action}`;
}
buildHeaders(credentials, stream = true) {
const raw = {
"Content-Type": "application/json",
Authorization: `Bearer ${credentials.accessToken}`,
// Dynamic headers matching native GeminiCLI client
"User-Agent": geminiCLIUserAgent(this._currentModel || "unknown"),
"X-Goog-Api-Client": googApiClientHeader(),
...(stream && { Accept: "text/event-stream" }),
};
buildHeaders(credentials, stream = true, clientHeaders?: Record<string, string> | null, model?: string) {
void clientHeaders;
const raw = getGeminiCliHeaders(
normalizeGeminiModel(model || "unknown"),
credentials.accessToken,
stream ? "*/*" : "application/json"
);
return scrubProxyAndFingerprintHeaders(raw);
}
// Track current model for dynamic UA. BaseExecutor calls buildUrl before buildHeaders.
private _currentModel = "unknown";
async onboardManagedProject(
accessToken: string,
tierId = DEFAULT_ONBOARD_TIER,
options: OnboardOptions = {}
options: OnboardOptions = {},
model = "unknown"
): Promise<string | null> {
const currentModel = normalizeGeminiModel(model);
const attempts =
Number.isInteger(options.attempts) && options.attempts! > 0
? Number(options.attempts)
@@ -155,11 +166,7 @@ export class GeminiCLIExecutor extends BaseExecutor {
try {
response = await fetch(ONBOARD_USER_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
"User-Agent": "GeminiCLI/1.0.0",
},
headers: getGeminiCliHeaders(currentModel, accessToken, "application/json"),
body: JSON.stringify(requestBody),
signal: controller.signal,
});
@@ -201,7 +208,7 @@ export class GeminiCLIExecutor extends BaseExecutor {
* Native Gemini CLI refreshes this every 30 seconds — OmniRoute stores it once
* at OAuth connection time, so it goes stale. This method keeps it fresh.
*/
async refreshProject(accessToken: string): Promise<string | null> {
async refreshProject(accessToken: string, model = "unknown"): Promise<string | null> {
// Check cache
const cached = projectCache.get(accessToken);
if (cached && cached.expiresAt > Date.now()) {
@@ -212,7 +219,7 @@ export class GeminiCLIExecutor extends BaseExecutor {
const inflight = inflightRefresh.get(accessToken);
if (inflight) return inflight;
const promise = this._doRefresh(accessToken);
const promise = this._doRefresh(accessToken, model);
inflightRefresh.set(accessToken, promise);
try {
return await promise;
@@ -221,7 +228,8 @@ export class GeminiCLIExecutor extends BaseExecutor {
}
}
async _doRefresh(accessToken: string): Promise<string | null> {
async _doRefresh(accessToken: string, model = "unknown"): Promise<string | null> {
const currentModel = normalizeGeminiModel(model);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), LOAD_CODE_ASSIST_TIMEOUT_MS);
@@ -230,12 +238,8 @@ export class GeminiCLIExecutor extends BaseExecutor {
try {
response = await fetch(LOAD_CODE_ASSIST_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
headers: getGeminiCliHeaders(currentModel, accessToken, "application/json"),
body: JSON.stringify({
cloudaicompanionProject: DEFAULT_PROJECT_ID,
metadata: { ...LOAD_CODE_ASSIST_METADATA },
}),
signal: controller.signal,
@@ -258,7 +262,7 @@ export class GeminiCLIExecutor extends BaseExecutor {
console.warn(
"[OmniRoute] loadCodeAssist returned no project — attempting managed project onboarding"
);
projectId = await this.onboardManagedProject(accessToken, extractDefaultTierId(data));
projectId = await this.onboardManagedProject(accessToken, extractDefaultTierId(data), {}, currentModel);
}
if (!projectId) {
@@ -279,25 +283,50 @@ export class GeminiCLIExecutor extends BaseExecutor {
}
async transformRequest(model, body, stream, credentials) {
this._currentModel = normalizeGeminiModel(model);
const currentModel = normalizeGeminiModel(model);
const normalizedBody =
shouldStripCloudCodeThinking(this.provider, this._currentModel) &&
shouldStripCloudCodeThinking(this.provider, currentModel) &&
body &&
typeof body === "object"
? stripCloudCodeThinkingConfig(body)
: body;
// Refresh the project ID via loadCodeAssist (cached for 30s).
if (normalizedBody && typeof normalizedBody === "object" && normalizedBody.request) {
if (credentials.accessToken) {
const freshProject = await this.refreshProject(credentials.accessToken);
if (freshProject) {
normalizedBody.project = freshProject;
}
}
const bodyRecord =
normalizedBody && typeof normalizedBody === "object"
? (normalizedBody as Record<string, any>)
: { request: {} };
const requestRecord =
bodyRecord.request && typeof bodyRecord.request === "object"
? cloneGeminiCliRecord(bodyRecord.request as Record<string, any>)
: {};
const envelope: Record<string, any> = {
model: currentModel,
project: bodyRecord.project || credentials.projectId || "",
user_prompt_id: bodyRecord.user_prompt_id || generateGeminiCliRequestId(),
request: {
...requestRecord,
session_id: requestRecord.session_id || generateGeminiCliSessionId(),
},
};
for (const [key, value] of Object.entries(bodyRecord)) {
if (!(key in envelope) && key !== "request") {
envelope[key] = value;
}
}
// Refresh the project ID via loadCodeAssist (cached for 30s).
if (credentials.accessToken) {
const freshProject = await this.refreshProject(credentials.accessToken, currentModel);
if (freshProject) {
envelope.project = freshProject;
}
}
if (envelope.request) {
// Obfuscate sensitive client names in user content
const contents = normalizedBody.request?.contents;
const contents = envelope.request?.contents;
if (Array.isArray(contents)) {
for (const msg of contents) {
if (Array.isArray(msg.parts)) {
@@ -310,7 +339,7 @@ export class GeminiCLIExecutor extends BaseExecutor {
}
}
}
return normalizedBody;
return envelope;
}
async refreshCredentials(credentials, log) {

File diff suppressed because it is too large Load Diff

View File

@@ -106,6 +106,53 @@ function resolveAwsPollyBaseUrl(providerSpecificData, region) {
return stripTrailingSlashes(baseUrl.replace(/\/v1\/speech\/?$/i, ""));
}
function getProviderSpecificData(credentials) {
return credentials?.providerSpecificData &&
typeof credentials.providerSpecificData === "object" &&
!Array.isArray(credentials.providerSpecificData)
? credentials.providerSpecificData
: {};
}
function normalizeXiaomiMimoSpeechUrl(baseUrl) {
const configured = getStringValue(baseUrl) || "https://api.xiaomimimo.com/v1";
const normalized = stripTrailingSlashes(configured).replace(/\/chat\/completions$/i, "");
return `${normalized}/chat/completions`;
}
function normalizeXiaomiMimoMimeType(format) {
switch (getStringValue(format)?.toLowerCase()) {
case undefined:
case null:
case "mp3":
case "audio/mp3":
case "audio/mpeg":
return "audio/mpeg";
case "wav":
case "audio/wav":
return "audio/wav";
default:
return null;
}
}
function getXiaomiMimoAudioData(data) {
const messageAudio = data?.choices?.[0]?.message?.audio;
const directAudio = data?.audio || data?.output_audio;
const firstDataItem = Array.isArray(data?.data) ? data.data[0] : null;
return (
getStringValue(messageAudio?.data) ||
getStringValue(messageAudio?.b64_json) ||
getStringValue(directAudio?.data) ||
getStringValue(directAudio?.b64_json) ||
getStringValue(firstDataItem?.b64_json) ||
getStringValue(firstDataItem?.audio) ||
getStringValue(data?.audioContent) ||
getStringValue(data?.audio_content)
);
}
function normalizeAwsPollyEngine(modelId) {
const engine = getStringValue(modelId) || "standard";
return ["standard", "neural", "long-form", "generative"].includes(engine) ? engine : "standard";
@@ -467,6 +514,59 @@ async function handleAwsPollySpeech(providerConfig, body, modelId, token, creden
return audioStreamResponse(res, outputFormat === "pcm" ? "audio/pcm" : "audio/mpeg");
}
/**
* Xiaomi MiMo TTS uses chat/completions with an audio config instead of OpenAI's /audio/speech
* request body.
*/
async function handleXiaomiMimoSpeech(providerConfig, body, modelId, token, credentials) {
const providerSpecificData = getProviderSpecificData(credentials);
const url = normalizeXiaomiMimoSpeechUrl(providerSpecificData.baseUrl || providerConfig.baseUrl);
const audioMimeType = normalizeXiaomiMimoMimeType(body.response_format);
if (!audioMimeType) {
return errorResponse(400, "Xiaomi MiMo TTS supports response_format mp3 or wav only");
}
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...buildAuthHeaders(providerConfig, token),
},
body: JSON.stringify({
model: modelId,
messages: [{ role: "assistant", content: body.input }],
audio: {
format: audioMimeType,
voice: body.voice || getStringValue(providerSpecificData.defaultVoice) || "mimo_default",
},
}),
});
if (!res.ok) {
return upstreamErrorResponse(res, await res.text());
}
const contentType = res.headers.get("content-type") || "";
if (contentType.startsWith("audio/")) {
return audioStreamResponse(res, audioMimeType);
}
const data = await res.json();
const audioBase64 = getXiaomiMimoAudioData(data);
if (!audioBase64) {
return errorResponse(502, "Xiaomi MiMo TTS response did not contain audio data");
}
const audioBuffer = Uint8Array.from(atob(audioBase64), (c) => c.charCodeAt(0));
return new Response(audioBuffer, {
status: 200,
headers: {
...CORS_HEADERS,
"Content-Type": audioMimeType,
},
});
}
/**
* Handle Coqui TTS (local, no auth)
* POST {baseUrl} with { text, speaker_id } → WAV audio
@@ -556,7 +656,7 @@ export async function handleAudioSpeech({
if (!providerConfig) {
return errorResponse(
400,
`No speech provider found for model "${body.model}". Use format provider/model. Available: openai, hyperbolic, deepgram, nvidia, elevenlabs, huggingface, inworld, cartesia, playht, aws-polly, coqui, tortoise, qwen`
`No speech provider found for model "${body.model}". Use format provider/model. Available: openai, hyperbolic, deepgram, nvidia, elevenlabs, huggingface, inworld, cartesia, playht, aws-polly, xiaomi-mimo, coqui, tortoise, qwen`
);
}
@@ -605,6 +705,10 @@ export async function handleAudioSpeech({
return handleAwsPollySpeech(providerConfig, body, modelId, token, credentials);
}
if (providerConfig.format === "xiaomi-mimo-tts") {
return handleXiaomiMimoSpeech(providerConfig, body, modelId, token, credentials);
}
if (providerConfig.format === "coqui") {
return handleCoquiSpeech(providerConfig, body);
}

View File

@@ -136,6 +136,7 @@ import {
isFallbackDecision,
EMERGENCY_FALLBACK_CONFIG,
} from "../services/emergencyFallback.ts";
import type { CompressionConfig } from "../services/compression/types.ts";
import { prepareWebSearchFallbackBody } from "../services/webSearchFallback.ts";
import {
resolveExplicitStreamAlias,
@@ -1561,15 +1562,37 @@ export async function handleChatCore({
body?.messages || body?.input || body?.contents || body?.request?.contents || [];
if (body && Array.isArray(allMessages) && allMessages.length > 0) {
let estimatedTokens = estimateTokens(JSON.stringify(allMessages));
let promptCompressionEnabled = false;
let compressionSettings: CompressionConfig | null = null;
try {
const { getCompressionSettings } = await import("../../src/lib/db/compression.ts");
compressionSettings = await getCompressionSettings();
promptCompressionEnabled = compressionSettings.enabled;
} catch (err) {
log?.warn?.(
"COMPRESSION",
"Compression settings lookup skipped: " + (err instanceof Error ? err.message : String(err))
);
}
// --- Modular Compression Pipeline (Phase 1 Lite + Phase 2 Standard/Caveman + Phase 3 Aggressive) ---
// Runs BEFORE the existing reactive compressContext() to proactively reduce tokens.
try {
const { getCompressionSettings } = await import("../../src/lib/db/compression.ts");
const { selectCompressionStrategy, applyCompression } =
await import("../services/compression/strategySelector.ts");
const { trackCompressionStats } = await import("../services/compression/stats.ts");
let config = await getCompressionSettings();
let config: CompressionConfig = compressionSettings ?? {
enabled: false,
defaultMode: "off",
autoTriggerTokens: 0,
cacheMinutes: 5,
preserveSystemPrompt: true,
comboOverrides: {},
};
if (!promptCompressionEnabled || !compressionSettings) {
log?.debug?.("COMPRESSION", "Prompt compression disabled or unavailable");
}
let compressionComboKey = comboName ?? null;
if (isCombo && comboName) {
try {
@@ -1704,6 +1727,12 @@ export async function handleChatCore({
}
// --- End Modular Compression Pipeline ---
if (!promptCompressionEnabled) {
log?.debug?.(
"CONTEXT",
"Skipping proactive context compression: Prompt Compression disabled"
);
}
let contextLimit = getTokenLimit(provider, effectiveModel);
if (isCombo && comboName) {
@@ -1748,7 +1777,7 @@ export async function handleChatCore({
`Checking compression: ${estimatedTokens} tokens vs ${threshold} threshold (${contextLimit} limit, ${reservedTokens} reserved)`
);
if (estimatedTokens > threshold) {
if (promptCompressionEnabled && estimatedTokens > threshold) {
log?.info?.(
"CONTEXT",
`Proactive compression triggered: ${estimatedTokens} tokens > ${threshold} threshold (${contextLimit} limit)`
@@ -2190,6 +2219,11 @@ export async function handleChatCore({
}
}
// OpenAI's `store` parameter is not supported by most compatible providers and breaks them
if (provider !== "openai" && "store" in translatedBody) {
delete translatedBody.store;
}
// Provider-specific max_tokens caps (#711)
// Some providers reject requests when max_tokens exceeds their API limit.
// Cap before sending to avoid upstream HTTP 400 errors.

View File

@@ -123,10 +123,17 @@ export function translateNonStreamingResponse(
toString(itemObj.call_id) ||
toString(itemObj.id) ||
`call_${Date.now()}_${toolCalls.length}`;
let argsToEmit = itemObj.arguments;
if (argsToEmit != null && typeof argsToEmit === "object" && !Array.isArray(argsToEmit)) {
const cleaned: JsonRecord = { ...(argsToEmit as JsonRecord) };
for (const [k, v] of Object.entries(cleaned)) {
if (v === "" || (Array.isArray(v) && v.length === 0)) delete cleaned[k];
}
argsToEmit = cleaned;
}
const fnArgs =
typeof itemObj.arguments === "string"
? itemObj.arguments
: JSON.stringify(itemObj.arguments || {});
typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit || {});
const rawName = toString(itemObj.name);
// Strip Claude OAuth proxy_ prefix using toolNameMap
const resolvedName = toolNameMap?.get(rawName) ?? rawName;

View File

@@ -1072,6 +1072,115 @@ export const compressionConfigureTool: McpToolDefinition<
sourceEndpoints: ["/api/compression/configure"],
};
// ============ 1proxy Tools ============
export const oneproxyFetchInput = z.object({
protocol: z.string().optional().describe("Filter by protocol: http, https, socks4, socks5"),
countryCode: z.string().optional().describe("Filter by country code (e.g. US, DE)"),
minQuality: z.number().optional().describe("Minimum quality score (0-100)"),
limit: z.number().optional().describe("Maximum number of proxies to return"),
});
export const oneproxyFetchOutput = z.object({
items: z.array(
z.object({
id: z.string(),
host: z.string(),
port: z.number(),
type: z.string(),
countryCode: z.string().nullable(),
qualityScore: z.number().nullable(),
latencyMs: z.number().nullable(),
anonymity: z.string().nullable(),
googleAccess: z.boolean(),
status: z.string(),
})
),
total: z.number(),
});
export const oneproxyFetchTool: McpToolDefinition<
typeof oneproxyFetchInput,
typeof oneproxyFetchOutput
> = {
name: "omniroute_oneproxy_fetch",
description:
"Fetch free proxies from the 1proxy marketplace with optional filters for protocol, country, and quality. Returns validated proxies with quality scores.",
inputSchema: oneproxyFetchInput,
outputSchema: oneproxyFetchOutput,
scopes: ["read:proxies"],
auditLevel: "basic",
phase: 2,
sourceEndpoints: ["/api/settings/oneproxy"],
};
export const oneproxyRotateInput = z.object({
strategy: z
.enum(["random", "quality", "sequential"])
.optional()
.describe("Rotation strategy: quality (best first), random, or sequential"),
});
export const oneproxyRotateOutput = z.object({
id: z.string(),
host: z.string(),
port: z.number(),
type: z.string(),
countryCode: z.string().nullable(),
qualityScore: z.number().nullable(),
latencyMs: z.number().nullable(),
});
export const oneproxyRotateTool: McpToolDefinition<
typeof oneproxyRotateInput,
typeof oneproxyRotateOutput
> = {
name: "omniroute_oneproxy_rotate",
description:
"Get the next available free proxy from the 1proxy pool using the specified rotation strategy.",
inputSchema: oneproxyRotateInput,
outputSchema: oneproxyRotateOutput,
scopes: ["read:proxies"],
auditLevel: "basic",
phase: 2,
sourceEndpoints: ["/api/settings/oneproxy/rotate"],
};
export const oneproxyStatsInput = z.object({}).describe("No parameters required");
export const oneproxyStatsOutput = z.object({
stats: z.object({
total: z.number(),
active: z.number(),
avgQuality: z.number().nullable(),
lastValidated: z.string().nullable(),
byProtocol: z.array(z.object({ protocol: z.string(), count: z.number() })),
byCountry: z.array(z.object({ countryCode: z.string(), count: z.number() })),
}),
status: z.object({
lastSyncSuccess: z.boolean(),
lastSyncError: z.string().nullable(),
lastSyncAt: z.string().nullable(),
lastSyncCount: z.number(),
consecutiveFailures: z.number(),
}),
});
export const oneproxyStatsTool: McpToolDefinition<
typeof oneproxyStatsInput,
typeof oneproxyStatsOutput
> = {
name: "omniroute_oneproxy_stats",
description:
"Returns 1proxy sync status and statistics: total proxies, average quality, sync history, and distribution by protocol and country.",
inputSchema: oneproxyStatsInput,
outputSchema: oneproxyStatsOutput,
scopes: ["read:proxies"],
auditLevel: "basic",
phase: 2,
sourceEndpoints: ["/api/settings/oneproxy"],
};
// ============ Tool Registry ============
/** All MCP tool definitions, ordered by phase then name */
@@ -1100,6 +1209,9 @@ export const MCP_TOOLS = [
cacheFlushTool,
compressionStatusTool,
compressionConfigureTool,
oneproxyFetchTool,
oneproxyRotateTool,
oneproxyStatsTool,
] as const;
/** Essential tools only (Phase 1) */

View File

@@ -42,6 +42,9 @@ import {
syncPricingInput,
cacheStatsInput,
cacheFlushInput,
oneproxyFetchInput,
oneproxyRotateInput,
oneproxyStatsInput,
} from "./schemas/tools.ts";
import { startMcpHeartbeat } from "./runtimeHeartbeat.ts";
@@ -66,6 +69,9 @@ import {
handleSyncPricing,
handleCacheStats,
handleCacheFlush,
handleOneproxyFetch,
handleOneproxyRotate,
handleOneproxyStats,
} from "./tools/advancedTools.ts";
import { memoryTools } from "./tools/memoryTools.ts";
import { skillTools } from "./tools/skillTools.ts";
@@ -832,6 +838,44 @@ export function createMcpServer(): McpServer {
)
);
// ── 1proxy Tools ──────────────────────────────
server.registerTool(
"omniroute_oneproxy_fetch",
{
description:
"Fetch free proxies from the 1proxy marketplace with optional filters for protocol, country, and quality. Returns validated proxies with quality scores.",
inputSchema: oneproxyFetchInput,
},
withScopeEnforcement("omniroute_oneproxy_fetch", (args) =>
handleOneproxyFetch(oneproxyFetchInput.parse(args))
)
);
server.registerTool(
"omniroute_oneproxy_rotate",
{
description:
"Get the next available free proxy from the 1proxy pool using the specified rotation strategy.",
inputSchema: oneproxyRotateInput,
},
withScopeEnforcement("omniroute_oneproxy_rotate", (args) =>
handleOneproxyRotate(oneproxyRotateInput.parse(args))
)
);
server.registerTool(
"omniroute_oneproxy_stats",
{
description:
"Returns 1proxy sync status and statistics: total proxies, average quality, sync history, and distribution by protocol and country.",
inputSchema: oneproxyStatsInput,
},
withScopeEnforcement("omniroute_oneproxy_stats", (args) =>
handleOneproxyStats(oneproxyStatsInput.parse(args))
)
);
// ── Memory Tools ──────────────────────────────
Object.values(memoryTools).forEach((toolDef) => {
server.registerTool(

View File

@@ -1000,3 +1000,119 @@ export async function handleCacheFlush(args: { signature?: string; model?: strin
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
}
// ============ 1proxy Tools ============
export async function handleOneproxyFetch(
args: { protocol?: string; countryCode?: string; minQuality?: number; limit?: number } = {}
) {
const start = Date.now();
try {
const params = new URLSearchParams();
if (args.protocol) params.set("protocol", args.protocol);
if (args.countryCode) params.set("countryCode", args.countryCode);
if (args.minQuality) params.set("minQuality", String(args.minQuality));
if (args.limit) params.set("limit", String(args.limit));
const query = params.toString();
const path = query ? `/api/settings/oneproxy?${query}` : "/api/settings/oneproxy";
const raw = toRecord(await apiFetch(path));
const items = toArrayOfRecords(raw.items).map((r) => ({
id: toString(r.id, ""),
host: toString(r.host, ""),
port: toNumber(r.port, 0),
type: toString(r.type, "http"),
countryCode: typeof r.country_code === "string" ? r.country_code : null,
qualityScore: r.quality_score != null ? toNumber(r.quality_score) : null,
latencyMs: r.latency_ms != null ? toNumber(r.latency_ms) : null,
anonymity: typeof r.anonymity === "string" ? r.anonymity : null,
googleAccess: r.google_access === 1 || r.google_access === true,
status: toString(r.status, "active"),
}));
const result = { items, total: toNumber(raw.total, items.length) };
await logToolCall("omniroute_oneproxy_fetch", args, result, Date.now() - start, true);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await logToolCall("omniroute_oneproxy_fetch", args, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
}
export async function handleOneproxyRotate(
args: { strategy?: "random" | "quality" | "sequential" } = {}
) {
const start = Date.now();
try {
const body: Record<string, unknown> = {};
if (args.strategy) body.strategy = args.strategy;
const raw = toRecord(
await apiFetch("/api/settings/oneproxy/rotate", {
method: "POST",
body: JSON.stringify(body),
})
);
const result = {
id: toString(raw.id, ""),
host: toString(raw.host, ""),
port: toNumber(raw.port, 0),
type: toString(raw.type, "http"),
countryCode: typeof raw.country_code === "string" ? raw.country_code : null,
qualityScore: raw.quality_score != null ? toNumber(raw.quality_score) : null,
latencyMs: raw.latency_ms != null ? toNumber(raw.latency_ms) : null,
};
await logToolCall("omniroute_oneproxy_rotate", args, result, Date.now() - start, true);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await logToolCall("omniroute_oneproxy_rotate", args, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
}
export async function handleOneproxyStats(args: Record<string, never> = {}) {
const start = Date.now();
try {
const raw = toRecord(await apiFetch("/api/settings/oneproxy?action=stats"));
const statsRaw = toRecord(raw.stats);
const statusRaw = toRecord(raw.status);
const stats = {
total: toNumber(statsRaw.total, 0),
active: toNumber(statsRaw.active, 0),
avgQuality: statsRaw.avg_quality != null ? toNumber(statsRaw.avg_quality) : null,
lastValidated: typeof statsRaw.last_validated === "string" ? statsRaw.last_validated : null,
byProtocol: toArrayOfRecords(statsRaw.by_protocol || statsRaw.byProtocol).map((r) => ({
protocol: toString(r.protocol, ""),
count: toNumber(r.count, 0),
})),
byCountry: toArrayOfRecords(statsRaw.by_country || statsRaw.byCountry).map((r) => ({
countryCode: toString(r.countryCode || r.country_code, ""),
count: toNumber(r.count, 0),
})),
};
const status = {
lastSyncSuccess: toBoolean(statusRaw.last_sync_success, false),
lastSyncError:
typeof statusRaw.last_sync_error === "string" ? statusRaw.last_sync_error : null,
lastSyncAt: typeof statusRaw.last_sync_at === "string" ? statusRaw.last_sync_at : null,
lastSyncCount: toNumber(statusRaw.last_sync_count, 0),
consecutiveFailures: toNumber(statusRaw.consecutive_failures, 0),
};
const result = { stats, status };
await logToolCall("omniroute_oneproxy_stats", args, result, Date.now() - start, true);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await logToolCall("omniroute_oneproxy_stats", args, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@omniroute/open-sse",
"version": "3.7.7",
"version": "3.7.8",
"description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration",
"type": "module",
"main": "index.js",

View File

@@ -56,7 +56,16 @@ type ModelFailureState = {
// Provider-level failure tracking for circuit breaker behavior
// Error codes that count toward provider-level failure threshold
const PROVIDER_FAILURE_ERROR_CODES = new Set([408, 429, 500, 502, 503, 504]);
// 429 (rate limit) is intentionally excluded: rate limits are connection-scoped
// and handled via Connection Cooldown, not provider-wide circuit breaker.
// Counting 429 toward provider failure causes cascading provider trips at scale
// when many connections hit rate limits simultaneously (Issue #1846).
const PROVIDER_FAILURE_ERROR_CODES = new Set([408, 500, 502, 503, 504]);
// Per-connection failure deduplication: prevents rapid-fire failures from the
// same connection from counting multiple times toward the provider breaker.
const CONNECTION_FAILURE_DEDUP_MS = 5000;
const lastConnectionFailure = new Map<string, number>();
// T06 (sub2api PR #1037): Signals that indicate permanent account deactivation.
// When a 401 body contains these strings, the account is permanently dead
@@ -541,14 +550,29 @@ export function getProviderCooldownRemainingMs(provider: string | null | undefin
*/
export function recordProviderFailure(
provider: string | null | undefined,
log?: { warn?: (...args: unknown[]) => void }
log?: { warn?: (...args: unknown[]) => void },
connectionId?: string | null
): void {
if (!provider) return;
// Deduplicate rapid-fire failures from the same connection
if (connectionId) {
const dedupKey = `${provider}:${connectionId}`;
const now = Date.now();
const lastFailure = lastConnectionFailure.get(dedupKey);
if (lastFailure && now - lastFailure < CONNECTION_FAILURE_DEDUP_MS) {
return;
}
// Prevent memory leak by clearing map if it grows too large
if (lastConnectionFailure.size > 10000) {
lastConnectionFailure.clear();
}
lastConnectionFailure.set(dedupKey, now);
}
const breaker = getProviderBreaker(provider);
if (!breaker) return;
// Skip if already in cooldown to prevent timer reset (indefinite lockout bug)
if (!breaker.canExecute()) return;
breaker._onFailure();

View File

@@ -29,6 +29,7 @@ export interface AcquireAccountSemaphoreOptions {
maxConcurrency?: number | null;
timeoutMs?: number;
signal?: AbortSignal | null;
maxQueueSize?: number;
}
export interface AccountSemaphoreStatsEntry {
@@ -39,6 +40,7 @@ export interface AccountSemaphoreStatsEntry {
}
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_QUEUE_SIZE = 20;
const gates = new Map<string, AccountGate>();
@@ -187,6 +189,7 @@ export function acquire(
maxConcurrency = null,
timeoutMs = DEFAULT_TIMEOUT_MS,
signal = null,
maxQueueSize = DEFAULT_MAX_QUEUE_SIZE,
}: AcquireAccountSemaphoreOptions = {}
): Promise<() => void> {
if (isBypassed(maxConcurrency)) {
@@ -205,6 +208,14 @@ export function acquire(
return Promise.resolve(createReleaseFn(semaphoreKey));
}
if (gate.queue.length >= maxQueueSize) {
const err = new Error(`Semaphore queue full (${maxQueueSize}) for ${semaphoreKey}`) as Error & {
code: string;
};
err.code = "SEMAPHORE_QUEUE_FULL";
return Promise.reject(err);
}
return new Promise((resolve, reject) => {
let abortListener: (() => void) | null = null;

View File

@@ -5,10 +5,10 @@ import {
} from "./antigravityVersion.ts";
/**
* Antigravity and Gemini CLI header utilities.
* Antigravity header utilities.
*
* Generates User-Agent strings and API client headers that match
* the real Antigravity and Gemini CLI binaries.
* the real Antigravity client flows.
*
* Based on CLIProxyAPI's misc/header_utils.go.
*/
@@ -16,12 +16,10 @@ import {
type AntigravityHeaderProfile = "loadCodeAssist" | "fetchAvailableModels" | "models";
const ANTIGRAVITY_VERSION = ANTIGRAVITY_FALLBACK_VERSION;
export const GEMINI_CLI_VERSION = "0.39.1";
export const GEMINI_SDK_VERSION = "1.30.0";
export const NODE_VERSION = "v22.21.1";
export const ANTIGRAVITY_LOAD_CODE_ASSIST_USER_AGENT = "google-api-nodejs-client/10.3.0";
export const ANTIGRAVITY_LOAD_CODE_ASSIST_API_CLIENT =
"google-cloud-sdk vscode_cloudshelleditor/0.1";
export const ANTIGRAVITY_CREDIT_PROBE_API_CLIENT = "google-genai-sdk/1.30.0 gl-node/v22.21.1";
const LOAD_CODE_ASSIST_METADATA = Object.freeze({
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
@@ -38,32 +36,6 @@ function withOptionalBearerAuth(
return headers;
}
function getPlatform(): string {
const p = typeof process !== "undefined" ? process.platform : "unknown";
switch (p) {
case "win32":
return "windows";
case "darwin":
return "macos";
default:
return p; // "linux", etc.
}
}
function getArch(): string {
const a = typeof process !== "undefined" ? process.arch : "unknown";
switch (a) {
case "x64":
return "x64";
case "ia32":
return "x86";
case "arm64":
return "arm64";
default:
return a;
}
}
/**
* Antigravity User-Agent: "antigravity/VERSION darwin/arm64"
*
@@ -118,20 +90,9 @@ export function getAntigravityHeaders(
}
}
/**
* Gemini CLI User-Agent: "GeminiCLI/VERSION/MODEL (OS; ARCH)"
* Example: "GeminiCLI/1.0.0/gemini-3-flash (macos; arm64)"
*/
export function geminiCLIUserAgent(model: string): string {
return `GeminiCLI/${GEMINI_CLI_VERSION}/${model || "unknown"} (${getPlatform()}; ${getArch()})`;
}
/**
* X-Goog-Api-Client header value matching the real Gemini SDK.
* Example: "google-genai-sdk/1.30.0 gl-node/v22.21.1"
*/
export function googApiClientHeader(): string {
return `google-genai-sdk/${GEMINI_SDK_VERSION} gl-node/${NODE_VERSION}`;
/** X-Goog-Api-Client used by Antigravity's credit probe path. */
export function getAntigravityCreditProbeApiClientHeader(): string {
return ANTIGRAVITY_CREDIT_PROBE_API_CLIENT;
}
export { ANTIGRAVITY_VERSION };

View File

@@ -38,7 +38,10 @@ for (const [k, v] of Object.entries(TOOL_RENAME_MAP)) {
REVERSE_MAP[v] = k;
}
export function remapToolNamesInRequest(body: Record<string, unknown>): void {
export function remapToolNamesInRequest(body: Record<string, unknown>): boolean {
let hasLowercase = false;
let hasTitleCase = false;
// Remap tool definitions
const tools = body.tools as Array<Record<string, unknown>> | undefined;
if (Array.isArray(tools)) {
@@ -46,6 +49,9 @@ export function remapToolNamesInRequest(body: Record<string, unknown>): void {
const name = String(tool.name || "");
if (TOOL_RENAME_MAP[name]) {
tool.name = TOOL_RENAME_MAP[name];
hasLowercase = true;
} else if (REVERSE_MAP[name]) {
hasTitleCase = true;
}
}
}
@@ -59,7 +65,12 @@ export function remapToolNamesInRequest(body: Record<string, unknown>): void {
for (const block of content) {
if (block.type === "tool_use" && typeof block.name === "string") {
const mapped = TOOL_RENAME_MAP[block.name];
if (mapped) block.name = mapped;
if (mapped) {
block.name = mapped;
hasLowercase = true;
} else if (REVERSE_MAP[block.name]) {
hasTitleCase = true;
}
}
}
}
@@ -69,11 +80,24 @@ export function remapToolNamesInRequest(body: Record<string, unknown>): void {
const toolChoice = body.tool_choice as Record<string, unknown> | undefined;
if (toolChoice?.type === "tool" && typeof toolChoice.name === "string") {
const mapped = TOOL_RENAME_MAP[toolChoice.name];
if (mapped) toolChoice.name = mapped;
if (mapped) {
toolChoice.name = mapped;
hasLowercase = true;
} else if (REVERSE_MAP[toolChoice.name]) {
hasTitleCase = true;
}
}
if (hasLowercase && !hasTitleCase) {
body._claudeCodeRequiresLowercaseToolNames = true;
}
return hasLowercase && !hasTitleCase;
}
export function remapToolNamesInResponse(text: string): string {
export function remapToolNamesInResponse(text: string, forceLowercase = true): string {
if (!forceLowercase) return text;
// Replace TitleCase tool names back to lowercase in SSE chunks
for (const [titleCase, lower] of Object.entries(REVERSE_MAP)) {
// Match in "name":"ToolName" patterns

View File

@@ -0,0 +1,41 @@
export function getRuntimePlatform(): string {
return typeof process !== "undefined" && typeof process.platform === "string"
? process.platform
: "unknown";
}
export function getRuntimeArch(): string {
return typeof process !== "undefined" && typeof process.arch === "string"
? process.arch
: "unknown";
}
export function getRuntimeNodeVersion(): string {
return typeof process !== "undefined" && process.versions?.node
? process.versions.node
: "unknown";
}
export function normalizeCloudCodePlatform(platform = getRuntimePlatform()): string {
switch (platform) {
case "win32":
return "windows";
case "darwin":
return "macos";
default:
return platform || "unknown";
}
}
export function normalizeCloudCodeArch(arch = getRuntimeArch()): string {
switch (arch) {
case "ia32":
return "x86";
default:
return arch || "unknown";
}
}
export function getCloudCodeNodeApiClientHeader(nodeVersion = getRuntimeNodeVersion()): string {
return `gl-node/${nodeVersion.replace(/^v/, "")}`;
}

View File

@@ -1678,7 +1678,7 @@ export async function handleComboChat({
// Trigger shared provider circuit breaker for 5xx errors and connection failures
if (isProviderFailureCode(result.status)) {
recordProviderFailure(provider, log);
recordProviderFailure(provider, log, target.connectionId);
}
// Check if this is a transient error worth retrying on same model
@@ -1841,8 +1841,11 @@ async function handleRoundRobinCombo({
timeoutMs: queueTimeout,
});
} catch (err) {
if (err.code === "SEMAPHORE_TIMEOUT") {
log.warn("COMBO-RR", `Semaphore timeout for ${modelStr}, trying next model`);
if (err.code === "SEMAPHORE_TIMEOUT" || err.code === "SEMAPHORE_QUEUE_FULL") {
log.warn(
"COMBO-RR",
`Semaphore ${err.code === "SEMAPHORE_QUEUE_FULL" ? "queue full" : "timeout"} for ${modelStr}, trying next model`
);
if (offset > 0) fallbackCount++;
continue;
}

View File

@@ -17,6 +17,51 @@ interface LiteCompressionOptions {
supportsVision?: boolean | null;
}
function trimTrailingHorizontalWhitespace(line: string): string {
let end = line.length;
while (end > 0) {
const code = line.charCodeAt(end - 1);
if (code !== 32 && code !== 9) break;
end--;
}
return end === line.length ? line : line.slice(0, end);
}
function collapseNewlineRuns(content: string): string {
let normalized = "";
let newlineRun = 0;
for (const char of content) {
if (char === "\n") {
newlineRun++;
if (newlineRun <= 2) {
normalized += char;
}
continue;
}
newlineRun = 0;
normalized += char;
}
return normalized;
}
function normalizeMessageWhitespace(content: string): string {
return collapseNewlineRuns(content).split("\n").map(trimTrailingHorizontalWhitespace).join("\n");
}
function modelSupportsVision(model: string): boolean {
const normalized = model.toLowerCase();
return (
normalized.includes("vision") ||
normalized.includes("gpt-4") ||
normalized.includes("4o") ||
normalized.includes("claude-3") ||
normalized.includes("gemini")
);
}
export function collapseWhitespace(body: ChatBody): {
body: ChatBody;
applied: boolean;
@@ -25,7 +70,7 @@ export function collapseWhitespace(body: ChatBody): {
let applied = false;
const messages = body.messages.map((msg) => {
if (typeof msg.content !== "string") return msg;
const normalized = msg.content.replace(/\n{3,}/g, "\n\n").replace(/[ \t]+$/gm, "");
const normalized = normalizeMessageWhitespace(msg.content);
if (normalized !== msg.content) applied = true;
return { ...msg, content: normalized };
});
@@ -101,7 +146,11 @@ export function replaceImageUrls(
): { body: ChatBody; applied: boolean } {
if (!body.messages) return { body, applied: false };
const supportsVision =
typeof options === "object" && options !== null ? options.supportsVision : undefined;
typeof options === "object" && options !== null
? options.supportsVision
: typeof options === "string"
? modelSupportsVision(options)
: undefined;
if (supportsVision !== false) return { body, applied: false };
let applied = false;

View File

@@ -15,8 +15,8 @@ export function extractPreservedBlocks(text: string): { text: string; blocks: Pr
return placeholder;
};
// Extract fenced code blocks (```lang\n...\n```)
result = result.replace(/```[a-z]*\n[\s\S]*?\n```/g, (match) => addBlock(match));
// Extract fenced code blocks with a linear scan to avoid ReDoS-prone patterns.
result = extractFencedCodeBlocks(result, addBlock);
// Extract inline code (`...`)
result = result.replace(/`[^`\n]+`/g, (match) => addBlock(match));
@@ -40,6 +40,38 @@ export function extractPreservedBlocks(text: string): { text: string; blocks: Pr
return { text: result, blocks };
}
function extractFencedCodeBlocks(text: string, addBlock: (content: string) => string): string {
let output = "";
let cursor = 0;
while (cursor < text.length) {
const start = text.indexOf("```", cursor);
if (start === -1) {
output += text.slice(cursor);
break;
}
const openingLineEnd = text.indexOf("\n", start + 3);
if (openingLineEnd === -1) {
output += text.slice(cursor);
break;
}
const closeStart = text.indexOf("\n```", openingLineEnd + 1);
if (closeStart === -1) {
output += text.slice(cursor);
break;
}
const closeEnd = closeStart + 4;
output += text.slice(cursor, start);
output += addBlock(text.slice(start, closeEnd));
cursor = closeEnd;
}
return output;
}
export function restorePreservedBlocks(text: string, blocks: PreservedBlock[]): string {
let result = text;
for (const block of blocks) {

View File

@@ -3,9 +3,86 @@ import type { Summarizer, SummarizerOpts } from "./types.ts";
const COMPRESSED_MARKER_RE = /^\[COMPRESSED:/;
const INTENT_TRIGGERS =
/^(?:request|fix|implement|add|remove|update|refactor|create|delete|change|build)\s*:/i;
const FILE_PATH_RE =
/[\w./-]+\.(?:ts|tsx|js|jsx|py|md|json|sql|css|html|yaml|yml|sh|rb|go|rs|java|c|cpp|h|hpp)/g;
const ERROR_RE = /(?:Error|error|ERROR):\s*\S+|error\s+TS\d+|Exception:\s*\S+/g;
const FILE_EXTENSIONS = new Set([
"ts",
"tsx",
"js",
"jsx",
"py",
"md",
"json",
"sql",
"css",
"html",
"yaml",
"yml",
"sh",
"rb",
"go",
"rs",
"java",
"c",
"cpp",
"h",
"hpp",
]);
function stripTokenPunctuation(token: string): string {
let start = 0;
let end = token.length;
const leading = new Set(["'", '"', "`", "(", "[", "{"]);
const trailing = new Set(["'", '"', "`", ")", "]", "}", ",", ";", ":"]);
while (start < end && leading.has(token[start])) start++;
while (end > start && trailing.has(token[end - 1])) end--;
return token.slice(start, end);
}
function getKnownFilePathToken(token: string): string | null {
const clean = stripTokenPunctuation(token);
const lastDot = clean.lastIndexOf(".");
if (lastDot <= 0 || lastDot === clean.length - 1) return null;
const extension = clean.slice(lastDot + 1).toLowerCase();
if (!FILE_EXTENSIONS.has(extension)) return null;
if (!clean.includes("/") && !clean.includes(".")) return null;
return clean;
}
function extractFilePathTokens(text: string): string[] {
const paths: string[] = [];
for (const token of text.split(/\s+/)) {
const filePath = getKnownFilePathToken(token);
if (filePath) paths.push(filePath);
}
return paths;
}
function extractErrorSnippets(text: string): string[] {
const snippets: string[] = [];
for (const segment of text.split(/[.\n]/)) {
const trimmed = segment.trim();
if (!trimmed) continue;
const lower = trimmed.toLowerCase();
if (
trimmed.includes("TypeError:") ||
trimmed.includes("ReferenceError:") ||
trimmed.includes("SyntaxError:") ||
trimmed.includes("RangeError:") ||
trimmed.includes("URIError:") ||
trimmed.includes("EvalError:") ||
trimmed.includes("Error:") ||
trimmed.includes("Exception:") ||
lower.includes("error ts")
) {
snippets.push(trimmed);
}
}
return snippets;
}
function extractIntents(messages: Array<{ role: string; content?: string | unknown[] }>): string[] {
const intents: string[] = [];
@@ -30,9 +107,9 @@ function extractFilePaths(
for (const msg of messages) {
const text = extractText(msg.content);
if (!text) continue;
const matches = text.match(FILE_PATH_RE);
if (matches) {
for (const m of matches) paths.add(m);
const matches = extractFilePathTokens(text);
for (const m of matches) {
paths.add(m);
}
}
return [...paths].slice(0, 20);
@@ -43,10 +120,7 @@ function extractErrors(messages: Array<{ role: string; content?: string | unknow
for (const msg of messages) {
const text = extractText(msg.content);
if (!text) continue;
const matches = text.match(ERROR_RE);
if (matches) {
for (const m of matches) errors.push(m.slice(0, 150));
}
for (const match of extractErrorSnippets(text)) errors.push(match.slice(0, 150));
}
return errors.slice(0, 10);
}
@@ -64,14 +138,46 @@ function extractLastDecision(
}
function trimCodeFences(text: string): string {
const fenceRe = /```[a-z]*\n([\s\S]*?)\n```/g;
return text.replace(fenceRe, (_match, code: string) => {
let output = "";
let cursor = 0;
while (cursor < text.length) {
const start = text.indexOf("```", cursor);
if (start === -1) {
output += text.slice(cursor);
break;
}
const openingLineEnd = text.indexOf("\n", start + 3);
if (openingLineEnd === -1) {
output += text.slice(cursor);
break;
}
const closeStart = text.indexOf("\n```", openingLineEnd + 1);
if (closeStart === -1) {
output += text.slice(cursor);
break;
}
const closeEnd = closeStart + 4;
const opening = text.slice(start, openingLineEnd);
const code = text.slice(openingLineEnd + 1, closeStart);
const lines = code.split("\n");
if (lines.length <= 4) return _match;
const head = lines.slice(0, 3).join("\n");
const tail = lines[lines.length - 1];
return "```" + head + "\n…\n" + tail + "\n```";
});
output += text.slice(cursor, start);
if (lines.length <= 4) {
output += text.slice(start, closeEnd);
} else {
const head = lines.slice(0, 3).join("\n");
const tail = lines[lines.length - 1];
output += `${opening}\n${head}\n…\n${tail}\n\`\`\``;
}
cursor = closeEnd;
}
return output;
}
function extractText(content?: string | unknown[]): string {

View File

@@ -6,18 +6,64 @@ export interface CompressionResult {
saved: number;
}
const CODE_INDICATORS =
/(?:^|\n)\s*(?:import\s|export\s|function\s|class\s|const\s|let\s|var\s|return\s|if\s*\(|for\s*\(|while\s*\()/;
const GREP_LINE_RE = /^[\w./-]+:\d+:/m;
const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]/g;
const SHELL_PROMPT_RE = /\$\s/;
const JSON_PREFIX_RE = /^\s*[{[]/;
const ERROR_RE = /(?:Error|Exception|Traceback)[:\s]/i;
function isCodeLikeLine(rawLine: string): boolean {
const line = rawLine.trimStart();
return (
line.startsWith("import ") ||
line.startsWith("export ") ||
line.startsWith("function ") ||
line.startsWith("class ") ||
line.startsWith("const ") ||
line.startsWith("let ") ||
line.startsWith("var ") ||
line.startsWith("return ") ||
line.startsWith("if(") ||
line.startsWith("if (") ||
line.startsWith("for(") ||
line.startsWith("for (") ||
line.startsWith("while(") ||
line.startsWith("while (")
);
}
function parseGrepLinePath(line: string): string | null {
const firstColon = line.indexOf(":");
if (firstColon <= 0) return null;
const secondColon = line.indexOf(":", firstColon + 1);
if (secondColon === -1) return null;
const lineNumber = line.slice(firstColon + 1, secondColon);
if (!lineNumber || ![...lineNumber].every((char) => char >= "0" && char <= "9")) {
return null;
}
const filePath = line.slice(0, firstColon);
if (!filePath || /\s/.test(filePath)) return null;
return filePath;
}
function hasErrorLikeOutput(content: string): boolean {
const lower = content.toLowerCase();
return (
lower.includes("error:") ||
lower.includes("error ") ||
lower.includes("[error]") ||
lower.includes("exception:") ||
lower.includes("exception ") ||
lower.includes("[exception]") ||
lower.includes("traceback")
);
}
function compressFileContent(content: string): string | null {
const lines = content.split("\n");
if (lines.length < 3) return null;
if (!CODE_INDICATORS.test(content)) return null;
if (!lines.some(isCodeLikeLine)) return null;
const keep = 20;
const tail = 5;
if (lines.length <= keep + tail) return content;
@@ -29,12 +75,12 @@ function compressFileContent(content: string): string | null {
function compressGrepSearch(content: string): string | null {
const lines = content.split("\n");
const grepLines = lines.filter((l) => GREP_LINE_RE.test(l));
const grepLines = lines.filter((line) => parseGrepLinePath(line) !== null);
if (grepLines.length === 0) return null;
const paths = new Set<string>();
for (const line of grepLines) {
const match = line.match(/^([\w./-]+):\d+:/);
if (match) paths.add(match[1]);
const filePath = parseGrepLinePath(line);
if (filePath) paths.add(filePath);
}
const top30 = grepLines.slice(0, 30);
const remaining = grepLines.length - top30.length;
@@ -99,7 +145,7 @@ function compressJson(content: string): string | null {
}
function compressErrorMessage(content: string): string | null {
if (!ERROR_RE.test(content)) return null;
if (!hasErrorLikeOutput(content)) return null;
const lines = content.split("\n");
const errorLine = lines[0] || "";
const stackLines = lines.slice(1);

View File

@@ -0,0 +1,31 @@
import {
getCloudCodeNodeApiClientHeader,
normalizeCloudCodeArch,
normalizeCloudCodePlatform,
} from "./cloudCodeHeaders.ts";
export const GEMINI_CLI_VERSION = "0.40.1";
export const GEMINI_CLI_GOOGLE_API_NODE_CLIENT_VERSION = "9.15.1";
export function geminiCliUserAgent(model: string): string {
const normalizedModel = model || "unknown";
return `GeminiCLI/${GEMINI_CLI_VERSION}/${normalizedModel} (${normalizeCloudCodePlatform()}; ${normalizeCloudCodeArch()}; terminal) google-api-nodejs-client/${GEMINI_CLI_GOOGLE_API_NODE_CLIENT_VERSION}`;
}
export function geminiCliApiClientHeader(): string {
return getCloudCodeNodeApiClientHeader();
}
export function getGeminiCliHeaders(
model: string,
accessToken: string,
accept: "application/json" | "*/*"
): Record<string, string> {
return {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
"User-Agent": geminiCliUserAgent(model),
"X-Goog-Api-Client": geminiCliApiClientHeader(),
Accept: accept,
};
}

View File

@@ -332,6 +332,11 @@ export function buildProviderHeaders(provider, credentials, stream = true, body
if (token) {
headers["x-api-key"] = token;
}
} else if (authHeader === "key") {
const token = credentials.apiKey || credentials.accessToken;
if (token) {
headers["Authorization"] = `Key ${token}`;
}
} else if (authHeader === "x-goog-api-key") {
if (credentials.apiKey) {
headers["x-goog-api-key"] = credentials.apiKey;

View File

@@ -26,7 +26,7 @@ export type QuotaFetcher = (
connection?: Record<string, unknown>
) => Promise<QuotaInfo | null>;
const EXHAUSTION_THRESHOLD = 0.95;
const EXHAUSTION_THRESHOLD = 0.98;
const WARN_THRESHOLD = 0.8;
const quotaFetcherRegistry = new Map<string, QuotaFetcher>();

View File

@@ -28,6 +28,38 @@ function toRecord(value: unknown): JsonRecord | null {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null;
}
function isResponsesMessageItem(record: JsonRecord): boolean {
return record.type === "message" || (!record.type && typeof record.role === "string");
}
function isInternalAssistantMessage(record: JsonRecord): boolean {
if (!isResponsesMessageItem(record)) return false;
if (record.role !== "assistant") return false;
const phase = typeof record.phase === "string" ? record.phase.trim().toLowerCase() : "";
if (!phase) return false;
// OpenCode can send assistant-side commentary/analysis frames in Responses
// shape. Those frames are local runtime state, not durable conversation turns
// for Codex replay. Re-injecting them makes the model continue hidden notes.
return phase !== "final";
}
function sanitizeRememberedConversationItems(items: readonly unknown[]): unknown[] {
const sanitized: unknown[] = [];
for (const item of items) {
const record = toRecord(item);
if (record && isInternalAssistantMessage(record)) {
continue;
}
sanitized.push(structuredClone(item));
}
return sanitized;
}
function cleanupRememberedResponseToolCalls(now: number = Date.now()) {
for (const [responseId, entry] of rememberedResponseToolCalls.entries()) {
if (entry.expiresAt <= now) {
@@ -122,7 +154,7 @@ export function rememberResponseFunctionCalls(
rememberedResponseToolCalls.set(normalizedResponseId, {
functionCalls,
conversationItems: existingEntry?.conversationItems?.map((item) => structuredClone(item)) || [],
conversationItems: sanitizeRememberedConversationItems(existingEntry?.conversationItems || []),
updatedAt: now,
expiresAt: now + RESPONSE_TOOL_CALL_TTL_MS,
});
@@ -140,7 +172,10 @@ export function rememberResponseConversationState(
const normalizedRequestInput = Array.isArray(requestInput) ? requestInput : [];
const normalizedOutputItems = Array.isArray(outputItems) ? outputItems : [];
const conversationItems = [...normalizedRequestInput, ...normalizedOutputItems];
const conversationItems = sanitizeRememberedConversationItems([
...normalizedRequestInput,
...normalizedOutputItems,
]);
if (conversationItems.length === 0) {
return;
}
@@ -150,7 +185,7 @@ export function rememberResponseConversationState(
const existingEntry = rememberedResponseToolCalls.get(normalizedResponseId);
rememberedResponseToolCalls.set(normalizedResponseId, {
functionCalls: existingEntry?.functionCalls?.map((functionCall) => ({ ...functionCall })) || [],
conversationItems: conversationItems.map((item) => structuredClone(item)),
conversationItems,
updatedAt: Date.now(),
expiresAt: Date.now() + RESPONSE_TOOL_CALL_TTL_MS,
});
@@ -185,7 +220,7 @@ export function getRememberedResponseConversationItems(responseId: unknown): unk
return [];
}
return entry.conversationItems.map((item) => structuredClone(item));
return sanitizeRememberedConversationItems(entry.conversationItems);
}
export function getRememberedFunctionCallsByIds(

View File

@@ -841,6 +841,51 @@ export async function getAccessToken(provider, credentials, log, proxyConfig: un
return null;
}
// RACE CONDITION PREVENTION:
// If the credentials object in memory is stale (e.g. it waited in a semaphore while another
// request refreshed the token), using its OLD refreshToken will cause the provider (e.g. OpenAI)
// to reject it with 'refresh_token_reused' and revoke the new token family.
// We MUST check if the DB has a newer token before proceeding with a network refresh.
if (credentials.connectionId) {
try {
const { getProviderConnectionById } = await import("../../src/lib/db/providers");
const dbConnection = await getProviderConnectionById(credentials.connectionId);
if (
dbConnection &&
dbConnection.refreshToken &&
dbConnection.refreshToken !== credentials.refreshToken
) {
log?.info?.(
"TOKEN_REFRESH",
`Stale token detected in memory for ${provider}. Using refreshed token from DB.`
);
// If the DB token is not expired, we can just return it!
const now = Date.now();
const dbExpiresAt = dbConnection.expiresAt ? new Date(dbConnection.expiresAt).getTime() : 0;
if (dbExpiresAt > now + 60000) {
// 60 seconds buffer
log?.info?.("TOKEN_REFRESH", `DB token is still valid. Skipping OAuth refresh.`);
return {
accessToken: dbConnection.accessToken,
refreshToken: dbConnection.refreshToken,
expiresIn: dbConnection.expiresIn,
};
} else {
// DB token is also expired, but it's the NEWEST one. We must use it to refresh.
credentials.refreshToken = dbConnection.refreshToken;
credentials.accessToken = dbConnection.accessToken;
}
}
} catch (e) {
log?.warn?.(
"TOKEN_REFRESH",
`Failed to check DB for stale token: ${e instanceof Error ? e.message : String(e)}`
);
}
}
const cacheKey = getRefreshCacheKey(provider, credentials.refreshToken);
// If a refresh is already in-flight, reuse it

View File

@@ -19,7 +19,7 @@ import { safePercentage } from "@/shared/utils/formatting";
import { fetchBailianQuota, type BailianTripleWindowQuota } from "./bailianQuotaFetcher.ts";
import {
antigravityUserAgent,
googApiClientHeader,
getAntigravityCreditProbeApiClientHeader,
getAntigravityHeaders,
getAntigravityLoadCodeAssistMetadata,
} from "./antigravityHeaders.ts";
@@ -72,6 +72,10 @@ const CURSOR_USAGE_CONFIG = {
clientVersion: CURSOR_REGISTRY_VERSION,
};
const NANOGPT_CONFIG = {
usageUrl: "https://nano-gpt.com/api/subscription/v1/usage",
};
const MINIMAX_USAGE_CONFIG = {
minimax: {
usageUrls: [
@@ -595,6 +599,86 @@ async function getBailianCodingPlanUsage(
}
}
/**
* NanoGPT Usage
* Fetches subscription-level quota from the NanoGPT API.
* Returns daily/weekly token limits and daily image limits for PRO accounts.
*/
async function getNanoGptUsage(apiKey: string) {
if (!apiKey) {
return { message: "NanoGPT API key not available. Add a key to view usage." };
}
try {
const res = await fetch(NANOGPT_CONFIG.usageUrl, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!res.ok) {
if (res.status === 401) return { message: "Invalid NanoGPT API key." };
return { message: `NanoGPT quota API error (${res.status})` };
}
const data = toRecord(await res.json());
const quotas: Record<string, UsageQuota> = {};
// active -> PRO, otherwise FREE
const plan = data.active ? "PRO" : "FREE";
if (data.active) {
// 1. Tokens limit
// dailyInputTokens if exists, else weeklyInputTokens
let tokenQuota = toRecord(data.dailyInputTokens);
let tokenLabel = "Daily Tokens";
if (!tokenQuota.resetAt) {
const weeklyQuota = toRecord(data.weeklyInputTokens);
if (weeklyQuota.remaining !== undefined) {
tokenQuota = weeklyQuota;
tokenLabel = "Weekly Tokens";
}
}
if (tokenQuota.remaining !== undefined) {
const used = toNumber(tokenQuota.used, 0);
const remaining = toNumber(tokenQuota.remaining, 0);
const total = used + remaining;
quotas[tokenLabel] = {
used,
total,
remaining,
remainingPercentage: clampPercentage(100 - toNumber(tokenQuota.percentUsed, 0) * 100),
resetAt: parseResetTime(tokenQuota.resetAt),
unlimited: false,
};
}
// 2. Images limit
const imageQuota = toRecord(data.dailyImages);
if (imageQuota.remaining !== undefined) {
const used = toNumber(imageQuota.used, 0);
const remaining = toNumber(imageQuota.remaining, 0);
const total = used + remaining;
quotas["Daily Images"] = {
used,
total,
remaining,
remainingPercentage: clampPercentage(100 - toNumber(imageQuota.percentUsed, 0) * 100),
resetAt: parseResetTime(imageQuota.resetAt),
unlimited: false,
};
}
if (Object.keys(quotas).length === 0) {
return { plan, message: "NanoGPT connected, but no active limits found." };
}
}
return { plan, quotas };
} catch (error) {
return { message: `NanoGPT connected. Unable to fetch usage: ${(error as Error).message}` };
}
}
/**
* Get usage data for a provider connection
* @param {Object} connection - Provider connection with accessToken
@@ -635,6 +719,8 @@ export async function getUsageForProvider(connection, options: { forceRefresh?:
return await getCursorUsage(accessToken);
case "bailian-coding-plan":
return await getBailianCodingPlanUsage(id, apiKey, providerSpecificData);
case "nanogpt":
return await getNanoGptUsage(apiKey);
default:
return { message: `Usage API not implemented for ${provider}` };
}
@@ -652,7 +738,7 @@ function parseResetTime(resetValue) {
if (resetValue instanceof Date) {
date = resetValue;
} else if (typeof resetValue === "number") {
date = new Date(resetValue);
date = new Date(resetValue < 1e12 ? resetValue * 1000 : resetValue);
} else if (typeof resetValue === "string") {
date = new Date(resetValue);
} else {
@@ -1427,7 +1513,7 @@ async function probeAntigravityCreditBalanceUncached(
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
"User-Agent": antigravityUserAgent(),
"X-Goog-Api-Client": googApiClientHeader(),
"X-Goog-Api-Client": getAntigravityCreditProbeApiClientHeader(),
Accept: "text/event-stream",
};

View File

@@ -223,13 +223,13 @@ export function createResponsesApiTransformStream(logger = null) {
if (callId && !state.funcItemDone[idx]) {
let args = state.funcArgsBuf[idx] || "{}";
// Fix #1674: Final cleanup of empty string placeholders that might have been split across delta chunks
// Fix #1674 & #1852: Final cleanup of empty string and empty array placeholders
try {
const parsed = JSON.parse(args);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
let modified = false;
for (const [k, v] of Object.entries(parsed)) {
if (v === "") {
if (v === "" || (Array.isArray(v) && v.length === 0)) {
delete parsed[k];
modified = true;
}
@@ -506,12 +506,13 @@ export function createResponsesApiTransformStream(logger = null) {
const refCallId = state.funcCallIds[tcIdx] || newCallId;
let deltaStr = tc.function.arguments;
// Fix #1674: cx/gpt-5.5 injects empty strings for optional parameters.
// We strip these directly from the streaming deltas to avoid breaking strict clients like Claude Code.
if (deltaStr.includes('""')) {
// Fix #1674 & #1852: Strip empty strings and empty arrays from streaming deltas
if (deltaStr.includes('""') || deltaStr.includes("[]") || deltaStr.includes("[ ]")) {
deltaStr = deltaStr
.replace(/,"[a-zA-Z0-9_]+":""/g, "")
.replace(/"[a-zA-Z0-9_]+":"",/g, "");
.replace(/"[a-zA-Z0-9_]+":"",/g, "")
.replace(/,"[a-zA-Z0-9_]+":\s*\[\s*\]/g, "")
.replace(/"[a-zA-Z0-9_]+":\s*\[\s*\],?/g, "");
}
if (refCallId) {

View File

@@ -73,11 +73,13 @@ type GeminiRequest = {
type CloudCodeEnvelope = {
project: string;
model: string;
userAgent: string;
requestId: string;
user_prompt_id?: string;
userAgent?: string;
requestId?: string;
requestType?: string;
request: {
sessionId: string;
session_id?: string;
sessionId?: string;
contents: GeminiContent[];
systemInstruction?: GeminiContent;
generationConfig: GeminiGenerationConfig;
@@ -393,27 +395,38 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra
const cleanModel = model.includes("/") ? model.split("/").pop()! : model;
const envelope: CloudCodeEnvelope = {
project: projectId,
model: cleanModel,
userAgent: isAntigravity ? "antigravity" : "gemini-cli",
requestId: isAntigravity ? `agent-${generateUUID()}` : generateRequestId(),
request: {
sessionId: generateSessionId(),
contents: geminiCLI.contents,
systemInstruction: geminiCLI.systemInstruction,
generationConfig: geminiCLI.generationConfig,
tools: geminiCLI.tools,
},
};
const envelope: CloudCodeEnvelope = isAntigravity
? {
project: projectId,
model: cleanModel,
userAgent: "antigravity",
requestType: "agent",
requestId: `agent-${generateUUID()}`,
request: {
sessionId: generateSessionId(),
contents: geminiCLI.contents,
systemInstruction: geminiCLI.systemInstruction,
generationConfig: geminiCLI.generationConfig,
tools: geminiCLI.tools,
},
}
: {
model: cleanModel,
project: projectId,
user_prompt_id: generateRequestId(),
request: {
contents: geminiCLI.contents,
systemInstruction: geminiCLI.systemInstruction,
generationConfig: geminiCLI.generationConfig,
tools: geminiCLI.tools,
},
};
if (geminiCLI._toolNameMap instanceof Map && geminiCLI._toolNameMap.size > 0) {
envelope._toolNameMap = geminiCLI._toolNameMap;
}
// Antigravity specific fields
if (isAntigravity) {
envelope.requestType = "agent";
// Inject required default system prompt for Antigravity
const defaultPart: GeminiPart = { text: ANTIGRAVITY_DEFAULT_SYSTEM };
if (envelope.request.systemInstruction?.parts) {
@@ -429,7 +442,8 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra
};
}
} else {
// Keep safetySettings for Gemini CLI
// Gemini CLI's native Cloud Code envelope uses snake_case identifiers.
envelope.request.session_id = generateSessionId();
envelope.request.safetySettings = geminiCLI.safetySettings;
}

View File

@@ -633,10 +633,10 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
let argsToEmit = item.arguments;
if (argsToEmit != null && typeof argsToEmit === "object" && !Array.isArray(argsToEmit)) {
// Fix #1674: Strip empty string placeholders emitted by GPT-5.5 for optional fields
// Fix #1674 & #1852: Strip empty string and array placeholders emitted by GPT-5.5 for optional fields
const cleaned = { ...argsToEmit };
for (const [k, v] of Object.entries(cleaned)) {
if (v === "") delete cleaned[k];
if (v === "" || (Array.isArray(v) && v.length === 0)) delete cleaned[k];
}
argsToEmit = cleaned;
}
@@ -685,7 +685,7 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
if (argsToEmit != null && typeof argsToEmit === "object" && !Array.isArray(argsToEmit)) {
const cleaned = { ...argsToEmit };
for (const [k, v] of Object.entries(cleaned)) {
if (v === "") delete cleaned[k];
if (v === "" || (Array.isArray(v) && v.length === 0)) delete cleaned[k];
}
argsToEmit = cleaned;
}

View File

@@ -184,10 +184,21 @@ export function openaiToClaudeResponse(chunk, state) {
if (tc.function?.arguments) {
const toolInfo = state.toolCalls.get(idx);
if (toolInfo) {
let deltaStr = tc.function.arguments;
// Fix #1852: Strip empty string and array placeholders from streaming tool arguments
if (deltaStr.includes('""') || deltaStr.includes("[]") || deltaStr.includes("[ ]")) {
deltaStr = deltaStr
.replace(/,"[a-zA-Z0-9_]+":""/g, "")
.replace(/"[a-zA-Z0-9_]+":"",/g, "")
.replace(/,"[a-zA-Z0-9_]+":\s*\[\s*\]/g, "")
.replace(/"[a-zA-Z0-9_]+":\s*\[\s*\],?/g, "");
}
results.push({
type: "content_block_delta",
index: toolInfo.blockIndex,
delta: { type: "input_json_delta", partial_json: tc.function.arguments },
delta: { type: "input_json_delta", partial_json: deltaStr },
});
}
}

View File

@@ -982,9 +982,16 @@ export function createSSEStream(options: StreamOptions = {}) {
if (extracted) {
usage = extracted;
}
// Track content length and accumulate for call log
if (parsed.delta && typeof parsed.delta === "string") {
// Keep generic Responses deltas for fallback usage estimates,
// but only visible text deltas may become assistant content in
// logs/replay payloads.
if (typeof parsed.delta === "string") {
totalContentLength += parsed.delta.length;
}
if (
parsed.type === "response.output_text.delta" &&
typeof parsed.delta === "string"
) {
passthroughAccumulatedContent = appendBoundedText(
passthroughAccumulatedContent,
parsed.delta

77
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "3.7.7",
"version": "3.7.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "3.7.7",
"version": "3.7.8",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -22,7 +22,6 @@
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.6.2",
"bottleneck": "^2.19.5",
"dompurify": "^3.4.0",
"express": "^5.2.1",
"fetch-socks": "^1.3.2",
"http-proxy-middleware": "^3.0.5",
@@ -33,7 +32,7 @@
"lowdb": "^7.0.1",
"monaco-editor": "^0.55.1",
"next": "^16.2.3",
"next-intl": "^4.8.3",
"next-intl": "^4.11.0",
"node-machine-id": "^1.1.12",
"open": "^11.0.0",
"ora": "^9.1.0",
@@ -1409,18 +1408,18 @@
"license": "MIT"
},
"node_modules/@formatjs/icu-messageformat-parser": {
"version": "3.5.5",
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.5.tgz",
"integrity": "sha512-ASMon8BNlKHgQQpZx84xI80EXRS90GlsEU4wEulCKCzrMtUdrfEvFc9UEYmRbvEvtFQLZ4qHXnisUy6PuFjwyA==",
"version": "3.5.6",
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.6.tgz",
"integrity": "sha512-04ZjRIeQCnR/h32wBP9/S7rkyy1hLAs2fXJcNwc7hseJd//K9TMBqK0ukb4dXqnALKQ9m5ruZeOD2qqEkK9ixg==",
"license": "MIT",
"dependencies": {
"@formatjs/icu-skeleton-parser": "2.1.5"
"@formatjs/icu-skeleton-parser": "2.1.6"
}
},
"node_modules/@formatjs/icu-skeleton-parser": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.5.tgz",
"integrity": "sha512-9Kc6tMaAPZKTGevdfcvx5zT3v4BTfamo+djJE29wF6ds1QLhoA09MZNDpWMZaebWzuoOTIXhDvgmqmjSlUOGlw==",
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.6.tgz",
"integrity": "sha512-9f1VQ2kaaLHK0WPU1OrAmiNKCKJwyoDmwNzQXbUa6XtFBOgHZ4YZURE8sSedHmMr0kvpB75OtplB0hMYkfdwfg==",
"license": "MIT"
},
"node_modules/@formatjs/intl-localematcher": {
@@ -6732,9 +6731,9 @@
}
},
"node_modules/dompurify": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz",
"integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
"integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -8634,9 +8633,9 @@
}
},
"node_modules/icu-minify": {
"version": "4.9.2",
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.9.2.tgz",
"integrity": "sha512-MYt1aKn6Hrag6KO1gns+2XUjguLnL4lUxnHZXCStB2hdml+ooYzMW1VnOkrwKg5aTh5fGjvfZsgJ9DYC21lTmg==",
"version": "4.11.0",
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.11.0.tgz",
"integrity": "sha512-XRvblCwLqWXio5ZLcmDqXvJv7alSACK6UjXuuMOdQWB//d25AQX6xlVlI1FEbc3Q6iPLXXo6HaVLn8LcAFhn1Q==",
"funding": [
{
"type": "individual",
@@ -8767,15 +8766,21 @@
}
},
"node_modules/intl-messageformat": {
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.2.tgz",
"integrity": "sha512-yUfyIkPGqMvvk2onw2xBJeLsjXdiYUYebR8mmZVQYBuZUJsFGVht48Ftm1khgu8EZ0n+izX4rAEj3fLAilkh9g==",
"version": "11.2.3",
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.3.tgz",
"integrity": "sha512-kZthTU+3WLcoWoRg5j6LOkN1TeUBtmkX0OIwSAbcHVIfQAEbGVdmANM8u6GL3eUDOqLwheYoXMUshAh1UdeXlQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@formatjs/fast-memoize": "3.1.2",
"@formatjs/icu-messageformat-parser": "3.5.5"
"@formatjs/fast-memoize": "3.1.3",
"@formatjs/icu-messageformat-parser": "3.5.6"
}
},
"node_modules/intl-messageformat/node_modules/@formatjs/fast-memoize": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.3.tgz",
"integrity": "sha512-Ocd1vPuD68rW6BJDuAOtnnc1GPeVepY5kZXML1psGVFQ+1Q8CfkftT3Tnam+Mxx97Pz08jIEDCotl/GV+Naccg==",
"license": "MIT"
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
@@ -11160,9 +11165,9 @@
}
},
"node_modules/next-intl": {
"version": "4.9.2",
"resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.9.2.tgz",
"integrity": "sha512-AZoMRsVGLZczB2hisq1OTWmNAYAKwk/jaWH4+9pfl5TCG8kbILZZptZHux9zw7DyN1yzh6X7jmaQvoykHs9Y7Q==",
"version": "4.11.0",
"resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.11.0.tgz",
"integrity": "sha512-Chp8rgEVUYOX/bCtYy+PXH6lDX3X+GPT9sR9HScHroL283em/4urP9btfdHEMEHJJXdq2W/5wDaDDtWONPdNSA==",
"funding": [
{
"type": "individual",
@@ -11174,11 +11179,11 @@
"@formatjs/intl-localematcher": "^0.8.1",
"@parcel/watcher": "^2.4.1",
"@swc/core": "^1.15.2",
"icu-minify": "^4.9.2",
"icu-minify": "^4.11.0",
"negotiator": "^1.0.0",
"next-intl-swc-plugin-extractor": "^4.9.2",
"next-intl-swc-plugin-extractor": "^4.11.0",
"po-parser": "^2.1.1",
"use-intl": "^4.9.2"
"use-intl": "^4.11.0"
},
"peerDependencies": {
"next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0",
@@ -11191,9 +11196,9 @@
}
},
"node_modules/next-intl-swc-plugin-extractor": {
"version": "4.9.2",
"resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.9.2.tgz",
"integrity": "sha512-VMlEe18tvYgl+2Fl+/hOKckm0Z0EZooBLt3nFjDMva+gopFwZYhNeLBDrc/R8rulWvwBxz8LN+uKPFSQ0fFJ4A==",
"version": "4.11.0",
"resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.11.0.tgz",
"integrity": "sha512-WUGBSxGNd8eQ0rAsJHFmRw2H7+SZAXQIY/HAnYM57JaUsj5D2vx4KOz4zFtXlyKDtsw9awHfgWVvBae2/RDF9A==",
"license": "MIT"
},
"node_modules/next/node_modules/@swc/helpers": {
@@ -14346,9 +14351,9 @@
}
},
"node_modules/use-intl": {
"version": "4.9.2",
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.9.2.tgz",
"integrity": "sha512-/FCW/P/wJCv6oGTUOWLNvv+i2uowOg7r/1HAZSBfwbcNoBe5tzXgh9uZaoEKaRl9t7MnZqrDNgEJoYd+1ko1oQ==",
"version": "4.11.0",
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.11.0.tgz",
"integrity": "sha512-7ILhTLuo3fnSKhoTGDk5X9591pjtWr6qB4inrlvGkN9OEyKhoiG73GZFoLSs68wz3BsSGtoWa62iWvrYEYU+iA==",
"funding": [
{
"type": "individual",
@@ -14359,7 +14364,7 @@
"dependencies": {
"@formatjs/fast-memoize": "^3.1.0",
"@schummar/icu-type-parser": "1.21.5",
"icu-minify": "^4.9.2",
"icu-minify": "^4.11.0",
"intl-messageformat": "^11.1.0"
},
"peerDependencies": {
@@ -15201,7 +15206,7 @@
},
"open-sse": {
"name": "@omniroute/open-sse",
"version": "3.7.7"
"version": "3.7.8"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "3.7.7",
"version": "3.7.8",
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
"type": "module",
"bin": {
@@ -83,6 +83,8 @@
"check:docs-sync": "node scripts/check-docs-sync.mjs",
"check:node-runtime": "node --import tsx/esm scripts/check-supported-node-runtime.ts",
"check:pack-artifact": "node --import tsx/esm scripts/validate-pack-artifact.ts",
"audit:deps": "npm audit --audit-level=moderate && npm run audit:electron",
"audit:electron": "npm --prefix electron audit --audit-level=moderate",
"typecheck:core": "tsc --pretty false -p tsconfig.typecheck-core.json",
"typecheck:noimplicit:core": "tsc --pretty false -p tsconfig.typecheck-noimplicit-core.json",
"env:sync": "node scripts/sync-env.mjs",
@@ -116,7 +118,6 @@
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.6.2",
"bottleneck": "^2.19.5",
"dompurify": "^3.4.0",
"express": "^5.2.1",
"fetch-socks": "^1.3.2",
"http-proxy-middleware": "^3.0.5",
@@ -127,7 +128,7 @@
"lowdb": "^7.0.1",
"monaco-editor": "^0.55.1",
"next": "^16.2.3",
"next-intl": "^4.8.3",
"next-intl": "^4.11.0",
"node-machine-id": "^1.1.12",
"open": "^11.0.0",
"ora": "^9.1.0",
@@ -202,7 +203,7 @@
},
"overrides": {
"lodash-es": "^4.18.1",
"dompurify": "^3.4.0",
"dompurify": "^3.4.2",
"path-to-regexp": "^8.4.0",
"postcss": "^8.5.10",
"hono": "^4.12.14",

View File

@@ -18,7 +18,7 @@
* 4. process.env (shell / Docker -e flags, highest priority)
*/
import { randomBytes, createDecipheriv } from "node:crypto";
import { randomBytes, createDecipheriv, scryptSync, createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { homedir } from "node:os";
@@ -246,14 +246,41 @@ export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) {
const iv = Buffer.from(parts[2], "hex");
const ct = Buffer.from(parts[3], "hex");
const tag = Buffer.from(parts[4], "hex");
const key = Buffer.from(merged.STORAGE_ENCRYPTION_KEY, "hex");
const decipher = createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(tag);
try {
// Try decrypting with both key derivation methods matching encryption.ts
const tryDecrypt = (derivedKey) => {
const decipher = createDecipheriv("aes-256-gcm", derivedKey, iv);
decipher.setAuthTag(tag);
decipher.update(ct);
decipher.final();
// Decrypt succeeded — key matches
};
// Dynamic salt (current): scryptSync(secret, sha256(secret).slice(0,16), 32)
const dynamicSalt = createHash("sha256")
.update(merged.STORAGE_ENCRYPTION_KEY)
.digest()
.slice(0, 16);
const dynamicKey = scryptSync(merged.STORAGE_ENCRYPTION_KEY, dynamicSalt, 32);
// Legacy salt (fallback): scryptSync(secret, "omniroute-field-encryption-v1", 32)
const legacySalt = "omniroute-field-encryption-v1";
const legacyKey = scryptSync(merged.STORAGE_ENCRYPTION_KEY, legacySalt, 32);
let keyMatched = false;
try {
tryDecrypt(dynamicKey);
keyMatched = true;
} catch {
// Try legacy key as fallback
try {
tryDecrypt(legacyKey);
keyMatched = true;
} catch {
// Both failed — key truly doesn't match
}
}
if (!keyMatched) {
log(
"⛔ STORAGE_ENCRYPTION_KEY does not match the key used to encrypt your stored credentials."
);

View File

@@ -59,6 +59,8 @@ async function main() {
OMNIROUTE_BASE_URL: baseUrl,
}),
OMNIROUTE_E2E_BOOTSTRAP_MODE: process.env.OMNIROUTE_E2E_BOOTSTRAP_MODE || "open",
REQUIRE_API_KEY: explicitBaseUrl ? process.env.REQUIRE_API_KEY : "false",
ENABLE_CLI_TOOLS: "true",
};
if (!(await isServerReady())) {

View File

@@ -167,7 +167,11 @@ const testServerEnv = {
OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK: process.env.OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK || "true",
OMNIROUTE_HIDE_HEALTHCHECK_LOGS: process.env.OMNIROUTE_HIDE_HEALTHCHECK_LOGS || "true",
...(bootstrapMode === "open"
? {}
? {
INITIAL_PASSWORD: "",
OMNIROUTE_E2E_PASSWORD: "",
OMNIROUTE_API_KEY: "",
}
: {
INITIAL_PASSWORD: playwrightPassword,
OMNIROUTE_E2E_PASSWORD: playwrightPassword,

View File

@@ -0,0 +1,39 @@
import os
import shutil
import subprocess
categories = {
"viable": [1718, 1731, 1764],
"need_details": [1765, 1679, 1594, 1584],
"defer": [1845, 1833, 1814, 1786, 1737, 1736, 1735, 1716, 1591, 1590, 1589, 1588, 1587],
"notfit": [1826, 1788, 1529, 1586] # 1586 is already exists, goes to notfit
}
# Create dirs
os.makedirs("_ideia/viable/need_details", exist_ok=True)
os.makedirs("_ideia/defer", exist_ok=True)
os.makedirs("_ideia/notfit", exist_ok=True)
files = os.listdir("_ideia")
for f in files:
if not f.endswith(".md"): continue
num_str = f.split('-')[0]
if not num_str.isdigit(): continue
num = int(num_str)
src = os.path.join("_ideia", f)
if num in categories["viable"]:
dst = os.path.join("_ideia/viable", f)
elif num in categories["need_details"]:
dst = os.path.join("_ideia/viable/need_details", f)
elif num in categories["defer"]:
dst = os.path.join("_ideia/defer", f)
elif num in categories["notfit"]:
dst = os.path.join("_ideia/notfit", f)
else:
continue
shutil.move(src, dst)
print(f"Moved {f} to {dst}")

View File

@@ -0,0 +1,96 @@
import json
import os
import subprocess
import re
issues = [1845, 1833, 1826, 1814, 1788, 1786, 1765, 1764, 1737, 1736, 1735, 1731, 1718, 1716, 1679, 1594, 1591, 1590, 1589, 1588, 1587, 1586, 1584, 1529]
os.makedirs('_ideia', exist_ok=True)
def slugify(value):
value = re.sub(r'\[Feature\]', '', value, flags=re.IGNORECASE)
value = re.sub(r'[^\w\s-]', '', value).strip().lower()
return re.sub(r'[-\s]+', '-', value)
for num in issues:
print(f"Processing #{num}...")
try:
res = subprocess.run(
['gh', 'issue', 'view', str(num), '--repo', 'diegosouzapw/OmniRoute', '--json', 'number,title,labels,body,comments,createdAt,author,assignees'],
capture_output=True, text=True, check=True
)
data = json.loads(res.stdout)
slug = slugify(data['title'])
filename = f"_ideia/{data['number']}-{slug}.md"
author = data['author']['login']
created_at = data['createdAt']
body = data['body']
comments = data['comments']
participants = set([author])
comments_str = ""
if comments:
for c in comments:
c_author = c['author']['login']
participants.add(c_author)
comments_str += f"**@{c_author}** ({c['createdAt']}):\n{c['body']}\n---\n"
else:
comments_str = "*No comments.*"
participants_list = "\n".join([f"- @{p}" for p in participants])
content = f"""# Feature: {data['title']}
> GitHub Issue: #{data['number']} — opened by @{author} on {created_at}
> Status: 📋 Cataloged | Priority: TBD
## 📝 Original Request
{body}
## 💬 Community Discussion
{comments_str}
### Participants
{participants_list}
### Key Points
- Needs detailed analysis
## 🎯 Refined Feature Description
Feature needs manual refinement and interpretation to fill logical gaps and outline high-level technical scope.
### What it solves
- TBD
### How it should work (high level)
1. TBD
2. TBD
### Affected areas
- TBD
## 📎 Attachments & References
- Check issue body for references
## 🔗 Related Ideas
- None yet
"""
with open(filename, 'w', encoding='utf-8') as f:
f.write(content)
except Exception as e:
print(f"Error processing {num}: {e}")
print("Done.")

View File

@@ -0,0 +1,91 @@
import os
viable_data = {
1718: {
"title": "expose upstream error details in client-facing error responses",
"solves": "Debugging upstream errors is difficult because they are hidden behind generic '[400] Error from provider' wrappers.",
"how": "1. Modify `buildErrorBody` to accept `upstream_details`.\n2. In `BaseExecutor` or specific handlers, parse the raw upstream response on failure.\n3. Propagate the parsed body to the client response inside `upstream_details`.",
"areas": "open-sse/executors/base.ts, open-sse/utils/errors.ts, src/app/api/v1/chat/completions/route.ts",
"req_summary": "Expose the upstream error body (e.g. context_length_exceeded) directly in the error response under an `upstream_details` key, without breaking OpenAI compatibility.",
"req_approach": "Modify the central error generation functions (like `buildErrorBody`) to optionally accept an `upstreamDetails` object. Update the request executors to pass the JSON parsed error from the upstream response into this new parameter when a request fails.",
"req_files": "| File | Changes |\n|---|---|\n| `open-sse/utils/errors.ts` | Update `buildErrorBody` to include `upstream_details`. |\n| `open-sse/executors/base.ts` | Extract response body on failure and pass to error builder. |",
"req_effort": "Low. A few files changed. No breaking changes."
},
1731: {
"title": "(combo): provider-level exhaustion tracking to skip same-provider targets",
"solves": "Combo routing wastes significant time retrying multiple targets from the same provider when the entire provider is rate-limited or quota-exhausted.",
"how": "1. Track 429 quota exhaustion errors at the provider level.\n2. In `combo.ts`, before attempting a target, check if its provider is currently marked as exhausted.\n3. If exhausted, skip the target and move to the next provider.",
"areas": "open-sse/services/combo.ts, open-sse/services/accountFallback.ts",
"req_summary": "Implement provider-level 429 exhaustion tracking in the combo router so it skips remaining targets of a provider if a 429 quota exhaustion occurs.",
"req_approach": "Add a temporary exclusion set in `handleComboChat` that tracks providers that have returned a hard 429. Before evaluating the next target in the combo, check if its provider is in the exclusion set and skip it if true.",
"req_files": "| File | Changes |\n|---|---|\n| `open-sse/services/combo.ts` | Add logic to track provider failures and skip matching targets. |\n| `open-sse/services/accountFallback.ts` | Properly bubble up the 429 status. |",
"req_effort": "Medium. Needs careful state tracking across the combo loop. No breaking changes."
},
1764: {
"title": "Make installation script detect termux",
"solves": "OmniRoute fails to start or install properly on Termux because `wreq-js` attempts to load a native `libgcc` arm64 module which is incompatible.",
"how": "1. Update the `postinstall` script to check `process.env.PREFIX` for termux.\n2. If termux is detected, gracefully skip or patch the wreq-js installation/loading.",
"areas": "scripts/postinstall.mjs, open-sse/executors/wreq.ts",
"req_summary": "Detect termux environments during installation or runtime and gracefully handle the `wreq-js` native module failure, allowing the rest of OmniRoute to function.",
"req_approach": "Modify `scripts/postinstall.mjs` or the wreq-js loader logic. If `process.env.PREFIX && process.env.PREFIX.includes('termux')` is true, avoid hard crashing on wreq-js load failures.",
"req_files": "| File | Changes |\n|---|---|\n| `scripts/postinstall.mjs` | Add termux detection and warning. |\n| `open-sse/utils/env.ts` (or similar) | Graceful downgrade. |",
"req_effort": "Low. Very localized fix."
}
}
for root, _, files in os.walk("_ideia/viable"):
for f in files:
if not f.endswith(".md") or "requirements" in f: continue
num = int(f.split('-')[0])
if num in viable_data:
path = os.path.join(root, f)
with open(path, 'r') as file:
content = file.read()
d = viable_data[num]
# replace TBDs
content = content.replace("### What it solves\n\n- TBD", f"### What it solves\n\n- {d['solves']}")
content = content.replace("### How it should work (high level)\n\n1. TBD\n2. TBD", f"### How it should work (high level)\n\n{d['how']}")
content = content.replace("### Affected areas\n\n- TBD", f"### Affected areas\n\n- {d['areas']}")
content = content.replace("Feature needs manual refinement and interpretation to fill logical gaps and outline high-level technical scope.", "Refined and scoped for implementation.")
with open(path, 'w') as file:
file.write(content)
req_content = f"""# Requirements: {d['title']}
> Feature Idea: [#{num}](./{f})
> Research Date: 2026-05-01
> Verdict: ✅ VIABLE
## 🔍 Research Summary
{d['req_summary']}
## 📚 Reference Implementations
| # | Repository | Stars | Last Updated | Approach | Relevance |
| --- | ---------------- | ----- | ------------ | -------- | ------------ |
| 1 | OmniRoute Source | - | 2026-05-01 | Internal | High |
## 📐 Proposed Solution Architecture
### Approach
{d['req_approach']}
### Modified Files
{d['req_files']}
## ⚙️ Implementation Effort
- **Estimated complexity**: {d['req_effort']}
- **Breaking changes**: No
"""
req_path = os.path.join(root, f.replace(".md", ".requirements.md"))
with open(req_path, 'w') as req_file:
req_file.write(req_content)
print(f"Refined {num} and created requirements.")

View File

@@ -0,0 +1,47 @@
import Database from "better-sqlite3";
import fs from "fs";
import path from "path";
import { runMigrations, getMigrationStatus } from "../../src/lib/db/migrationRunner.ts";
const db = new Database(":memory:");
db.exec(`
CREATE TABLE IF NOT EXISTS _omniroute_migrations (
version TEXT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO _omniroute_migrations (version, name) VALUES ('001', 'initial_schema');
INSERT INTO _omniroute_migrations (version, name) VALUES ('002', 'mcp_a2a_tables');
INSERT INTO _omniroute_migrations (version, name) VALUES ('003', 'provider_node_custom_paths');
INSERT INTO _omniroute_migrations (version, name) VALUES ('004', 'proxy_registry');
INSERT INTO _omniroute_migrations (version, name) VALUES ('005', 'combo_agent_fields');
INSERT INTO _omniroute_migrations (version, name) VALUES ('006', 'detailed_request_logs');
INSERT INTO _omniroute_migrations (version, name) VALUES ('007', 'search_request_type');
INSERT INTO _omniroute_migrations (version, name) VALUES ('008', 'registered_keys');
INSERT INTO _omniroute_migrations (version, name) VALUES ('009', 'requested_model');
INSERT INTO _omniroute_migrations (version, name) VALUES ('010', 'model_combo_mappings');
INSERT INTO _omniroute_migrations (version, name) VALUES ('011', 'webhooks');
INSERT INTO _omniroute_migrations (version, name) VALUES ('012', 'fix_token_input_cache_tokens');
INSERT INTO _omniroute_migrations (version, name) VALUES ('013', 'quota_snapshots');
INSERT INTO _omniroute_migrations (version, name) VALUES ('014', 'unified_log_artifacts');
INSERT INTO _omniroute_migrations (version, name) VALUES ('015', 'create_memories');
INSERT INTO _omniroute_migrations (version, name) VALUES ('016', 'create_skills');
INSERT INTO _omniroute_migrations (version, name) VALUES ('017', 'version_manager_upstream_proxy');
INSERT INTO _omniroute_migrations (version, name) VALUES ('018', 'call_logs_detailed_tokens');
INSERT INTO _omniroute_migrations (version, name) VALUES ('019', 'context_handoffs');
INSERT INTO _omniroute_migrations (version, name) VALUES ('020', 'combo_sort_order');
INSERT INTO _omniroute_migrations (version, name) VALUES ('021', 'combo_call_log_targets');
INSERT INTO _omniroute_migrations (version, name) VALUES ('026', 'call_logs_cache_source');
INSERT INTO _omniroute_migrations (version, name) VALUES ('029', 'provider_connection_max_concurrent');
`);
console.log("Status before:", getMigrationStatus(db));
try {
runMigrations(db, { isNewDb: false });
} catch (e) {
console.error("Migration threw error:", e);
}
console.log("Status after:", getMigrationStatus(db));

View File

@@ -403,8 +403,8 @@ export default function AgentsPage() {
<span className="material-symbols-outlined text-[14px] text-text-muted">fingerprint</span>
<p className="text-xs text-text-muted">
{t("fingerprintSettingsHint")}{" "}
<Link href="/dashboard/settings" className="text-primary hover:underline">
{t("openSettings")}
<Link href="/dashboard/settings?tab=routing" className="text-primary hover:underline">
{t("settingsRoutingLink")}
</Link>
</p>
</div>

View File

@@ -240,7 +240,7 @@ export default function CompressionAnalyticsTab() {
<div className="card p-5">
<h3 className="font-semibold text-text mb-4 flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[20px]">show_chart</span>
Last 24 Hours
Last 24 Hours (Activity)
</h3>
<div className="flex items-end gap-2 h-48">
{stats.last24h.map((entry, idx) => {
@@ -258,7 +258,7 @@ export default function CompressionAnalyticsTab() {
</div>
</div>
<div className="text-xs text-text-muted rotate-45 origin-left">
{entry.hour.substring(0, 2)}
{entry.hour.substring(11, 13)}
</div>
</div>
);

View File

@@ -70,7 +70,7 @@ const MODALITY_CONFIG: Record<
placeholder: "Hello! Welcome to OmniRoute, your intelligent AI gateway...",
color: "from-green-500 to-teal-500",
textLabel: "Text",
needsCredentials: ["openai", "elevenlabs", "deepgram"],
needsCredentials: ["openai", "elevenlabs", "deepgram", "xiaomi-mimo"],
},
transcription: {
icon: "mic",
@@ -183,6 +183,15 @@ const PROVIDER_MODELS: Record<
models: [{ id: "huggingface/espnet/kan-bayashi_ljspeech_vits", name: "VITS LJSpeech" }],
},
{ id: "qwen", name: "Qwen", models: [{ id: "qwen/qwen3-tts", name: "Qwen3 TTS" }] },
{
id: "xiaomi-mimo",
name: "Xiaomi MiMo",
models: [
{ id: "xiaomi-mimo/mimo-v2.5-tts", name: "MiMo V2.5 TTS" },
{ id: "xiaomi-mimo/mimo-v2.5-tts-voicedesign", name: "MiMo V2.5 Voice Design" },
{ id: "xiaomi-mimo/mimo-v2.5-tts-voiceclone", name: "MiMo V2.5 Voice Clone" },
],
},
],
transcription: [
{
@@ -271,9 +280,26 @@ const VOICE_PRESETS: Record<string, { id: string; label: string }[]> = {
{ id: "Eva", label: "Eva (EN)" },
{ id: "Marcus", label: "Marcus (EN)" },
],
"xiaomi-mimo": [
{ id: "冰糖", label: "冰糖 (Chinese Female)" },
{ id: "茉莉", label: "茉莉 (Chinese Female)" },
{ id: "苏打", label: "苏打 (Chinese Male)" },
{ id: "白桦", label: "白桦 (Chinese Male)" },
{ id: "Mia", label: "Mia (English Female)" },
{ id: "Chloe", label: "Chloe (English Female)" },
{ id: "Milo", label: "Milo (English Male)" },
{ id: "Dean", label: "Dean (English Male)" },
],
};
const SPEECH_FORMATS = ["mp3", "wav", "opus", "flac", "pcm"];
const SPEECH_FORMATS_BY_PROVIDER: Record<string, string[]> = {
"xiaomi-mimo": ["mp3", "wav"],
};
function getSpeechFormats(providerId: string): string[] {
return SPEECH_FORMATS_BY_PROVIDER[providerId] || SPEECH_FORMATS;
}
function getVoiceList(providerId: string) {
return VOICE_PRESETS[providerId] ?? VOICE_PRESETS.default;
@@ -433,6 +459,15 @@ export default function MediaPageClient() {
new Set(LOCAL_PROVIDERS) // Optimistic: show all until we know otherwise
);
useEffect(() => {
const audioUrl = result?.audioUrl;
return () => {
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
};
}, [result?.audioUrl]);
useEffect(() => {
// Fetch configured provider connections to determine which local providers are set up
fetch("/api/providers")
@@ -485,6 +520,7 @@ export default function MediaPageClient() {
setSelectedModel(firstModel);
if (tab === "speech") {
setSpeechVoice(getVoiceList(firstProvider?.id ?? "")[0]?.id ?? "alloy");
setSpeechFormat(getSpeechFormats(firstProvider?.id ?? "")[0] ?? "mp3");
}
};
@@ -495,6 +531,8 @@ export default function MediaPageClient() {
setSelectedModel(firstModel);
if (activeTab === "speech") {
setSpeechVoice(getVoiceList(providerId)[0]?.id ?? "alloy");
const formats = getSpeechFormats(providerId);
setSpeechFormat((current) => (formats.includes(current) ? current : (formats[0] ?? "mp3")));
}
};
@@ -649,6 +687,7 @@ export default function MediaPageClient() {
const config = MODALITY_CONFIG[activeTab];
const voiceList = getVoiceList(selectedProvider);
const currentSpeechFormats = getSpeechFormats(selectedProvider);
const isTopazImageFlow = activeTab === "image" && selectedProvider === "topaz";
const isGenerateDisabled =
loading ||
@@ -765,7 +804,7 @@ export default function MediaPageClient() {
onChange={(e) => setSpeechFormat(e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-surface border border-black/10 dark:border-white/10 text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/30"
>
{SPEECH_FORMATS.map((f) => (
{currentSpeechFormats.map((f) => (
<option key={f} value={f}>
{f}
</option>

View File

@@ -606,6 +606,7 @@ export default function CombosPage() {
const [comboDragOverIndex, setComboDragOverIndex] = useState(null);
const [savingComboOrder, setSavingComboOrder] = useState(false);
const [comboConfigMode, setComboConfigMode] = useState("guided");
const [promptCompressionEnabled, setPromptCompressionEnabled] = useState(false);
const [selectedIntelligentComboId, setSelectedIntelligentComboId] = useState<string | null>(null);
const comboDragIndexRef = useRef<number | null>(null);
const activeFilter = normalizeIntelligentRoutingFilter(searchParams.get("filter"));
@@ -650,6 +651,10 @@ export default function CombosPage() {
.then((r) => (r.ok ? r.json() : null))
.then((settings) => setComboConfigMode(normalizeComboConfigMode(settings?.comboConfigMode)))
.catch(() => setComboConfigMode("guided"));
fetch("/api/settings/compression")
.then((r) => (r.ok ? r.json() : null))
.then((settings) => setPromptCompressionEnabled(settings?.enabled === true))
.catch(() => setPromptCompressionEnabled(false));
fetch("/api/settings/proxy")
.then((r) => (r.ok ? r.json() : null))
.then((c) => setProxyConfig(c))
@@ -1128,6 +1133,7 @@ export default function CombosPage() {
<ComboCard
combo={combo}
metrics={metrics[combo.name]}
compressionEnabled={promptCompressionEnabled}
providerNodes={providerNodes}
copied={copied}
onCopy={copy}
@@ -1499,6 +1505,7 @@ function ComboReadinessPanel({ checks, blockers, showDescription = true }) {
function ComboCard({
combo,
metrics,
compressionEnabled,
copied,
onCopy,
onEdit,
@@ -1524,10 +1531,20 @@ function ComboCard({
const tc = useTranslations("common");
const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible);
const strategyDescription = getStrategyDescription(t, strategy);
const initialCompressionMode = combo?.config?.compressionMode || combo.compressionOverride || "";
const hasRuntimeConfig = combo?.config && typeof combo.config === "object";
const initialCompressionMode =
typeof combo?.config?.compressionMode === "string"
? combo.config.compressionMode
: hasRuntimeConfig
? ""
: combo.compressionOverride || "";
const [compressionOverride, setCompressionOverride] = useState(initialCompressionMode);
const [isSavingCompression, setIsSavingCompression] = useState(false);
useEffect(() => {
setCompressionOverride(initialCompressionMode);
}, [initialCompressionMode]);
const handleCompressionOverrideChange = async (value) => {
setCompressionOverride(value);
setIsSavingCompression(true);
@@ -1687,20 +1704,22 @@ function ComboCard({
</span>
</div>
<div className="flex items-center gap-1.5 transition-opacity">
<select
value={compressionOverride}
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"
>
<option value="">Default</option>
<option value="off">Off</option>
<option value="lite">Lite</option>
<option value="standard">Standard</option>
<option value="aggressive">Aggressive</option>
<option value="ultra">Ultra</option>
</select>
{compressionEnabled && (
<select
value={compressionOverride}
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"
>
<option value="">Default</option>
<option value="off">Off</option>
<option value="lite">Lite</option>
<option value="standard">Standard</option>
<option value="aggressive">Aggressive</option>
<option value="ultra">Ultra</option>
</select>
)}
<button
onClick={onTest}
disabled={testing}

View File

@@ -99,7 +99,13 @@ export default function MemoryPage() {
const link = document.createElement("a");
link.href = url;
link.download = `memory-export-${new Date().toISOString()}.json`;
link.click();
try {
document.body.appendChild(link);
link.click();
} finally {
link.remove();
URL.revokeObjectURL(url);
}
};
const handleImportClick = () => {

View File

@@ -233,6 +233,14 @@ export default function PlaygroundPage() {
const isImageEndpoint = selectedEndpoint === "images";
const supportsVision = isChatEndpoint && isVisionModel(selectedModel);
useEffect(() => {
return () => {
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
};
}, [audioUrl]);
// Load connections for a given provider — filtered from allConnections
const providerConnections = allConnections.filter((c) => {
if (!selectedProvider) return false;

View File

@@ -503,6 +503,7 @@ interface ConnectionRowConnection {
providerSpecificData?: Record<string, unknown>;
expiresAt?: string;
tokenExpiresAt?: string;
maxConcurrent?: number | null;
}
interface ConnectionRowProps {
@@ -5151,6 +5152,15 @@ function ConnectionRow({
{t("autoPriority", { priority: connection.globalPriority })}
</span>
)}
{connection.maxConcurrent != null && connection.maxConcurrent > 0 && (
<span
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs font-medium bg-zinc-500/15 text-zinc-500 dark:bg-zinc-400/15 dark:text-zinc-400"
title={t("accountConcurrencyCapLabel")}
>
<span className="material-symbols-outlined text-[11px]">dynamic_feed</span>
{connection.maxConcurrent}
</span>
)}
{/* Rate Limit Protection — inline toggle with label */}
<span className="text-text-muted/30 select-none">|</span>
<button

View File

@@ -36,6 +36,13 @@ interface CacheMetrics {
const REFRESH_INTERVAL_MS = 10_000;
const REFRESH_INTERVAL_SECONDS = REFRESH_INTERVAL_MS / 1000;
function formatNumberCompact(num: number): string {
return Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 1,
}).format(num);
}
export default function CacheStatsCard() {
const [metrics, setMetrics] = useState<CacheMetrics | null>(null);
const [resetting, setResetting] = useState(false);
@@ -117,19 +124,19 @@ export default function CacheStatsCard() {
<div>
<p className="text-text-muted">{t("inputTokens")}</p>
<p className="font-mono text-lg text-text-main">
{metrics.totalInputTokens.toLocaleString()}
{formatNumberCompact(metrics.totalInputTokens)}
</p>
</div>
<div>
<p className="text-text-muted">{t("cachedTokensRead")}</p>
<p className="font-mono text-lg text-green-400">
{metrics.totalCachedTokens.toLocaleString()}
{formatNumberCompact(metrics.totalCachedTokens)}
</p>
</div>
<div>
<p className="text-text-muted">{t("cacheCreationWrite")}</p>
<p className="font-mono text-lg text-blue-400">
{metrics.totalCacheCreationTokens.toLocaleString()}
{formatNumberCompact(metrics.totalCacheCreationTokens)}
</p>
</div>
</div>
@@ -157,7 +164,7 @@ export default function CacheStatsCard() {
<div>
<p className="text-text-muted">{t("tokensSaved")}</p>
<p className="font-mono text-lg text-green-400">
{metrics.tokensSaved.toLocaleString()}
{formatNumberCompact(metrics.tokensSaved)}
</p>
</div>
<div>
@@ -189,13 +196,13 @@ export default function CacheStatsCard() {
</div>
<div className="flex items-center gap-4 font-mono">
<span className="text-text-muted" title={t("inputTokens")}>
{t("inputShort")}: {stats.inputTokens.toLocaleString()}
{t("inputShort")}: {formatNumberCompact(stats.inputTokens)}
</span>
<span className="text-green-400" title={t("cachedTokensRead")}>
{t("cachedShort")}: {stats.cachedTokens.toLocaleString()}
{t("cachedShort")}: {formatNumberCompact(stats.cachedTokens)}
</span>
<span className="text-blue-400" title={t("cacheCreationWrite")}>
{t("writeShort")}: {stats.cacheCreationTokens.toLocaleString()}
{t("writeShort")}: {formatNumberCompact(stats.cacheCreationTokens)}
</span>
<span className="text-green-400 w-12 text-right">
{providerCacheRate.toFixed(0)}%

View File

@@ -0,0 +1,330 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Button, Card } from "@/shared/components";
type OneproxyItem = {
id: string;
name: string;
host: string;
port: number;
type: string;
countryCode: string | null;
qualityScore: number | null;
latencyMs: number | null;
anonymity: string | null;
googleAccess: boolean;
status: string;
lastValidated: string | null;
};
type OneproxyStats = {
total: number;
active: number;
avgQuality: number | null;
lastValidated: string | null;
byProtocol: Array<{ protocol: string; count: number }>;
byCountry: Array<{ countryCode: string; count: number }>;
};
type SyncStatus = {
lastSyncSuccess: boolean;
lastSyncError: string | null;
lastSyncAt: string | null;
lastSyncCount: number;
consecutiveFailures: number;
};
export default function OneproxyTab() {
const t = useTranslations("settings");
const [proxies, setProxies] = useState<OneproxyItem[]>([]);
const [stats, setStats] = useState<OneproxyStats | null>(null);
const [status, setStatus] = useState<SyncStatus | null>(null);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<string | null>(null);
const [filterProtocol, setFilterProtocol] = useState("");
const [filterCountry, setFilterCountry] = useState("");
const [minQuality, setMinQuality] = useState("");
const loadData = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (filterProtocol) params.set("protocol", filterProtocol);
if (filterCountry) params.set("countryCode", filterCountry);
if (minQuality) params.set("minQuality", minQuality);
const [proxiesRes, statsRes] = await Promise.all([
fetch(`/api/settings/oneproxy?${params.toString()}`),
fetch("/api/settings/oneproxy?action=stats"),
]);
if (proxiesRes.ok) {
const data = await proxiesRes.json();
setProxies(data.items || []);
}
if (statsRes.ok) {
const data = await statsRes.json();
setStats(data.stats);
setStatus(data.status);
}
} catch {
} finally {
setLoading(false);
}
}, [filterProtocol, filterCountry, minQuality]);
useEffect(() => {
loadData();
}, [loadData]);
const handleSync = async () => {
setSyncing(true);
setSyncResult(null);
try {
const res = await fetch("/api/settings/oneproxy", { method: "POST" });
const data = await res.json();
if (data.success) {
setSyncResult(`Synced ${data.total} proxies (${data.added} new, ${data.updated} updated)`);
} else {
setSyncResult(`Sync failed: ${data.error}`);
}
await loadData();
} catch (err) {
setSyncResult(`Sync failed: ${err}`);
} finally {
setSyncing(false);
}
};
const handleClearAll = async () => {
if (!confirm("Clear all 1proxy proxies?")) return;
try {
await fetch("/api/settings/oneproxy?clearAll=1", { method: "DELETE" });
await loadData();
} catch {
// ignore
}
};
const handleDelete = async (id: string) => {
try {
await fetch(`/api/settings/oneproxy?id=${id}`, { method: "DELETE" });
setProxies((prev) => prev.filter((p) => p.id !== id));
if (stats) setStats({ ...stats, total: stats.total - 1, active: stats.active - 1 });
} catch {
// ignore
}
};
const qualityColor = (score: number | null) => {
if (score == null) return "bg-gray-500";
if (score >= 80) return "bg-green-500";
if (score >= 50) return "bg-yellow-500";
return "bg-red-500";
};
const protocolBadge = (type: string) => {
const colors: Record<string, string> = {
http: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
https: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
socks4: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
socks5: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200",
};
return colors[type] || "bg-gray-100 text-gray-800";
};
return (
<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>
<p className="text-sm text-text-muted mt-1">
Fetch and rotate free validated proxies from the 1proxy community platform
</p>
</div>
<div className="flex gap-2">
<Button onClick={handleSync} disabled={syncing} variant="primary">
{syncing ? "Syncing..." : "Sync Now"}
</Button>
{proxies.length > 0 && (
<Button onClick={handleClearAll} variant="danger">
Clear All
</Button>
)}
</div>
</div>
{syncResult && (
<div
className={`p-3 rounded-lg text-sm ${
syncResult.startsWith("Synced")
? "bg-green-50 text-green-800 dark:bg-green-900/30 dark:text-green-300"
: "bg-red-50 text-red-800 dark:bg-red-900/30 dark:text-red-300"
}`}
>
{syncResult}
</div>
)}
{stats && (
<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>
</Card>
<Card className="p-4">
<div className="text-2xl font-bold text-green-600">{stats.active}</div>
<div className="text-sm text-text-muted">Active</div>
</Card>
<Card className="p-4">
<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>
</Card>
<Card className="p-4">
<div className="text-2xl font-bold text-text-main">
{status?.lastSyncAt ? new Date(status.lastSyncAt).toLocaleTimeString() : "Never"}
</div>
<div className="text-sm text-text-muted">Last Sync</div>
</Card>
</div>
)}
<Card className="p-4">
<div className="flex flex-wrap gap-3">
<select
value={filterProtocol}
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="http">HTTP</option>
<option value="https">HTTPS</option>
<option value="socks4">SOCKS4</option>
<option value="socks5">SOCKS5</option>
</select>
<input
type="text"
placeholder="Country code (e.g. US)"
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"
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"
/>
</div>
</Card>
<Card className="p-4">
{loading ? (
<div className="text-center py-8 text-text-muted">Loading proxies...</div>
) : proxies.length === 0 ? (
<div className="text-center py-8 text-text-muted">
No 1proxy proxies found. Click &quot;Sync Now&quot; to fetch free proxies.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 px-3 text-text-muted font-medium">Host</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">Protocol</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">Country</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">Quality</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">Latency</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">Anonymity</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">Google</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">Actions</th>
</tr>
</thead>
<tbody>
{proxies.map((proxy) => (
<tr
key={proxy.id}
className="border-b border-border/50 hover:bg-black/5 dark:hover:bg-white/5"
>
<td className="py-2 px-3 font-mono text-text-main">
{proxy.host}:{proxy.port}
</td>
<td className="py-2 px-3">
<span
className={`px-2 py-0.5 rounded text-xs font-medium ${protocolBadge(proxy.type)}`}
>
{proxy.type.toUpperCase()}
</span>
</td>
<td className="py-2 px-3 text-text-main">{proxy.countryCode || "—"}</td>
<td className="py-2 px-3">
<div className="flex items-center gap-2">
<div
className={`w-3 h-3 rounded-full ${qualityColor(proxy.qualityScore)}`}
/>
<span className="text-text-main">{proxy.qualityScore ?? "—"}</span>
</div>
</td>
<td className="py-2 px-3 text-text-main">
{proxy.latencyMs != null ? `${proxy.latencyMs}ms` : "—"}
</td>
<td className="py-2 px-3 text-text-main">{proxy.anonymity || "—"}</td>
<td className="py-2 px-3">
{proxy.googleAccess ? (
<span className="text-green-600">&#10003;</span>
) : (
<span className="text-red-600">&#10007;</span>
)}
</td>
<td className="py-2 px-3">
<button
onClick={() => handleDelete(proxy.id)}
className="text-red-500 hover:text-red-700 text-xs"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
{status && (
<Card className="p-4">
<h3 className="text-sm font-semibold text-text-main mb-2">Sync Status</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={status.lastSyncSuccess ? "text-green-600" : "text-red-600"}>
{status.lastSyncSuccess ? "Success" : "Failed"}
</span>
</div>
<div>
<span className="text-text-muted">Proxies fetched: </span>
<span className="text-text-main">{status.lastSyncCount}</span>
</div>
<div>
<span className="text-text-muted">Consecutive failures: </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-red-600 text-xs">{status.lastSyncError}</span>
</div>
)}
</div>
</Card>
)}
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ProxyConfigModal, Toggle } from "@/shared/components";
import { useTranslations } from "next-intl";
import ProxyRegistryManager from "./ProxyRegistryManager";
import OneproxyTab from "./OneproxyTab";
export default function ProxyTab() {
const [proxyModalOpen, setProxyModalOpen] = useState(false);
@@ -137,6 +138,7 @@ export default function ProxyTab() {
</Card>
<ProxyRegistryManager />
<OneproxyTab />
<Card className="p-6 mt-4">
<div className="flex items-center justify-between">
<div>

View File

@@ -1,14 +1,20 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Button, Card } from "@/shared/components";
import { useTranslations } from "next-intl";
import FallbackChainsEditor from "./FallbackChainsEditor";
import {
CLI_COMPAT_PROVIDER_DISPLAY,
CLI_COMPAT_TOGGLE_IDS,
normalizeCliCompatProviderId,
} from "@/shared/constants/cliCompatProviders";
export default function RoutingTab() {
const [settings, setSettings] = useState<any>({
alwaysPreserveClientCache: "auto",
antigravitySignatureCacheMode: "enabled",
cliCompatProviders: [],
});
const [loading, setLoading] = useState(true);
const [lkgpCacheLoading, setLkgpCacheLoading] = useState(false);
@@ -40,6 +46,26 @@ export default function RoutingTab() {
}
};
const cliCompatProviders = useMemo(
() =>
Array.isArray(settings.cliCompatProviders)
? settings.cliCompatProviders.map((providerId: string) => normalizeCliCompatProviderId(providerId))
: [],
[settings.cliCompatProviders]
);
const cliCompatProviderSet = useMemo(() => new Set(cliCompatProviders), [cliCompatProviders]);
const toggleCliCompatProvider = (providerId: string, enabled: boolean) => {
const normalizedProviderId = normalizeCliCompatProviderId(providerId);
const nextProviders = new Set(cliCompatProviders);
if (enabled) {
nextProviders.add(normalizedProviderId);
} else {
nextProviders.delete(normalizedProviderId);
}
updateSetting({ cliCompatProviders: Array.from(nextProviders) });
};
return (
<div className="flex flex-col gap-6">
<Card>
@@ -224,6 +250,62 @@ export default function RoutingTab() {
</div>
</Card>
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-500">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
fingerprint
</span>
</div>
<div>
<h3 className="text-lg font-semibold">{t("cliFingerprint")}</h3>
<p className="text-sm text-text-muted">{t("cliFingerprintDesc")}</p>
<p className="mt-1 text-xs text-text-muted">
{t("cliFingerprintEnabled", { count: cliCompatProviderSet.size })}
</p>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
{CLI_COMPAT_TOGGLE_IDS.map((providerId) => {
const normalizedProviderId = normalizeCliCompatProviderId(providerId);
const providerDisplay = CLI_COMPAT_PROVIDER_DISPLAY[providerId];
const checked = cliCompatProviderSet.has(normalizedProviderId);
const label = providerDisplay?.name || providerId;
const description = providerDisplay?.description || providerId;
return (
<button
key={providerId}
type="button"
onClick={() => toggleCliCompatProvider(providerId, !checked)}
disabled={loading}
aria-pressed={checked}
title={checked ? t("disableFingerprintTitle", { provider: label }) : t("enableFingerprintTitle", { provider: label })}
className={`flex items-start gap-3 rounded-lg border p-3 text-left transition-all ${
checked
? "border-indigo-500/50 bg-indigo-500/5 ring-1 ring-indigo-500/20"
: "border-border/50 hover:border-border hover:bg-surface/30"
} ${loading ? "cursor-not-allowed opacity-60" : ""}`}
>
<span
className={`material-symbols-outlined mt-0.5 text-[18px] ${checked ? "text-indigo-400" : "text-text-muted"}`}
aria-hidden="true"
>
{checked ? "check_circle" : "radio_button_unchecked"}
</span>
<span className="min-w-0 flex-1">
<span className={`block text-sm font-medium ${checked ? "text-indigo-400" : ""}`}>
{label}
</span>
<span className="mt-1 block text-xs text-text-muted">{description}</span>
</span>
</button>
);
})}
</div>
</Card>
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-green-500/10 text-green-500">

View File

@@ -40,6 +40,7 @@ const PROVIDER_CONFIG = {
"kimi-coding": { label: "Kimi Coding", color: "#1E3A8A" },
minimax: { label: "MiniMax", color: "#7C3AED" },
"minimax-cn": { label: "MiniMax CN", color: "#DC2626" },
nanogpt: { label: "NanoGPT", color: "#4F46E5" },
};
const TIER_FILTERS = [
@@ -301,6 +302,7 @@ export default function ProviderLimits() {
"kimi-coding": 9,
minimax: 10,
"minimax-cn": 11,
nanogpt: 12,
};
return [...filteredConnections].sort(
(a, b) => (priority[a.provider] || 9) - (priority[b.provider] || 9)

View File

@@ -289,6 +289,14 @@ export function parseQuotaData(provider, data) {
}
break;
case "nanogpt":
if (data.quotas) {
Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => {
normalizedQuotas.push(normalizeQuotaEntry(name, quota));
});
}
break;
default:
// Generic fallback for unknown providers
if (data.quotas) {

View File

@@ -106,6 +106,7 @@ async function rejectIfA2ADisabled(id: string | number | null) {
// ============ Route Handler ============
export async function POST(req: NextRequest) {
console.log("==> HIT A2A ROUTER:", req.url);
// Auth check
if (!authenticate(req)) {
return jsonRpcError(null, -32600, "Unauthorized: missing or invalid API key");
@@ -180,6 +181,7 @@ export async function POST(req: NextRequest) {
metadata: result.metadata,
});
} catch (err) {
console.error("A2A ERROR TRACE:", err);
const msg = err instanceof Error ? err.message : String(err);
tm.updateTask(task.id, "failed", [{ type: "error", content: msg }], msg);
return jsonRpcError(id, -32603, `Skill execution failed: ${msg}`);

View File

@@ -14,7 +14,7 @@ import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key (finding #258-2).
*/
export async function GET(request: Request) {
if (await isAuthRequired()) {
if (await isAuthRequired(request)) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

View File

@@ -21,7 +21,7 @@ const REQUIRED_TABLES = ["provider_connections", "provider_nodes", "combos", "ap
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key (finding #258-3).
*/
export async function POST(request: Request) {
if (await isAuthRequired()) {
if (await isAuthRequired(request)) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

View File

@@ -99,7 +99,7 @@ function tryIdeAuth(): {
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key (finding #258-4).
*/
export async function GET(request: Request) {
if (await isAuthRequired()) {
if (await isAuthRequired(request)) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

View File

@@ -11,7 +11,7 @@ import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key (finding #258-5).
*/
export async function GET(request: Request) {
if (await isAuthRequired()) {
if (await isAuthRequired(request)) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

View File

@@ -14,6 +14,7 @@ import {
} from "@/lib/localDb";
import {
SAFE_OUTBOUND_FETCH_PRESETS,
SafeOutboundFetchError,
getSafeOutboundFetchErrorStatus,
safeOutboundFetch,
} from "@/shared/network/safeOutboundFetch";
@@ -64,6 +65,7 @@ import {
} from "@/lib/providerModels/modelDiscovery";
type JsonRecord = Record<string, unknown>;
type LocalCatalogModel = { id: string; name?: string };
const antigravityDiscoveryInflight = new Map<
string,
@@ -118,6 +120,19 @@ function isNamedOpenAIStyleProvider(provider: string): boolean {
return NAMED_OPENAI_STYLE_PROVIDERS.has(provider);
}
function mergeLocalCatalogModels<T extends LocalCatalogModel, U extends LocalCatalogModel>(
registryCatalogModels: T[],
specialtyCatalogModels: U[]
): Array<T | U> {
if (registryCatalogModels.length === 0) return specialtyCatalogModels;
const registryModelIds = new Set(registryCatalogModels.map((model) => model.id));
return [
...registryCatalogModels,
...specialtyCatalogModels.filter((model) => !registryModelIds.has(model.id)),
];
}
function buildOptionalBearerHeaders(token: string | null | undefined): Record<string, string> {
return {
"Content-Type": "application/json",
@@ -402,55 +417,48 @@ export function getStaticModelsForProvider(
return staticModelsFn();
}
const specialtyModels: Array<{ id: string; name: string }> = [];
const appendModels = (models: Array<{ id: string; name?: string }>) => {
for (const model of models) {
if (specialtyModels.some((existing) => existing.id === model.id)) continue;
specialtyModels.push({
id: model.id,
name: model.name || model.id,
});
}
};
const embeddingProvider = getEmbeddingProvider(provider);
if (embeddingProvider) {
return embeddingProvider.models.map((model) => ({
id: model.id,
name: model.name || model.id,
}));
appendModels(embeddingProvider.models);
}
const rerankProvider = getRerankProvider(provider);
if (rerankProvider) {
return rerankProvider.models.map((model) => ({
id: model.id,
name: model.name || model.id,
}));
appendModels(rerankProvider.models);
}
const imageProvider = getImageProvider(provider);
if (imageProvider) {
return imageProvider.models.map((model) => ({
id: model.id,
name: model.name || model.id,
}));
appendModels(imageProvider.models);
}
const videoProvider = getVideoProvider(provider);
if (videoProvider) {
return videoProvider.models.map((model) => ({
id: model.id,
name: model.name || model.id,
}));
appendModels(videoProvider.models);
}
const speechProvider = getSpeechProvider(provider);
if (speechProvider) {
return speechProvider.models.map((model) => ({
id: model.id,
name: model.name || model.id,
}));
appendModels(speechProvider.models);
}
const transcriptionProvider = getTranscriptionProvider(provider);
if (transcriptionProvider) {
return transcriptionProvider.models.map((model) => ({
id: model.id,
name: model.name || model.id,
}));
appendModels(transcriptionProvider.models);
}
return undefined;
return specialtyModels.length > 0 ? specialtyModels : undefined;
}
// Provider models endpoints configuration
@@ -825,9 +833,8 @@ export async function GET(
const specialtyCatalogModels = getStaticModelsForProvider(provider) || [];
const toLocalCatalogModels = () => {
const localCatalog =
registryCatalogModels.length > 0 ? registryCatalogModels : specialtyCatalogModels;
return localCatalog.map((model: any) => ({
const localCatalog = mergeLocalCatalogModels(registryCatalogModels, specialtyCatalogModels);
return localCatalog.map((model) => ({
id: model.id,
name: model.name || model.id,
...(registryCatalogModels.length > 0 ? { owned_by: provider } : {}),
@@ -1755,13 +1762,12 @@ export async function GET(
});
}
const localCatalog =
registryCatalogModels.length > 0 ? registryCatalogModels : specialtyCatalogModels;
const localCatalog = mergeLocalCatalogModels(registryCatalogModels, specialtyCatalogModels);
if (!config && localCatalog.length > 0) {
return buildResponse({
provider,
connectionId,
models: localCatalog.map((m: any) => ({
models: localCatalog.map((m) => ({
id: m.id,
name: m.name || m.id,
...(registryCatalogModels.length > 0 ? { owned_by: provider } : {}),
@@ -1895,6 +1901,10 @@ export async function GET(
return buildApiDiscoveryResponse(allModels);
} catch (error) {
if (error instanceof SafeOutboundFetchError && error.code === "URL_GUARD_BLOCKED") {
return NextResponse.json({ error: error.message }, { status: 400 });
}
const status = getSafeOutboundFetchErrorStatus(error);
if (status) {
const message = error instanceof Error ? error.message : "Failed to fetch models";

View File

@@ -124,10 +124,12 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
rateLimitedUntil,
lastTested,
healthCheckInterval,
group,
maxConcurrent,
providerSpecificData: incomingPsd,
} = body;
const existing = await getProviderConnectionById(id);
const existing = (await getProviderConnectionById(id)) as Record<string, any> | null;
if (!existing) {
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
}
@@ -148,6 +150,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
if (rateLimitedUntil !== undefined) updateData.rateLimitedUntil = rateLimitedUntil;
if (lastTested !== undefined) updateData.lastTested = lastTested;
if (healthCheckInterval !== undefined) updateData.healthCheckInterval = healthCheckInterval;
if (group !== undefined) updateData.group = group;
if (maxConcurrent !== undefined) updateData.maxConcurrent = maxConcurrent;
// Merge providerSpecificData (partial update — preserve existing keys not sent by caller)
if (incomingPsd !== undefined && incomingPsd !== null && typeof incomingPsd === "object") {
@@ -240,7 +244,7 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
const { id } = await params;
// Fetch connection before deleting to check provider type
const connection = await getProviderConnectionById(id);
const connection = (await getProviderConnectionById(id)) as Record<string, any> | null;
if (!connection) {
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
}

View File

@@ -13,7 +13,7 @@ import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
* Exports a legacy 9router compatible JSON backup.
*/
export async function GET(request: Request) {
if (await isAuthRequired()) {
if (await isAuthRequired(request)) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
@@ -41,8 +41,8 @@ export async function GET(request: Request) {
// Metadata to identify export version
_meta: {
exportedAt: new Date().toISOString(),
version: "omniroute-v3-legacy-export"
}
version: "omniroute-v3-legacy-export",
},
};
return new NextResponse(JSON.stringify(exportData, null, 2), {

View File

@@ -15,7 +15,7 @@ import { runJsonMigration, type LegacyJsonData } from "@/lib/db/jsonMigration";
* 🔒 A pre-import backup is created automatically before any data is written.
*/
export async function POST(request: Request) {
if (await isAuthRequired()) {
if (await isAuthRequired(request)) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
@@ -44,7 +44,9 @@ export async function POST(request: Request) {
data = JSON.parse(rawText) as LegacyJsonData;
} catch {
return NextResponse.json(
{ error: "Invalid JSON: the file could not be parsed. Please upload a valid .json backup." },
{
error: "Invalid JSON: the file could not be parsed. Please upload a valid .json backup.",
},
{ status: 400 }
);
}

View File

@@ -0,0 +1,44 @@
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { rotateOneproxyProxy } from "@/lib/oneproxyRotator";
import { oneproxyRotateSchema } from "@/shared/validation/oneproxySchemas";
export async function POST(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
rawBody = {};
}
try {
const validation = validateBody(oneproxyRotateSchema, rawBody);
if (isValidationFailure(validation)) {
return createErrorResponse({
status: 400,
message: validation.error.message,
type: "invalid_request",
});
}
const proxy = await rotateOneproxyProxy({
strategy: validation.data.strategy,
});
if (!proxy) {
return createErrorResponse({
status: 404,
message: "No active 1proxy proxies available",
type: "not_found",
});
}
return Response.json(proxy);
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to rotate 1proxy proxy");
}
}

View File

@@ -0,0 +1,126 @@
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import {
listOneproxyProxies,
getOneproxyStats,
deleteOneproxyProxy,
clearAllOneproxyProxies,
} from "@/lib/localDb";
import {
syncOneproxyProxies,
getOneproxySyncStatus,
resetOneproxyCircuitBreaker,
} from "@/lib/oneproxySync";
import { oneproxyFilterSchema, oneproxySyncSchema } from "@/shared/validation/oneproxySchemas";
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { searchParams } = new URL(request.url);
const action = searchParams.get("action");
if (action === "stats") {
const stats = await getOneproxyStats();
const status = getOneproxySyncStatus();
return Response.json({ stats, status });
}
if (action === "status") {
return Response.json(getOneproxySyncStatus());
}
const filterValidation = validateBody(oneproxyFilterSchema, {
protocol: searchParams.get("protocol") || undefined,
countryCode: searchParams.get("countryCode") || undefined,
minQuality: searchParams.get("minQuality") || undefined,
limit: searchParams.get("limit") || undefined,
});
if (isValidationFailure(filterValidation)) {
return createErrorResponse({
status: 400,
message: filterValidation.error.message,
type: "invalid_request",
});
}
const proxies = await listOneproxyProxies({
protocol: filterValidation.data.protocol,
countryCode: filterValidation.data.countryCode,
minQuality: filterValidation.data.minQuality,
limit: filterValidation.data.limit,
});
return Response.json({ items: proxies, total: proxies.length });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to load 1proxy proxies");
}
}
export async function POST(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return createErrorResponse({
status: 400,
message: "Invalid JSON body",
type: "invalid_request",
});
}
try {
const validation = validateBody(oneproxySyncSchema, rawBody);
if (isValidationFailure(validation)) {
return createErrorResponse({
status: 400,
message: validation.error.message,
type: "invalid_request",
});
}
const result = await syncOneproxyProxies();
return Response.json(result);
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to sync 1proxy proxies");
}
}
export async function DELETE(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
const clearAll = searchParams.get("clearAll") === "1";
if (clearAll) {
const count = await clearAllOneproxyProxies();
return Response.json({ success: true, deleted: count });
}
if (!id) {
return createErrorResponse({
status: 400,
message: "id or clearAll is required",
type: "invalid_request",
});
}
const deleted = await deleteOneproxyProxy(id);
if (!deleted) {
return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" });
}
return Response.json({ success: true });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to delete 1proxy proxy");
}
}

View File

@@ -97,7 +97,7 @@ async function getModelCatalogAuthRejection(
settings: Record<string, any>,
headers: Record<string, string>
): Promise<Response | null> {
if (settings.requireAuthForModels !== true || !(await isAuthRequired())) return null;
if (settings.requireAuthForModels !== true || !(await isAuthRequired(request))) return null;
const bearer = extractBearer(request.headers);
if (bearer) {

View File

@@ -4669,6 +4669,7 @@
"flowDiagramCli": "CLI Agent",
"flowDiagramCliDesc": "Processes with own auth/model",
"fingerprintSettingsHint": "CLI Fingerprint matching (disguise requests as specific CLI tools) can be configured in",
"settingsRoutingLink": "Settings/Routing",
"openSettings": "Settings"
},
"templateNames": {

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