mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
chore: merge release/v3.8.5 into main (post-v3.8.5 hotfixes + community PRs)
This commit is contained in:
@@ -37,6 +37,8 @@ INITIAL_PASSWORD=CHANGEME
|
||||
# Base directory for all persistent data (SQLite DB, logs, backups).
|
||||
# Used by: src/lib/db/core.ts — resolves the SQLite database file path.
|
||||
# Default: ~/.omniroute/ | Override for Docker or custom installations.
|
||||
# Hint: When running in Docker, consider mounting a host directory here for data persistence across container restarts
|
||||
# also if you want to share the same database as "npm run dev" use "./data"
|
||||
# DATA_DIR=/var/lib/omniroute
|
||||
|
||||
# Encryption key for SQLite database encryption at rest.
|
||||
|
||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -15,15 +15,25 @@
|
||||
|
||||
- **docker:** rebuild `better-sqlite3` native bindings after hardened install to resolve container startup crash (#2772 — thanks @thanet-s)
|
||||
- **combos:** make combo target timeout configurable, inheriting resolved request timeout by default and clamping values so they only shorten fallback latency (#2775 — thanks @rdself)
|
||||
- **oauth:** use public callbacks for remote Google OAuth with custom creds (#2787 — thanks @akarray)
|
||||
- **combos:** allow rate-limited provider connections after transient 429s (#2786 — thanks @JxnLexn)
|
||||
- **logs:** keep database log settings in sync with the pipeline toggle (#2785 — thanks @JxnLexn)
|
||||
- **docker:** speedup docker creation by reducing steps and bunch up copy operations (#2784 — thanks @hartmark)
|
||||
- **codex:** apply global service tiers to combo request bodies (#2783 — thanks @JxnLexn)
|
||||
|
||||
### ⚡ Performance / CI
|
||||
|
||||
- **ci:** build Docker platforms on native runners (linux/amd64 on ubuntu-24.04 and linux/arm64 on ubuntu-24.04-arm) instead of emulated QEMU, reducing build times significantly (#2774 — thanks @thanet-s)
|
||||
|
||||
### 📝 Documentation
|
||||
|
||||
- **docs:** fix broken documentation links in README after Fumadocs migration (#2782 — thanks @kjhq)
|
||||
|
||||
### 🏆 Hall of Contributors
|
||||
|
||||
A special thanks to everyone who contributed code, reviews, and tests for this release:
|
||||
@hartmark, @hijak, @rdself, @thanet-s
|
||||
@akarray, @hartmark, @hijak, @JxnLexn, @kjhq, @rdself, @thanet-s
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -232,18 +232,25 @@ tests/
|
||||
├── translator/ # Translator-specific tests
|
||||
└── load/ # Load tests
|
||||
|
||||
docs/ # Documentation
|
||||
├── ARCHITECTURE.md # System architecture
|
||||
├── API_REFERENCE.md # All endpoints
|
||||
├── USER_GUIDE.md # Provider setup, CLI integration
|
||||
├── TROUBLESHOOTING.md # Common issues
|
||||
├── MCP-SERVER.md # MCP server (25 tools)
|
||||
├── A2A-SERVER.md # A2A agent protocol
|
||||
├── AUTO-COMBO.md # Auto-combo engine
|
||||
├── CLI-TOOLS.md # CLI tools integration
|
||||
├── COVERAGE_PLAN.md # Test coverage improvement plan
|
||||
├── openapi.yaml # OpenAPI specification
|
||||
└── adr/ # Architecture Decision Records
|
||||
docs/
|
||||
├── adr/ # Architecture Decision Records
|
||||
├── architecture/ # System architecture & resilience
|
||||
├── comparison/ # OmniRoute vs alternatives
|
||||
├── compression/ # Compression guides & rules
|
||||
├── dev/ # Development guides
|
||||
├── diagrams/ # Architecture diagrams
|
||||
├── frameworks/ # MCP, A2A, OpenCode, Memory, Skills
|
||||
├── guides/ # User guide, Docker, setup, troubleshooting
|
||||
├── i18n/ # Internationalized README translations
|
||||
├── marketing/ # Marketing materials
|
||||
├── ops/ # Deployment, proxy, coverage, releases
|
||||
├── providers/ # Provider-specific docs
|
||||
├── reference/ # API reference, env vars, CLI tools, free tiers
|
||||
├── releases/ # Release notes
|
||||
├── routing/ # Auto-combo engine, reasoning replay
|
||||
├── screenshots/ # Dashboard screenshots
|
||||
├── security/ # Guardrails, compliance, stealth, tokens
|
||||
└── specs/ # Design specs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
51
Dockerfile
51
Dockerfile
@@ -1,10 +1,22 @@
|
||||
FROM node:24-trixie-slim AS builder
|
||||
# ── Common base with runtime deps ──────────────────────────────────────────
|
||||
FROM node:24-trixie-slim AS base
|
||||
WORKDIR /app
|
||||
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=shared \
|
||||
--mount=type=cache,target=/var/lib/apt/lists,sharing=shared \
|
||||
apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates python3 make g++ \
|
||||
&& apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── Builder ────────────────────────────────────────────────────────────────
|
||||
FROM base AS builder
|
||||
|
||||
# Build tools for native module compilation
|
||||
# apt-get update needed here because base's rm -rf clears the shared cache
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=shared \
|
||||
--mount=type=cache,target=/var/lib/apt/lists,sharing=shared \
|
||||
apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package*.json ./
|
||||
@@ -26,12 +38,15 @@ RUN --mount=type=cache,target=/root/.npm \
|
||||
&& npm rebuild better-sqlite3 \
|
||||
&& node -e "require('better-sqlite3')(':memory:').close()"
|
||||
|
||||
# Use Turbopack for significant build speedup
|
||||
ENV OMNIROUTE_USE_TURBOPACK=1
|
||||
|
||||
COPY . ./
|
||||
RUN --mount=type=cache,target=/app/.next/cache \
|
||||
mkdir -p /app/data && npm run build -- --webpack
|
||||
mkdir -p /app/data && npm run build
|
||||
|
||||
FROM node:24-trixie-slim AS runner-base
|
||||
WORKDIR /app
|
||||
# ── Runner base ────────────────────────────────────────────────────────────
|
||||
FROM base AS runner-base
|
||||
|
||||
LABEL org.opencontainers.image.title="omniroute" \
|
||||
org.opencontainers.image.description="Unified AI proxy — route any LLM through one endpoint" \
|
||||
@@ -46,15 +61,11 @@ ENV NODE_OPTIONS="--max-old-space-size=256"
|
||||
|
||||
# Data directory inside Docker — must match the volume mount in docker-compose.yml
|
||||
ENV DATA_DIR=/app/data
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
|
||||
apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
# The standalone build + syncStandaloneExtraModules bundles all runtime files
|
||||
# (.next, node_modules, migrations, scripts, docs, etc.) into .next/standalone/.
|
||||
# Explicit overrides below cover modules that NFT tracing may miss.
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
# Explicitly copy @swc/helpers — not always traced by standalone output but needed at runtime
|
||||
COPY --from=builder /app/node_modules/@swc/helpers ./node_modules/@swc/helpers
|
||||
@@ -70,18 +81,6 @@ COPY --from=builder /app/node_modules/split2 ./node_modules/split2
|
||||
# traced by Next.js standalone output — copy them explicitly.
|
||||
COPY --from=builder /app/src/lib/db/migrations ./migrations
|
||||
ENV OMNIROUTE_MIGRATIONS_DIR=/app/migrations
|
||||
# MITM server.cjs is spawned at runtime via child_process — not traced by nft
|
||||
COPY --from=builder /app/src/mitm/server.cjs ./src/mitm/server.cjs
|
||||
# Runtime docs are pruned by .dockerignore to English markdown + OpenAPI.
|
||||
# Next.js standalone tracing does not include docs read via fs.
|
||||
COPY --from=builder /app/.next/standalone/docs ./docs
|
||||
|
||||
COPY --from=builder /app/scripts/dev/run-standalone.mjs ./dev/run-standalone.mjs
|
||||
COPY --from=builder /app/scripts/build/runtime-env.mjs ./build/runtime-env.mjs
|
||||
COPY --from=builder /app/scripts/build/bootstrap-env.mjs ./build/bootstrap-env.mjs
|
||||
COPY --from=builder /app/scripts/dev/healthcheck.mjs ./healthcheck.mjs
|
||||
|
||||
RUN node -e "require('better-sqlite3')(':memory:').close()"
|
||||
|
||||
# Hand /app over to the baked-in `node` non-root user (UID/GID 1000) so the
|
||||
# runtime process never holds root privileges. The chown happens after all
|
||||
|
||||
76
README.md
76
README.md
@@ -275,7 +275,7 @@ Result: 4 layers of fallback = zero downtime
|
||||
<b>+ also works with</b> · Cline · Antigravity · Windsurf · AMP · Hermes · Qwen CLI · Roo · Continue · <b>any OpenAI-compatible tool</b>
|
||||
</div>
|
||||
|
||||
<sub>📖 Per-tool setup for all 16+ tools → [`docs/CLI-TOOLS.md`](docs/CLI-TOOLS.md) · 🧩 OpenCode plugin → [`@omniroute/opencode-provider`](https://www.npmjs.com/package/@omniroute/opencode-provider)</sub>
|
||||
<sub>📖 Per-tool setup for all 16+ tools → [`docs/reference/CLI-TOOLS.md`](docs/reference/CLI-TOOLS.md) · 🧩 OpenCode plugin → [`@omniroute/opencode-provider`](https://www.npmjs.com/package/@omniroute/opencode-provider)</sub>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -332,7 +332,7 @@ Result: 4 layers of fallback = zero downtime
|
||||
| 🧩 **OpenCode plugin** | `@omniroute/opencode-provider` | Native OpenCode integration |
|
||||
| 🛠️ **From source** | `npm install && npm run dev` | Hack on it, contribute |
|
||||
|
||||
<sub>📖 [Docker Guide](docs/DOCKER_GUIDE.md) · [Desktop](electron/README.md) · [Termux](docs/TERMUX_GUIDE.md) · [PWA](docs/PWA_GUIDE.md) · [OpenCode](docs/frameworks/OPENCODE.md)</sub>
|
||||
<sub>📖 [Docker Guide](docs/guides/DOCKER_GUIDE.md) · [Desktop](electron/README.md) · [Termux](docs/guides/TERMUX_GUIDE.md) · [PWA](docs/guides/PWA_GUIDE.md) · [OpenCode](docs/frameworks/OPENCODE.md)</sub>
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -440,7 +440,7 @@ range = 78.4 – 94.6%
|
||||
|
||||
Code blocks, URLs, JSON and structured data are **always protected** by the preservation engine. Auto-trigger compression by token threshold, or assign a compression pipeline per routing combo.
|
||||
|
||||
📖 [`COMPRESSION_GUIDE.md`](docs/COMPRESSION_GUIDE.md) · [`RTK_COMPRESSION.md`](docs/RTK_COMPRESSION.md) · [`COMPRESSION_ENGINES.md`](docs/COMPRESSION_ENGINES.md)
|
||||
📖 [`COMPRESSION_GUIDE.md`](docs/compression/COMPRESSION_GUIDE.md) · [`RTK_COMPRESSION.md`](docs/compression/RTK_COMPRESSION.md) · [`COMPRESSION_ENGINES.md`](docs/compression/COMPRESSION_ENGINES.md)
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -509,7 +509,7 @@ pnpm install -g omniroute && pnpm approve-builds -g && omniroute
|
||||
yay -S omniroute-bin && systemctl --user enable --now omniroute.service
|
||||
```
|
||||
|
||||
📖 [Docker Guide](docs/DOCKER_GUIDE.md) — Compose profiles, Caddy HTTPS, Cloudflare tunnels.
|
||||
📖 [Docker Guide](docs/guides/DOCKER_GUIDE.md) — Compose profiles, Caddy HTTPS, Cloudflare tunnels.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -580,7 +580,7 @@ yay -S omniroute-bin && systemctl --user enable --now omniroute.service
|
||||
|
||||
> 💡 The dashboard "cost" is a **savings tracker**, not a bill — OmniRoute never charges you. A "$290 total cost" using free models means **$290 saved**.
|
||||
|
||||
📖 Complete free directory → [`docs/FREE_TIERS.md`](docs/FREE_TIERS.md) — 25+ providers, quotas, base URLs.
|
||||
📖 Complete free directory → [`docs/reference/FREE_TIERS.md`](docs/reference/FREE_TIERS.md) — 25+ providers, quotas, base URLs.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -616,7 +616,7 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
|
||||
- **🆓 1proxy marketplace** — hundreds of free validated proxies, quality scores, auto-rotation
|
||||
- **Anti-detection** — TLS fingerprint spoofing (`wreq-js`), CLI fingerprint matching, proxy IP preservation
|
||||
|
||||
📖 [`docs/PROXY_GUIDE.md`](docs/PROXY_GUIDE.md)
|
||||
📖 [`docs/ops/PROXY_GUIDE.md`](docs/ops/PROXY_GUIDE.md)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -631,7 +631,7 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
|
||||
**Quality & Ops:** built-in **Evals** (golden-set: exact/contains/regex/custom) · guardrails (PII, injection, vision) · health dashboard · p50/p95/p99 telemetry · webhooks · compliance audit.
|
||||
**AI Agent Skills:** drop-in markdown manifests — point any agent at `skills/omniroute/SKILL.md`. 10 skills available.
|
||||
|
||||
📖 [MCP Server](open-sse/mcp-server/README.md) · [A2A Server](src/lib/a2a/README.md) · [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md) · [Features Gallery](docs/FEATURES.md)
|
||||
📖 [MCP Server](open-sse/mcp-server/README.md) · [A2A Server](src/lib/a2a/README.md) · [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md) · [Features Gallery](docs/guides/FEATURES.md)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -651,7 +651,7 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
|
||||
**Will compression hurt quality?** No — it only compresses the **input**; code, URLs, JSON are always protected.
|
||||
**Does it work where AI is blocked?** Yes — 3-level proxy + 1proxy marketplace reach all 177 providers.
|
||||
|
||||
📖 [User Guide](docs/USER_GUIDE.md) · [API Reference](docs/API_REFERENCE.md) · [Environment Config](docs/ENVIRONMENT.md)
|
||||
📖 [User Guide](docs/guides/USER_GUIDE.md) · [API Reference](docs/reference/API_REFERENCE.md) · [Environment Config](docs/reference/ENVIRONMENT.md)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -669,7 +669,7 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
|
||||
| Docker SQLite locks | Use `--stop-timeout 40` for clean WAL checkpoint |
|
||||
| Node runtime errors | Use Node `>=20.20.2 <21`, `>=22.22.2 <23`, or `>=24 <25` |
|
||||
|
||||
🐛 **Reporting a bug?** Run `npm run system-info` and attach `system-info.txt`. 📖 [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md)
|
||||
🐛 **Reporting a bug?** Run `npm run system-info` and attach `system-info.txt`. 📖 [`docs/guides/TROUBLESHOOTING.md`](docs/guides/TROUBLESHOOTING.md)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -740,50 +740,50 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
|
||||
|
||||
| Document | Description |
|
||||
| ------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment |
|
||||
| [Setup Guide](docs/SETUP_GUIDE.md) | Full install methods, CLI tool configs, protocol setup, timeout tuning |
|
||||
| [CLI Tools Guide](docs/CLI-TOOLS.md) | Per-tool setup for Claude Code, Codex, Cursor, Cline, OpenClaw, Kilo, Copilot |
|
||||
| [User Guide](docs/guides/USER_GUIDE.md) | Providers, combos, CLI integration, deployment |
|
||||
| [Setup Guide](docs/guides/SETUP_GUIDE.md) | Full install methods, CLI tool configs, protocol setup, timeout tuning |
|
||||
| [CLI Tools Guide](docs/reference/CLI-TOOLS.md) | Per-tool setup for Claude Code, Codex, Cursor, Cline, OpenClaw, Kilo, Copilot |
|
||||
| [Quick Start](README.md#-quick-start) | 3-step install → connect → configure |
|
||||
|
||||
### 🔧 Operations & Deployment
|
||||
|
||||
| Document | Description |
|
||||
| ---------------------------------------------------- | -------------------------------------------------------------- |
|
||||
| [Docker Guide](docs/DOCKER_GUIDE.md) | Docker run, Compose profiles, Caddy HTTPS, tunnels, image tags |
|
||||
| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup |
|
||||
| [Fly.io Deployment](docs/FLY_IO_DEPLOYMENT_GUIDE.md) | Deploy to Fly.io with persistent storage |
|
||||
| [Termux Guide](docs/TERMUX_GUIDE.md) | Run OmniRoute on Android via Termux |
|
||||
| [PWA Guide](docs/PWA_GUIDE.md) | Progressive Web App install, caching, architecture |
|
||||
| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods |
|
||||
| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references |
|
||||
| [Docker Guide](docs/guides/DOCKER_GUIDE.md) | Docker run, Compose profiles, Caddy HTTPS, tunnels, image tags |
|
||||
| [VM Deployment](docs/ops/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup |
|
||||
| [Fly.io Deployment](docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md) | Deploy to Fly.io with persistent storage |
|
||||
| [Termux Guide](docs/guides/TERMUX_GUIDE.md) | Run OmniRoute on Android via Termux |
|
||||
| [PWA Guide](docs/guides/PWA_GUIDE.md) | Progressive Web App install, caching, architecture |
|
||||
| [Uninstall Guide](docs/guides/UNINSTALL.md) | Clean removal for all install methods |
|
||||
| [Environment Config](docs/reference/ENVIRONMENT.md) | Complete `.env` variables and references |
|
||||
|
||||
### 🧠 Features & Architecture
|
||||
|
||||
| Document | Description |
|
||||
| ---------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| [Architecture](docs/ARCHITECTURE.md) | System architecture, data flow, and internals |
|
||||
| [Compression Guide](docs/COMPRESSION_GUIDE.md) | 7-option pipeline: off / lite / standard / aggressive / ultra / RTK / stacked |
|
||||
| [RTK Compression](docs/RTK_COMPRESSION.md) | Command-output compression, filters, trust, verify, raw-output recovery |
|
||||
| [Compression Engines](docs/COMPRESSION_ENGINES.md) | Caveman, RTK, stacked pipelines, dashboard/API/MCP surfaces |
|
||||
| [Compression Rules Format](docs/COMPRESSION_RULES_FORMAT.md) | JSON rule-pack schemas for Caveman and RTK filters |
|
||||
| [Compression Language Packs](docs/COMPRESSION_LANGUAGE_PACKS.md) | Language detection and Caveman rule-pack authoring |
|
||||
| [Resilience Guide](docs/RESILIENCE_GUIDE.md) | Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing |
|
||||
| [Auto-Combo Engine](docs/AUTO-COMBO.md) | 6-factor scoring, mode packs, self-healing |
|
||||
| [Proxy Guide](docs/PROXY_GUIDE.md) | 3-level proxy system, 1proxy marketplace, registry CRUD |
|
||||
| [Free Tiers](docs/FREE_TIERS.md) | 25+ free API providers consolidated directory |
|
||||
| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots |
|
||||
| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough |
|
||||
| [Architecture](docs/architecture/ARCHITECTURE.md) | System architecture, data flow, and internals |
|
||||
| [Compression Guide](docs/compression/COMPRESSION_GUIDE.md) | 7-option pipeline: off / lite / standard / aggressive / ultra / RTK / stacked |
|
||||
| [RTK Compression](docs/compression/RTK_COMPRESSION.md) | Command-output compression, filters, trust, verify, raw-output recovery |
|
||||
| [Compression Engines](docs/compression/COMPRESSION_ENGINES.md) | Caveman, RTK, stacked pipelines, dashboard/API/MCP surfaces |
|
||||
| [Compression Rules Format](docs/compression/COMPRESSION_RULES_FORMAT.md) | JSON rule-pack schemas for Caveman and RTK filters |
|
||||
| [Compression Language Packs](docs/compression/COMPRESSION_LANGUAGE_PACKS.md) | Language detection and Caveman rule-pack authoring |
|
||||
| [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md) | Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing |
|
||||
| [Auto-Combo Engine](docs/routing/AUTO-COMBO.md) | 6-factor scoring, mode packs, self-healing |
|
||||
| [Proxy Guide](docs/ops/PROXY_GUIDE.md) | 3-level proxy system, 1proxy marketplace, registry CRUD |
|
||||
| [Free Tiers](docs/reference/FREE_TIERS.md) | 25+ free API providers consolidated directory |
|
||||
| [Features Gallery](docs/guides/FEATURES.md) | Visual dashboard tour with screenshots |
|
||||
| [Codebase Documentation](docs/architecture/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough |
|
||||
|
||||
### 🤖 Protocols & APIs
|
||||
|
||||
| Document | Description |
|
||||
| ------------------------------------------- | --------------------------------------------------- |
|
||||
| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples |
|
||||
| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification |
|
||||
| [API Reference](docs/reference/API_REFERENCE.md) | All endpoints with examples |
|
||||
| [OpenAPI Spec](docs/reference/openapi.yaml) | OpenAPI 3.0 specification |
|
||||
| [MCP Server](open-sse/mcp-server/README.md) | 29 MCP tools, IDE configs, Python/TS/Go clients |
|
||||
| [MCP Server Guide](docs/MCP-SERVER.md) | MCP installation, transports, and tool reference |
|
||||
| [MCP Server Guide](docs/frameworks/MCP-SERVER.md) | MCP installation, transports, and tool reference |
|
||||
| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt |
|
||||
| [A2A Server Guide](docs/A2A-SERVER.md) | A2A agent card, tasks, skills, and streaming |
|
||||
| [A2A Server Guide](docs/frameworks/A2A-SERVER.md) | A2A agent card, tasks, skills, and streaming |
|
||||
|
||||
### 📋 Project & Quality
|
||||
|
||||
@@ -791,9 +791,9 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
|
||||
| ---------------------------------------------- | ----------------------------------------------- |
|
||||
| [Contributing](CONTRIBUTING.md) | Development setup and guidelines |
|
||||
| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices |
|
||||
| [i18n Guide](docs/I18N.md) | 40+ language support, translation workflow, RTL |
|
||||
| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps |
|
||||
| [Coverage Plan](docs/COVERAGE_PLAN.md) | Test coverage strategy and 4,690+ test suite |
|
||||
| [i18n Guide](docs/guides/I18N.md) | 40+ language support, translation workflow, RTL |
|
||||
| [Release Checklist](docs/ops/RELEASE_CHECKLIST.md) | Pre-release validation steps |
|
||||
| [Coverage Plan](docs/ops/COVERAGE_PLAN.md) | Test coverage strategy and 4,690+ test suite |
|
||||
|
||||
<br/>
|
||||
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
# OmniRoute Auto-Combo Engine
|
||||
|
||||
> Self-managing model chains with adaptive scoring + zero-config auto-routing
|
||||
|
||||
## Zero-Config Auto-Routing (`auto/` prefix)
|
||||
|
||||
> **NEW:** No combo creation required. Use `auto/` prefix directly in any client.
|
||||
|
||||
### Quick Examples
|
||||
|
||||
| Model ID | Variant | Behavior |
|
||||
| -------------- | ------- | ------------------------------------------------------------------------ |
|
||||
| `auto` | default | All connected providers, LKGP strategy, balanced weights |
|
||||
| `auto/coding` | coding | Quality-first weights, suitable for code generation |
|
||||
| `auto/fast` | fast | Low-latency weighted selection |
|
||||
| `auto/cheap` | cheap | Cost-optimized routing (lowest cost first) |
|
||||
| `auto/offline` | offline | Favors providers with highest quota availability |
|
||||
| `auto/smart` | smart | Quality-first + higher exploration rate (10%) for better model discovery |
|
||||
| `auto/lkgp` | lkgp | Explicit LKGP (same as default `auto`) |
|
||||
|
||||
**How to use:**
|
||||
|
||||
```bash
|
||||
# Any IDE or CLI tool that supports OpenAI format
|
||||
Base URL: http://localhost:20128/v1
|
||||
API Key: <your-endpoint-key>
|
||||
|
||||
# In your code/config, set model to:
|
||||
model: "auto" # balanced default
|
||||
model: "auto/coding" # best for coding tasks
|
||||
model: "auto/fast" # fastest available
|
||||
model: "auto/cheap" # cheapest per token
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. OmniRoute detects `auto/` prefix in `src/sse/handlers/chat.ts`
|
||||
2. Queries all **active provider connections** from the database
|
||||
3. Filters to those with valid credentials (API key or OAuth token)
|
||||
4. Determines the model per connection (`connection.defaultModel` or provider's first model)
|
||||
5. Builds a **virtual combo** in-memory (not stored in DB)
|
||||
6. Routes using the selected variant's weight profile + LKGP strategy
|
||||
|
||||
**Key properties:**
|
||||
|
||||
- ✅ **Always-on:** No toggle, no combo creation, no configuration needed
|
||||
- ✅ **Dynamic:** Reflects current connected providers automatically
|
||||
- ✅ **Session stickiness:** LKGP ensures last successful provider is prioritized
|
||||
- ✅ **Multi-account aware:** Each provider connection becomes a separate candidate
|
||||
- ✅ **No DB writes:** Virtual combo exists only for the request, zero persistence overhead
|
||||
|
||||
**Behind the scenes:**
|
||||
|
||||
```txt
|
||||
Request: { model: "auto/coding" }
|
||||
↓
|
||||
src/sse/handlers/chat.ts detects prefix
|
||||
↓
|
||||
createVirtualAutoCombo('coding') → candidatePool from active connections
|
||||
↓
|
||||
handleComboChat (same engine as persisted combos)
|
||||
↓
|
||||
Auto-scoring selects best provider/model per request
|
||||
```
|
||||
|
||||
**Implementation files:**
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------------------------------------------- | ----------------------------------------- |
|
||||
| `open-sse/services/autoCombo/autoPrefix.ts` | Prefix parser (`parseAutoPrefix`) |
|
||||
| `open-sse/services/autoCombo/virtualFactory.ts` | Creates virtual `AutoComboConfig` objects |
|
||||
| `open-sse/services/autoCombo/providerRegistryAccessor.ts` | Test hook for mocking provider registry |
|
||||
| `src/sse/handlers/chat.ts` | Integration: auto prefix short-circuit |
|
||||
| `src/shared/constants/providers.ts` | `SYSTEM_PROVIDERS.auto` system entry |
|
||||
|
||||
## How It Works (Persisted Auto-Combos)
|
||||
|
||||
The Auto-Combo Engine dynamically selects the best provider/model for each request using a **6-factor scoring function**:
|
||||
|
||||
| Factor | Weight | Description |
|
||||
| :--------- | :----- | :---------------------------------------------- |
|
||||
| Quota | 0.20 | Remaining capacity [0..1] |
|
||||
| Health | 0.25 | Circuit breaker: CLOSED=1.0, HALF=0.5, OPEN=0.0 |
|
||||
| CostInv | 0.20 | Inverse cost (cheaper = higher score) |
|
||||
| LatencyInv | 0.15 | Inverse p95 latency (faster = higher) |
|
||||
| TaskFit | 0.10 | Model × task type fitness score |
|
||||
| Stability | 0.10 | Low variance in latency/errors |
|
||||
|
||||
## Mode Packs
|
||||
|
||||
| Pack | Focus | Key Weight |
|
||||
| :---------------------- | :----------- | :--------------- |
|
||||
| 🚀 **Ship Fast** | Speed | latencyInv: 0.35 |
|
||||
| 💰 **Cost Saver** | Economy | costInv: 0.40 |
|
||||
| 🎯 **Quality First** | Best model | taskFit: 0.40 |
|
||||
| 📡 **Offline Friendly** | Availability | quota: 0.40 |
|
||||
|
||||
## Self-Healing
|
||||
|
||||
- **Temporary exclusion**: Score < 0.2 → excluded for 5 min (progressive backoff, max 30 min)
|
||||
- **Circuit breaker awareness**: OPEN → auto-excluded; HALF_OPEN → probe requests
|
||||
- **Incident mode**: >50% OPEN → disable exploration, maximize stability
|
||||
- **Cooldown recovery**: After exclusion, first request is a "probe" with reduced timeout
|
||||
|
||||
## Bandit Exploration
|
||||
|
||||
5% of requests (configurable) are routed to random providers for exploration. Disabled in incident mode.
|
||||
|
||||
## API
|
||||
|
||||
```bash
|
||||
# Create auto-combo
|
||||
curl -X POST http://localhost:20128/api/combos/auto \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"id":"my-auto","name":"Auto Coder","candidatePool":["anthropic","google","openai"],"modePack":"ship-fast"}'
|
||||
|
||||
# List auto-combos
|
||||
curl http://localhost:20128/api/combos/auto
|
||||
```
|
||||
|
||||
## Task Fitness
|
||||
|
||||
30+ models scored across 6 task types (`coding`, `review`, `planning`, `analysis`, `debugging`, `documentation`). Supports wildcard patterns (e.g., `*-coder` → high coding score).
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
| :------------------------------------------- | :------------------------------------ |
|
||||
| `open-sse/services/autoCombo/scoring.ts` | Scoring function & pool normalization |
|
||||
| `open-sse/services/autoCombo/taskFitness.ts` | Model × task fitness lookup |
|
||||
| `open-sse/services/autoCombo/engine.ts` | Selection logic, bandit, budget cap |
|
||||
| `open-sse/services/autoCombo/selfHealing.ts` | Exclusion, probes, incident mode |
|
||||
| `open-sse/services/autoCombo/modePacks.ts` | 4 weight profiles |
|
||||
| `src/app/api/combos/auto/route.ts` | REST API |
|
||||
@@ -1288,10 +1288,14 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
{ id: "kimi-k2.5", name: "Kimi K2.5" },
|
||||
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro" },
|
||||
{ id: "mimo-v2.5", name: "MiMo-V2.5" },
|
||||
{ id: "mimo-v2-pro", name: "MiMo-V2-Pro" },
|
||||
{ id: "mimo-v2-omni", name: "MiMo-V2-Omni" },
|
||||
{ id: "minimax-m2.7", name: "MiniMax M2.7", targetFormat: "claude" },
|
||||
{ id: "minimax-m2.5", name: "MiniMax M2.5", targetFormat: "claude" },
|
||||
{ id: "qwen3.7-max", name: "Qwen3.7 Max" },
|
||||
{ id: "qwen3.6-plus", name: "Qwen3.6 Plus" },
|
||||
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus" },
|
||||
{ id: "hy3-preview", name: "Hunyuan3 Preview" },
|
||||
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true },
|
||||
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true },
|
||||
],
|
||||
|
||||
@@ -190,7 +190,10 @@ type ComboLogger = {
|
||||
};
|
||||
|
||||
export type SingleModelTarget =
|
||||
| (ResolvedComboTarget & { modelAbortSignal?: AbortSignal | null })
|
||||
| (ResolvedComboTarget & {
|
||||
allowRateLimitedConnection?: boolean;
|
||||
modelAbortSignal?: AbortSignal | null;
|
||||
})
|
||||
| { modelAbortSignal: AbortSignal };
|
||||
|
||||
type HandleSingleModel = (
|
||||
@@ -201,7 +204,7 @@ type HandleSingleModel = (
|
||||
|
||||
type IsModelAvailable = (
|
||||
modelStr: string,
|
||||
target?: ResolvedComboTarget
|
||||
target?: ResolvedComboTarget & { allowRateLimitedConnection?: boolean }
|
||||
) => Promise<boolean> | boolean;
|
||||
|
||||
type ComboRelayOptions = {
|
||||
@@ -3098,6 +3101,7 @@ export async function handleComboChat({
|
||||
// #1731: Per-set-iteration set of providers whose quota is fully exhausted.
|
||||
// Reset each retry so providers excluded in a previous attempt get another chance.
|
||||
const exhaustedProviders = new Set<string>();
|
||||
const transientRateLimitedProviders = new Set<string>();
|
||||
if (setTry > 0) {
|
||||
log.info("COMBO", `All targets failed — retrying set (${setTry}/${maxSetRetries})`);
|
||||
await new Promise((resolve) => {
|
||||
@@ -3129,6 +3133,11 @@ export async function handleComboChat({
|
||||
const modelStr = target.modelStr;
|
||||
const provider = target.provider;
|
||||
const profile = await getRuntimeProviderProfile(provider);
|
||||
const allowRateLimitedConnection =
|
||||
Boolean(provider && provider !== "unknown") && transientRateLimitedProviders.has(provider);
|
||||
const targetForAttempt = allowRateLimitedConnection
|
||||
? { ...target, allowRateLimitedConnection: true }
|
||||
: target;
|
||||
|
||||
// #1731: Skip targets from a provider that already signaled full quota exhaustion this request.
|
||||
if (provider && exhaustedProviders.has(provider)) {
|
||||
@@ -3142,7 +3151,7 @@ export async function handleComboChat({
|
||||
|
||||
// Pre-check: skip models where no credentials are available (excluded, rate-limited, or unavailable)
|
||||
if (isModelAvailable) {
|
||||
const available = await isModelAvailable(modelStr, target);
|
||||
const available = await isModelAvailable(modelStr, targetForAttempt);
|
||||
if (!available) {
|
||||
log.info("COMBO", `Skipping ${modelStr} — no credentials available or model excluded`);
|
||||
if (i > 0) fallbackCount++;
|
||||
@@ -3232,7 +3241,7 @@ export async function handleComboChat({
|
||||
}
|
||||
}
|
||||
const result = await handleSingleModelWithTimeout(attemptBody, modelStr, {
|
||||
...target,
|
||||
...targetForAttempt,
|
||||
failoverBeforeRetry: config.failoverBeforeRetry,
|
||||
});
|
||||
|
||||
@@ -3501,6 +3510,8 @@ export async function handleComboChat({
|
||||
"COMBO",
|
||||
`Provider ${provider} quota exhausted — marking for skip on remaining targets (#1731)`
|
||||
);
|
||||
} else if (result.status === 429 && provider && provider !== "unknown") {
|
||||
transientRateLimitedProviders.add(provider);
|
||||
}
|
||||
|
||||
// Trigger shared provider circuit breaker for 5xx errors and connection failures.
|
||||
@@ -3690,6 +3701,7 @@ async function handleRoundRobinCombo({
|
||||
// When a target returns a quota-exhausted 429, remaining targets from the same
|
||||
// provider are skipped to avoid the cascade through N same-provider targets.
|
||||
const exhaustedProviders = new Set<string>();
|
||||
const transientRateLimitedProviders = new Set<string>();
|
||||
|
||||
// Try each model starting from the round-robin target
|
||||
for (let offset = 0; offset < modelCount; offset++) {
|
||||
@@ -3699,10 +3711,15 @@ async function handleRoundRobinCombo({
|
||||
const provider = target.provider;
|
||||
const profile = await getRuntimeProviderProfile(provider);
|
||||
const semaphoreKey = `combo:${combo.name}:${target.executionKey}`;
|
||||
const allowRateLimitedConnection =
|
||||
Boolean(provider && provider !== "unknown") && transientRateLimitedProviders.has(provider);
|
||||
const targetForAttempt = allowRateLimitedConnection
|
||||
? { ...target, allowRateLimitedConnection: true }
|
||||
: target;
|
||||
|
||||
// Pre-check availability
|
||||
if (isModelAvailable) {
|
||||
const available = await isModelAvailable(modelStr, target);
|
||||
const available = await isModelAvailable(modelStr, targetForAttempt);
|
||||
if (!available) {
|
||||
log.info("COMBO-RR", `Skipping ${modelStr} — no credentials available or model excluded`);
|
||||
if (offset > 0) fallbackCount++;
|
||||
@@ -3766,7 +3783,7 @@ async function handleRoundRobinCombo({
|
||||
);
|
||||
|
||||
const result = await handleSingleModel(body, modelStr, {
|
||||
...target,
|
||||
...targetForAttempt,
|
||||
failoverBeforeRetry: config.failoverBeforeRetry,
|
||||
});
|
||||
|
||||
@@ -3932,6 +3949,8 @@ async function handleRoundRobinCombo({
|
||||
if (providerExhausted) {
|
||||
exhaustedProviders.add(provider);
|
||||
log.info("COMBO-RR", `Provider ${provider} quota exhausted — marking for skip (#1731)`);
|
||||
} else if (result.status === 429 && provider && provider !== "unknown") {
|
||||
transientRateLimitedProviders.add(provider);
|
||||
}
|
||||
|
||||
// Transient errors → mark in semaphore so round-robin stops stampeding this target.
|
||||
|
||||
@@ -147,6 +147,18 @@ export async function syncStandaloneNativeAssets(
|
||||
sourcePath: path.join(rootDir, "node_modules", "wreq-js", "rust"),
|
||||
destinationPath: path.join(rootDir, ".next", "standalone", "node_modules", "wreq-js", "rust"),
|
||||
},
|
||||
{
|
||||
label: "better-sqlite3 native binary",
|
||||
sourcePath: path.join(rootDir, "node_modules", "better-sqlite3", "build"),
|
||||
destinationPath: path.join(
|
||||
rootDir,
|
||||
".next",
|
||||
"standalone",
|
||||
"node_modules",
|
||||
"better-sqlite3",
|
||||
"build"
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
let changed = false;
|
||||
@@ -171,6 +183,90 @@ export async function syncStandaloneNativeAssets(
|
||||
return changed;
|
||||
}
|
||||
|
||||
export async function syncStandaloneExtraModules(
|
||||
rootDir = projectRoot,
|
||||
fsImpl = fs,
|
||||
log = console
|
||||
) {
|
||||
const entries = [
|
||||
{
|
||||
label: "@swc/helpers",
|
||||
sourcePath: path.join(rootDir, "node_modules", "@swc", "helpers"),
|
||||
destRelative: path.join("node_modules", "@swc", "helpers"),
|
||||
},
|
||||
{
|
||||
label: "pino-abstract-transport",
|
||||
sourcePath: path.join(rootDir, "node_modules", "pino-abstract-transport"),
|
||||
destRelative: path.join("node_modules", "pino-abstract-transport"),
|
||||
},
|
||||
{
|
||||
label: "pino-pretty",
|
||||
sourcePath: path.join(rootDir, "node_modules", "pino-pretty"),
|
||||
destRelative: path.join("node_modules", "pino-pretty"),
|
||||
},
|
||||
{
|
||||
label: "split2",
|
||||
sourcePath: path.join(rootDir, "node_modules", "split2"),
|
||||
destRelative: path.join("node_modules", "split2"),
|
||||
},
|
||||
{
|
||||
label: "migrations",
|
||||
sourcePath: path.join(rootDir, "src", "lib", "db", "migrations"),
|
||||
destRelative: "migrations",
|
||||
},
|
||||
{
|
||||
label: "MITM server",
|
||||
sourcePath: path.join(rootDir, "src", "mitm", "server.cjs"),
|
||||
destRelative: path.join("src", "mitm", "server.cjs"),
|
||||
},
|
||||
{
|
||||
label: "run-standalone script",
|
||||
sourcePath: path.join(rootDir, "scripts", "dev", "run-standalone.mjs"),
|
||||
destRelative: path.join("dev", "run-standalone.mjs"),
|
||||
},
|
||||
{
|
||||
label: "runtime-env script",
|
||||
sourcePath: path.join(rootDir, "scripts", "build", "runtime-env.mjs"),
|
||||
destRelative: path.join("build", "runtime-env.mjs"),
|
||||
},
|
||||
{
|
||||
label: "bootstrap-env script",
|
||||
sourcePath: path.join(rootDir, "scripts", "build", "bootstrap-env.mjs"),
|
||||
destRelative: path.join("build", "bootstrap-env.mjs"),
|
||||
},
|
||||
{
|
||||
label: "healthcheck script",
|
||||
sourcePath: path.join(rootDir, "scripts", "dev", "healthcheck.mjs"),
|
||||
destRelative: "healthcheck.mjs",
|
||||
},
|
||||
{
|
||||
label: "public directory",
|
||||
sourcePath: path.join(rootDir, "public"),
|
||||
destRelative: "public",
|
||||
},
|
||||
{
|
||||
label: "playwright-core (dynamic import by gemini-web executor)",
|
||||
sourcePath: path.join(rootDir, "node_modules", "playwright-core"),
|
||||
destRelative: path.join("node_modules", "playwright-core"),
|
||||
},
|
||||
];
|
||||
|
||||
let changed = false;
|
||||
const standaloneRoot = path.join(rootDir, ".next", "standalone");
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!(await exists(entry.sourcePath))) continue;
|
||||
|
||||
const destPath = path.join(standaloneRoot, entry.destRelative);
|
||||
await fsImpl.mkdir(path.dirname(destPath), { recursive: true });
|
||||
await fsImpl.cp(entry.sourcePath, destPath, { recursive: true, force: true });
|
||||
log.log(`[build-next-isolated] Synced standalone module: ${entry.label}`);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
const movedPaths = [];
|
||||
const transientBuildPaths = getTransientBuildPaths();
|
||||
@@ -225,6 +321,15 @@ export async function main() {
|
||||
nativeAssetErr
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await syncStandaloneExtraModules(projectRoot);
|
||||
} catch (extraModuleErr) {
|
||||
console.warn(
|
||||
"[build-next-isolated] Non-fatal error syncing extra modules:",
|
||||
extraModuleErr
|
||||
);
|
||||
}
|
||||
}
|
||||
process.exitCode = result.code;
|
||||
} catch (error) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getRequestDetailLogCount,
|
||||
isDetailedLoggingEnabled,
|
||||
} from "@/lib/db/detailedLogs";
|
||||
import { getUserDatabaseSettings, updateDatabaseSettings } from "@/lib/db/databaseSettings";
|
||||
import { updateSettings } from "@/lib/db/settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -36,6 +37,14 @@ export async function POST(req: NextRequest) {
|
||||
const enabled = body.enabled === true || body.enabled === "1";
|
||||
|
||||
await updateSettings({ call_log_pipeline_enabled: enabled });
|
||||
const databaseSettings = getUserDatabaseSettings();
|
||||
updateDatabaseSettings({
|
||||
logs: {
|
||||
...databaseSettings.logs,
|
||||
detailedLogsEnabled: enabled,
|
||||
callLogPipelineEnabled: enabled,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
exchangeTokens,
|
||||
requestDeviceCode,
|
||||
pollForToken,
|
||||
resolveBrowserOAuthRedirectUri,
|
||||
} from "@/lib/oauth/providers";
|
||||
import {
|
||||
createProviderConnection,
|
||||
@@ -80,7 +81,9 @@ export async function GET(
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
if (action === "authorize") {
|
||||
const redirectUri = searchParams.get("redirect_uri") || "http://localhost:8080/callback";
|
||||
const requestedRedirectUri =
|
||||
searchParams.get("redirect_uri") || "http://localhost:8080/callback";
|
||||
const redirectUri = resolveBrowserOAuthRedirectUri(provider, requestedRedirectUri);
|
||||
const authData = generateAuthData(provider, redirectUri);
|
||||
if (provider === "qoder" && !authData.authUrl) {
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -739,8 +739,8 @@
|
||||
"endpointsSubtitle": "您的 AI 连接 URL",
|
||||
"apiManager": "API 管理",
|
||||
"apiManagerSubtitle": "管理 API 密钥和访问",
|
||||
"embeddedServices": "__MISSING__:Embedded Services",
|
||||
"embeddedServicesSubtitle": "__MISSING__:Manage local proxy services",
|
||||
"embeddedServices": "内嵌服务",
|
||||
"embeddedServicesSubtitle": "管理本地代理服务",
|
||||
"logs": "日志",
|
||||
"webhooks": "Webhook",
|
||||
"webhooksSubtitle": "获取事件通知",
|
||||
@@ -978,91 +978,91 @@
|
||||
"signatureTitle": "Webhook 签名",
|
||||
"signatureDescription": "每次投递都会包含一个 X-Webhook-Signature 请求头,该签名使用 Webhook 密钥通过 HMAC-SHA256 生成。信任载荷前请先验证签名。",
|
||||
"wizard": {
|
||||
"cancel": "__MISSING__:Cancel",
|
||||
"step1Title": "__MISSING__:Select Integration",
|
||||
"step2Title": "__MISSING__:Configure Target",
|
||||
"step3Title": "__MISSING__:Events & Test",
|
||||
"back": "__MISSING__:Back",
|
||||
"next": "__MISSING__:Next",
|
||||
"finish": "__MISSING__:Finish",
|
||||
"step1Desc": "__MISSING__:Select the target integration system for this webhook."
|
||||
"cancel": "取消",
|
||||
"step1Title": "选择集成",
|
||||
"step2Title": "配置目标",
|
||||
"step3Title": "事件与测试",
|
||||
"back": "返回",
|
||||
"next": "下一步",
|
||||
"finish": "完成",
|
||||
"step1Desc": "选择此 Webhook 要对接的目标集成系统。"
|
||||
},
|
||||
"howItWorks": {
|
||||
"step1": "__MISSING__:Choose an integration provider (e.g. Slack, Discord, custom webhook) and set up the connection details.",
|
||||
"step2": "__MISSING__:Configure system events to subscribe to (e.g. completion errors, model fallbacks, or usage limits).",
|
||||
"step3": "__MISSING__:Verify the configuration by sending a test payload to make sure the endpoint receives it.",
|
||||
"step4": "__MISSING__:Secure your endpoint by validating the X-Webhook-Signature HMAC-SHA256 header using the webhook's secret.",
|
||||
"title": "__MISSING__:How Webhooks Work",
|
||||
"customOnly": "__MISSING__:Only applicable for custom endpoint integrations.",
|
||||
"hmacRecipeTitle": "__MISSING__:HMAC Verification Recipe",
|
||||
"hmacRecipe": "__MISSING__:To verify webhook payloads in Node.js, compute the HMAC-SHA256 of the raw request body using your secret key. Compare it to the X-Webhook-Signature header using timingSafeEqual.",
|
||||
"timeoutNote": "__MISSING__:Webhook deliveries have a timeout of 10 seconds.",
|
||||
"retryNote": "__MISSING__:Failed deliveries are retried up to 5 times with exponential backoff.",
|
||||
"docsLink": "__MISSING__:Read the full Webhooks developer guide",
|
||||
"hmacRecipePython": "__MISSING__:Python HMAC verification: hmac.new(secret, body, hashlib.sha256).hexdigest()",
|
||||
"hmacRecipeBash": "__MISSING__:Bash HMAC verification: echo -n \"$body\" | openssl dgst -sha256 -hmac \"$secret\""
|
||||
"step1": "选择一个集成提供商(如 Slack、Discord、自定义 Webhook)并配置连接详情。",
|
||||
"step2": "配置要订阅的系统事件(如补全错误、模型回退或用量限制)。",
|
||||
"step3": "发送测试负载以验证端点是否能正常接收数据。",
|
||||
"step4": "使用 Webhook 密钥对 X-Webhook-Signature HMAC-SHA256 头进行验证,以确保端点安全。",
|
||||
"title": "Webhook 工作原理",
|
||||
"customOnly": "仅适用于自定义端点集成。",
|
||||
"hmacRecipeTitle": "HMAC 验证方案",
|
||||
"hmacRecipe": "在 Node.js 中验证 Webhook 负载:使用密钥对原始请求体计算 HMAC-SHA256,然后通过 timingSafeEqual 与 X-Webhook-Signature 头进行比对。",
|
||||
"timeoutNote": "Webhook 投递超时时间为 10 秒。",
|
||||
"retryNote": "投递失败将最多重试 5 次,采用指数退避策略。",
|
||||
"docsLink": "阅读完整的 Webhook 开发者指南",
|
||||
"hmacRecipePython": "Python HMAC 验证:hmac.new(secret, body, hashlib.sha256).hexdigest()",
|
||||
"hmacRecipeBash": "Bash HMAC 验证:echo -n \"$body\" | openssl dgst -sha256 -hmac \"$secret\""
|
||||
},
|
||||
"deliveries": {
|
||||
"title": "__MISSING__:Delivery Logs",
|
||||
"loadFailed": "__MISSING__:Failed to load webhook delivery logs.",
|
||||
"empty": "__MISSING__:No deliveries recorded yet. Trigger an event or send a test payload.",
|
||||
"status": "__MISSING__:Status",
|
||||
"event": "__MISSING__:Event",
|
||||
"latency": "__MISSING__:Latency",
|
||||
"at": "__MISSING__:Sent At"
|
||||
"title": "投递日志",
|
||||
"loadFailed": "加载 Webhook 投递日志失败。",
|
||||
"empty": "暂无投递记录。触发一个事件或发送测试负载。",
|
||||
"status": "状态",
|
||||
"event": "事件",
|
||||
"latency": "延迟",
|
||||
"at": "发送时间"
|
||||
},
|
||||
"kinds": {
|
||||
"comingSoon": "__MISSING__:Coming Soon",
|
||||
"slack": "__MISSING__:Slack",
|
||||
"slackDesc": "__MISSING__:Post system events directly into a Slack channel",
|
||||
"telegram": "__MISSING__:Telegram",
|
||||
"telegramDesc": "__MISSING__:Send event messages using a Telegram bot",
|
||||
"discord": "__MISSING__:Discord",
|
||||
"discordDesc": "__MISSING__:Deliver real-time updates directly to a Discord server",
|
||||
"custom": "__MISSING__:Custom Webhook",
|
||||
"customDesc": "__MISSING__:Deliver system event payloads to any HTTPS endpoint",
|
||||
"email": "__MISSING__:Email Notification",
|
||||
"emailDesc": "__MISSING__:Receive summary updates via email",
|
||||
"pagerduty": "__MISSING__:PagerDuty",
|
||||
"pagerdutyDesc": "__MISSING__:Trigger alerts on PagerDuty for critical system issues",
|
||||
"teams": "__MISSING__:Microsoft Teams",
|
||||
"teamsDesc": "__MISSING__:Forward system notifications to Microsoft Teams channels"
|
||||
"comingSoon": "即将推出",
|
||||
"slack": "Slack",
|
||||
"slackDesc": "将系统事件直接发布到 Slack 频道",
|
||||
"telegram": "Telegram",
|
||||
"telegramDesc": "通过 Telegram 机器人发送事件消息",
|
||||
"discord": "Discord",
|
||||
"discordDesc": "将实时更新直接推送到 Discord 服务器",
|
||||
"custom": "自定义 Webhook",
|
||||
"customDesc": "将系统事件负载投递到任意 HTTPS 端点",
|
||||
"email": "邮件通知",
|
||||
"emailDesc": "通过邮件接收摘要更新",
|
||||
"pagerduty": "PagerDuty",
|
||||
"pagerdutyDesc": "在 PagerDuty 上为关键系统问题触发告警",
|
||||
"teams": "Microsoft Teams",
|
||||
"teamsDesc": "将系统通知转发到 Microsoft Teams 频道"
|
||||
},
|
||||
"testPayloadSent": "__MISSING__:Test Payload Sent",
|
||||
"testResponse": "__MISSING__:Test Response",
|
||||
"testPayloadSent": "测试负载已发送",
|
||||
"testResponse": "测试响应",
|
||||
"validateUrl": {
|
||||
"checking": "__MISSING__:Checking URL...",
|
||||
"ok": "__MISSING__:URL is valid",
|
||||
"blockedPrivate": "__MISSING__:URL is a blocked private address",
|
||||
"invalidUrl": "__MISSING__:Invalid URL format"
|
||||
"checking": "正在检查 URL...",
|
||||
"ok": "URL 有效",
|
||||
"blockedPrivate": "URL 是被阻止的私有地址",
|
||||
"invalidUrl": "URL 格式无效"
|
||||
},
|
||||
"custom": {
|
||||
"endpointUrl": "__MISSING__:Endpoint URL",
|
||||
"endpointUrlPlaceholder": "__MISSING__:https://api.yourdomain.com/webhook",
|
||||
"secretKey": "__MISSING__:Secret Key",
|
||||
"secretKeyPlaceholder": "__MISSING__:Enter secret key or leave blank to auto-generate",
|
||||
"secretKeyHint": "__MISSING__:Used to sign the payload header for authentication."
|
||||
"endpointUrl": "端点 URL",
|
||||
"endpointUrlPlaceholder": "https://api.yourdomain.com/webhook",
|
||||
"secretKey": "密钥",
|
||||
"secretKeyPlaceholder": "输入密钥或留空以自动生成",
|
||||
"secretKeyHint": "用于对负载头进行签名以进行身份验证。"
|
||||
},
|
||||
"discord": {
|
||||
"webhookUrl": "__MISSING__:Discord Webhook URL",
|
||||
"webhookUrlPlaceholder": "__MISSING__:https://discord.com/api/webhooks/...",
|
||||
"webhookUrlHint": "__MISSING__:The webhook URL copied from your Discord channel integration settings.",
|
||||
"tutorial": "__MISSING__:How to create a Discord webhook:"
|
||||
"webhookUrl": "Discord Webhook URL",
|
||||
"webhookUrlPlaceholder": "https://discord.com/api/webhooks/...",
|
||||
"webhookUrlHint": "从 Discord 频道集成设置中复制的 Webhook URL。",
|
||||
"tutorial": "如何创建 Discord Webhook:"
|
||||
},
|
||||
"slack": {
|
||||
"webhookUrl": "__MISSING__:Slack Webhook URL",
|
||||
"webhookUrlPlaceholder": "__MISSING__:https://hooks.slack.com/services/...",
|
||||
"webhookUrlHint": "__MISSING__:The webhook URL copied from your Slack Incoming Webhooks integration.",
|
||||
"tutorial": "__MISSING__:How to create a Slack webhook:"
|
||||
"webhookUrl": "Slack Webhook URL",
|
||||
"webhookUrlPlaceholder": "https://hooks.slack.com/services/...",
|
||||
"webhookUrlHint": "从 Slack Incoming Webhooks 集成中复制的 Webhook URL。",
|
||||
"tutorial": "如何创建 Slack Webhook:"
|
||||
},
|
||||
"telegram": {
|
||||
"botToken": "__MISSING__:Telegram Bot Token",
|
||||
"botTokenPlaceholder": "__MISSING__:123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
|
||||
"botTokenHint": "__MISSING__:The HTTP API token received from @BotFather when creating your bot.",
|
||||
"chatId": "__MISSING__:Telegram Chat ID / Channel",
|
||||
"chatIdPlaceholder": "__MISSING__:-100123456789 or @channelname",
|
||||
"chatIdHint": "__MISSING__:The unique numerical identifier or public username of the chat/channel.",
|
||||
"tutorial": "__MISSING__:How to configure Telegram webhook:"
|
||||
"botToken": "Telegram 机器人令牌",
|
||||
"botTokenPlaceholder": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
|
||||
"botTokenHint": "创建机器人时从 @BotFather 获取的 HTTP API 令牌。",
|
||||
"chatId": "Telegram 聊天 ID / 频道",
|
||||
"chatIdPlaceholder": "-100123456789 或 @channelname",
|
||||
"chatIdHint": "聊天/频道的唯一数字标识符或公开用户名。",
|
||||
"tutorial": "如何配置 Telegram Webhook:"
|
||||
}
|
||||
},
|
||||
"compliance": {
|
||||
@@ -1557,7 +1557,10 @@
|
||||
"filterTypeRestricted": "受限",
|
||||
"shownOf": "已显示 {shown} / {total}",
|
||||
"emptyFilterTitle": "没有密钥匹配当前筛选条件",
|
||||
"emptyFilterClear": "清除筛选"
|
||||
"emptyFilterClear": "清除筛选",
|
||||
"allEndpointsAllowed": "此密钥可以访问所有 API 端点。",
|
||||
"endpointRestrictions": "允许的端点",
|
||||
"endpointsRestricted": "仅限 {count} 个端点。"
|
||||
},
|
||||
"auditLog": {
|
||||
"title": "审核日志",
|
||||
@@ -2570,14 +2573,14 @@
|
||||
"groupApiKey": "API 密钥",
|
||||
"groupAccount": "账户",
|
||||
"groupServiceTier": "服务层级",
|
||||
"serviceTierFast": "__MISSING__:Fast",
|
||||
"serviceTierFlex": "__MISSING__:Flex",
|
||||
"serviceTierStandard": "__MISSING__:Standard",
|
||||
"serviceTierBreakdownTitle": "__MISSING__:Service Tier",
|
||||
"serviceTierBreakdownSubtitle": "__MISSING__:Fast / Flex / Standard split",
|
||||
"serviceTierUsageSaved": "__MISSING__:usage saved",
|
||||
"serviceTierCostSaved": "__MISSING__:saved",
|
||||
"serviceTierCostShareSuffix": "__MISSING__:of cost",
|
||||
"serviceTierFast": "快速",
|
||||
"serviceTierFlex": "弹性",
|
||||
"serviceTierStandard": "标准",
|
||||
"serviceTierBreakdownTitle": "服务层级",
|
||||
"serviceTierBreakdownSubtitle": "快速 / 弹性 / 标准 分布",
|
||||
"serviceTierUsageSaved": "已节省用量",
|
||||
"serviceTierCostSaved": "已节省",
|
||||
"serviceTierCostShareSuffix": "成本占比",
|
||||
"dimension": "维度",
|
||||
"share": "占比",
|
||||
"legacyOrFree": "旧版 / 免费",
|
||||
@@ -2790,16 +2793,16 @@
|
||||
"localServer": "本地服务器",
|
||||
"cloudOmniroute": "云全路由",
|
||||
"copyUrl": "复制网址",
|
||||
"badgeLoopbackTooltip": "__MISSING__:This endpoint is only accessible locally (loopback only)",
|
||||
"badgeAlwaysProtectedTooltip": "__MISSING__:This endpoint is always protected and requires authorization",
|
||||
"badgeInternalTooltip": "__MISSING__:This is an internal system endpoint",
|
||||
"tierAll": "__MISSING__:All",
|
||||
"tierAuth": "__MISSING__:Authenticated",
|
||||
"tierLoopback": "__MISSING__:Loopback",
|
||||
"tierAlwaysProtected": "__MISSING__:Always Protected",
|
||||
"tierPublic": "__MISSING__:Public",
|
||||
"hideInternal": "__MISSING__:Hide Internal",
|
||||
"showInternal": "__MISSING__:Show Internal"
|
||||
"badgeLoopbackTooltip": "此端点仅可本地访问(仅限回环)",
|
||||
"badgeAlwaysProtectedTooltip": "此端点始终受保护,需要授权",
|
||||
"badgeInternalTooltip": "这是系统内部端点",
|
||||
"tierAll": "全部",
|
||||
"tierAuth": "需认证",
|
||||
"tierLoopback": "回环",
|
||||
"tierAlwaysProtected": "始终受保护",
|
||||
"tierPublic": "公开",
|
||||
"hideInternal": "隐藏内部端点",
|
||||
"showInternal": "显示内部端点"
|
||||
},
|
||||
"endpoints": {
|
||||
"tabProxy": "端点代理",
|
||||
@@ -3165,35 +3168,35 @@
|
||||
"noSessionQuotaMonitorsActive": "没有活动的会话配额监视器。",
|
||||
"gracefulDegradationStatus": "优雅降级状态",
|
||||
"additionalModels": "+{count} 更多型号",
|
||||
"providerHealthMatrixTitle": "__MISSING__:Provider Health Matrix",
|
||||
"providerHealthMatrixDescription": "__MISSING__:Provider × account × model states from breakers, cooldowns, lockouts and logs.",
|
||||
"healthMatrixRange": "__MISSING__:Health matrix range",
|
||||
"providerFilter": "__MISSING__:Provider filter",
|
||||
"onlyIssues": "__MISSING__:Only issues",
|
||||
"refresh": "__MISSING__:Refresh",
|
||||
"accounts": "__MISSING__:Accounts",
|
||||
"models": "__MISSING__:Models",
|
||||
"issues": "__MISSING__:Issues",
|
||||
"loadingProviderHealthMatrix": "__MISSING__:Loading provider health matrix...",
|
||||
"failedProviderHealthMatrix": "__MISSING__:Failed to load Provider Health Matrix: {error}",
|
||||
"noProvidersMatchedFilters": "__MISSING__:No providers matched the current filters.",
|
||||
"modelPillSummary": "__MISSING__:{requests} req · {successRate} success · {latency} avg",
|
||||
"modelLockoutSummary": "__MISSING__:{reason} · {duration} left",
|
||||
"locked": "__MISSING__:locked",
|
||||
"inferred": "__MISSING__:inferred",
|
||||
"inactive": "__MISSING__:Inactive",
|
||||
"noConnectionId": "__MISSING__:no connection id",
|
||||
"accountModelSummary": "__MISSING__:{connectionId} · {count} models",
|
||||
"cooldown": "__MISSING__:cooldown",
|
||||
"durationRemaining": "__MISSING__:{duration} remaining",
|
||||
"noSyncedModelsOrTraffic": "__MISSING__:No synced models or recent traffic yet.",
|
||||
"providerRowSummary": "__MISSING__:{active}/{total} active accounts · {requests} req · {successRate} success · {latency} avg",
|
||||
"cooldownCount": "__MISSING__:{count} cooldown",
|
||||
"lockoutCount": "__MISSING__:{count} lockouts",
|
||||
"issueCount": "__MISSING__:{count} issues",
|
||||
"score": "__MISSING__:Score",
|
||||
"lastRequest": "__MISSING__:Last request",
|
||||
"lastError": "__MISSING__:Last error"
|
||||
"providerHealthMatrixTitle": "提供商健康矩阵",
|
||||
"providerHealthMatrixDescription": "来自熔断器、冷却、锁定和日志的提供商 × 账户 × 模型状态。",
|
||||
"healthMatrixRange": "健康矩阵时间范围",
|
||||
"providerFilter": "提供商筛选",
|
||||
"onlyIssues": "仅显示问题",
|
||||
"refresh": "刷新",
|
||||
"accounts": "账户",
|
||||
"models": "模型",
|
||||
"issues": "问题",
|
||||
"loadingProviderHealthMatrix": "正在加载提供商健康矩阵...",
|
||||
"failedProviderHealthMatrix": "加载提供商健康矩阵失败:{error}",
|
||||
"noProvidersMatchedFilters": "没有提供商匹配当前筛选条件。",
|
||||
"modelPillSummary": "{requests} 请求 · {successRate} 成功率 · {latency} 平均延迟",
|
||||
"modelLockoutSummary": "{reason} · 剩余 {duration}",
|
||||
"locked": "已锁定",
|
||||
"inferred": "推断",
|
||||
"inactive": "不活跃",
|
||||
"noConnectionId": "无连接 ID",
|
||||
"accountModelSummary": "{connectionId} · {count} 个模型",
|
||||
"cooldown": "冷却中",
|
||||
"durationRemaining": "剩余 {duration}",
|
||||
"noSyncedModelsOrTraffic": "暂无同步模型或近期流量。",
|
||||
"providerRowSummary": "{active}/{total} 活跃账户 · {requests} 请求 · {successRate} 成功率 · {latency} 平均延迟",
|
||||
"cooldownCount": "{count} 个冷却中",
|
||||
"lockoutCount": "{count} 个锁定",
|
||||
"issueCount": "{count} 个问题",
|
||||
"score": "评分",
|
||||
"lastRequest": "最近请求",
|
||||
"lastError": "最近错误"
|
||||
},
|
||||
"telemetry": {
|
||||
"title": "系统遥测",
|
||||
@@ -3321,15 +3324,15 @@
|
||||
"provider": "提供商",
|
||||
"account": "Account",
|
||||
"elapsed": "已耗时",
|
||||
"activeStage": "__MISSING__:Stage",
|
||||
"activeStageUnknown": "__MISSING__:Not sent to upstream yet",
|
||||
"activeStageRegistered": "__MISSING__:Registered",
|
||||
"activeStagePayloadPrepared": "__MISSING__:Payload prepared",
|
||||
"activeStageWaitingAccountSlot": "__MISSING__:Waiting for account slot",
|
||||
"activeStageWaitingRateLimit": "__MISSING__:Waiting for rate limiter",
|
||||
"activeStageRateLimitSlotAcquired": "__MISSING__:Rate limit slot acquired",
|
||||
"activeStageSendingToProvider": "__MISSING__:Sending to upstream",
|
||||
"activeStageProviderResponseStarted": "__MISSING__:Upstream response started",
|
||||
"activeStage": "阶段",
|
||||
"activeStageUnknown": "尚未发送到上游",
|
||||
"activeStageRegistered": "已注册",
|
||||
"activeStagePayloadPrepared": "负载已准备",
|
||||
"activeStageWaitingAccountSlot": "等待账户槽位",
|
||||
"activeStageWaitingRateLimit": "等待速率限制器",
|
||||
"activeStageRateLimitSlotAcquired": "已获取速率限制槽位",
|
||||
"activeStageSendingToProvider": "正在发送到上游",
|
||||
"activeStageProviderResponseStarted": "上游响应已开始",
|
||||
"count": "数量",
|
||||
"payloads": "Payload",
|
||||
"viewPayloads": "查看",
|
||||
@@ -4101,22 +4104,22 @@
|
||||
"webFetch": "网页抓取",
|
||||
"webFetchTooltip": "从网页 URL 抽取内容的提供商(HTML → Markdown、抓取、截图)",
|
||||
"webFetchProvidersHeading": "网页抓取提供商",
|
||||
"compatibleProvidersDesc": "__MISSING__:OpenAI-compatible and Anthropic-compatible endpoints you host or configure. Point any OpenAI SDK to your own URL and route requests here.",
|
||||
"oauthProvidersDesc": "__MISSING__:Providers authenticated via OAuth — sign in once and OmniRoute handles token rotation automatically.",
|
||||
"compatibleProvidersDesc": "您托管或配置的 OpenAI 兼容和 Anthropic 兼容端点。将任意 OpenAI SDK 指向您的 URL 并在此处路由请求。",
|
||||
"oauthProvidersDesc": "通过 OAuth 认证的提供商——登录一次,OmniRoute 自动处理令牌轮换。",
|
||||
"webCookieProvidersDesc": "这些提供商使用浏览器网络会话、cookie 或网络令牌而不是 API 密钥。打开提供程序以添加所需的会话凭据。",
|
||||
"apiKeyProvidersDesc": "__MISSING__:Standard API key providers. Add your key once and OmniRoute routes, retries, and rate-limits on your behalf.",
|
||||
"noAuthProvidersDesc": "__MISSING__:Open endpoints that require no credentials — ready to use immediately without any sign-up.",
|
||||
"upstreamProxyProvidersDesc": "__MISSING__:Route outbound traffic through an upstream proxy before it reaches the provider. Useful for corporate networks or traffic inspection.",
|
||||
"webFetchProvidersDesc": "__MISSING__:Providers that fetch and extract content from web URLs. Use them to ground prompts with live web data.",
|
||||
"aggregatorsGatewaysDesc": "__MISSING__:Multi-provider aggregators and AI gateways that expose a single unified API across dozens of underlying models.",
|
||||
"enterpriseCloudDesc": "__MISSING__:Enterprise-tier and cloud-hosted models with enhanced SLAs, compliance certifications, and dedicated capacity.",
|
||||
"cloudAgentProvidersDesc": "__MISSING__:Autonomous cloud agents that execute long-running tasks with plan approval and live status tracking.",
|
||||
"localProvidersDesc": "__MISSING__:Self-hosted models running on your own hardware. No data leaves your infrastructure.",
|
||||
"searchProvidersDesc": "__MISSING__:Web and document search providers. Attach them to LLM calls for Retrieval-Augmented Generation.",
|
||||
"audioProvidersDesc": "__MISSING__:Text-to-speech and speech-to-text providers for voice I/O and audio transcription pipelines.",
|
||||
"embeddingRerankProvidersDesc": "__MISSING__:Vector embedding and reranking providers for semantic search, RAG pipelines, and similarity scoring.",
|
||||
"imageProvidersDesc": "__MISSING__:Image generation and vision providers — create images from text or analyse existing ones.",
|
||||
"videoProvidersDesc": "__MISSING__:Video generation providers. Create short video clips from text prompts or images.",
|
||||
"apiKeyProvidersDesc": "标准 API 密钥提供商。添加密钥后,OmniRoute 代为路由、重试和限流。",
|
||||
"noAuthProvidersDesc": "无需凭证的开放端点——无需注册即可立即使用。",
|
||||
"upstreamProxyProvidersDesc": "通过上游代理路由出站流量。适用于企业网络或流量审计场景。",
|
||||
"webFetchProvidersDesc": "从网页 URL 抓取和提取内容的提供商。用于将实时网页数据注入提示词。",
|
||||
"aggregatorsGatewaysDesc": "多提供商聚合器和 AI 网关,通过单一统一 API 对接数十个底层模型。",
|
||||
"enterpriseCloudDesc": "企业级和云托管模型,提供增强 SLA、合规认证和专用容量。",
|
||||
"cloudAgentProvidersDesc": "自主云代理,可执行长时间运行的任务,支持计划审批和实时状态跟踪。",
|
||||
"localProvidersDesc": "在您自己的硬件上运行的自托管模型。数据不会离开您的基础设施。",
|
||||
"searchProvidersDesc": "网页和文档搜索提供商。可附加到 LLM 调用以实现检索增强生成(RAG)。",
|
||||
"audioProvidersDesc": "文本转语音和语音转文本提供商,用于语音输入输出和音频转录管道。",
|
||||
"embeddingRerankProvidersDesc": "向量嵌入和重排序提供商,用于语义搜索、RAG 管道和相似度评分。",
|
||||
"imageProvidersDesc": "图像生成和视觉提供商——从文本创建图像或分析现有图像。",
|
||||
"videoProvidersDesc": "视频生成提供商。从文本提示或图像创建短视频片段。",
|
||||
"onboardingWizard": "提供商入职向导",
|
||||
"onboardingWizardShort": "入职向导",
|
||||
"onboardingWizardDescription": "通过验证、持久性和即时连接测试连接 API 密钥、自定义兼容和 OAuth 提供商。",
|
||||
@@ -4473,12 +4476,12 @@
|
||||
"configure": "配置",
|
||||
"globalSystemPrompt": "全局系统提示",
|
||||
"saved": "已保存",
|
||||
"beforePromptLabel": "__MISSING__:Before Prompt",
|
||||
"beforePromptDesc": "__MISSING__:Injected before agent/provider system instructions",
|
||||
"beforePromptPlaceholder": "__MISSING__:Instructions inserted before agent/provider prompt...",
|
||||
"afterPromptLabel": "__MISSING__:After Prompt",
|
||||
"afterPromptDesc": "__MISSING__:Injected after agent/provider system instructions",
|
||||
"afterPromptPlaceholder": "__MISSING__:Instructions inserted after agent/provider prompt...",
|
||||
"beforePromptLabel": "提示词前置",
|
||||
"beforePromptDesc": "注入到代理/提供商系统指令之前",
|
||||
"beforePromptPlaceholder": "插入到代理/提供商提示词之前的指令...",
|
||||
"afterPromptLabel": "提示词后置",
|
||||
"afterPromptDesc": "注入到代理/提供商系统指令之后",
|
||||
"afterPromptPlaceholder": "插入到代理/提供商提示词之后的指令...",
|
||||
"chars": "{count} 字符",
|
||||
"thinkingBudgetTitle": "思考预算",
|
||||
"thinkingBudgetDesc": "控制所有请求中 AI 推理令牌的使用",
|
||||
@@ -5270,15 +5273,15 @@
|
||||
"qdrantCleanupSuccess": "好的:删除 {count} 点(保留:{days} 天)",
|
||||
"qdrantCleanupFailed": "清理失败",
|
||||
"qdrantCleanupError": "错误:{error}",
|
||||
"vercelRelaySuccess": "__MISSING__:Vercel Relay deployed successfully",
|
||||
"vercelRelayButton": "__MISSING__:Deploy Vercel Relay",
|
||||
"vercelRelayModalTitle": "__MISSING__:Deploy Vercel Relay",
|
||||
"vercelRelayWarning": "__MISSING__:Warning: A Vercel deployment token is required. This token will only be used to create the serverless relay and will never be stored by OmniRoute.",
|
||||
"vercelRelayTokenLabel": "__MISSING__:Vercel Access Token",
|
||||
"vercelRelayProjectNameLabel": "__MISSING__:Vercel Project Name",
|
||||
"vercelRelayFreeTierNote": "__MISSING__:Relays are lightweight proxy endpoints deployed on Vercel's free tier to bypass local network/region limitations.",
|
||||
"vercelRelayDeploying": "__MISSING__:Deploying...",
|
||||
"vercelRelayDeploy": "__MISSING__:Deploy"
|
||||
"vercelRelaySuccess": "Vercel Relay 部署成功",
|
||||
"vercelRelayButton": "部署 Vercel Relay",
|
||||
"vercelRelayModalTitle": "部署 Vercel Relay",
|
||||
"vercelRelayWarning": "警告:需要 Vercel 部署令牌。此令牌仅用于创建无服务器中继,OmniRoute 不会存储该令牌。",
|
||||
"vercelRelayTokenLabel": "Vercel 访问令牌",
|
||||
"vercelRelayProjectNameLabel": "Vercel 项目名称",
|
||||
"vercelRelayFreeTierNote": "中继是在 Vercel 免费层上部署的轻量级代理端点,用于绕过本地网络/区域限制。",
|
||||
"vercelRelayDeploying": "正在部署...",
|
||||
"vercelRelayDeploy": "部署"
|
||||
},
|
||||
"contextRtk": {
|
||||
"title": "RTK 引擎",
|
||||
@@ -5777,9 +5780,9 @@
|
||||
"rawPlanWithValue": "原始计划:{plan}",
|
||||
"noPlanFromProvider": "提供商没有计划",
|
||||
"noQuotaData": "无配额数据",
|
||||
"cardExpand": "__MISSING__:Expand",
|
||||
"cardCollapse": "__MISSING__:Collapse",
|
||||
"moreQuotas": "__MISSING__:+{count} more",
|
||||
"cardExpand": "展开",
|
||||
"cardCollapse": "收起",
|
||||
"moreQuotas": "还有 {count} 项",
|
||||
"ungrouped": "未分组",
|
||||
"viewFlat": "平铺视图",
|
||||
"viewByEnvironment": "按环境分组",
|
||||
@@ -5960,8 +5963,8 @@
|
||||
"noSpendLast30Days": "过去 30 天内没有消费",
|
||||
"updatedShort": "更新于",
|
||||
"lastRefreshed": "上次刷新",
|
||||
"providerQuota": "__MISSING__:Provider Quota",
|
||||
"providerQuotaHomeHint": "__MISSING__:Live status across connected accounts"
|
||||
"providerQuota": "提供商配额",
|
||||
"providerQuotaHomeHint": "已连接账户的实时状态"
|
||||
},
|
||||
"modals": {
|
||||
"waitingAuth": "等待授权",
|
||||
|
||||
@@ -83,6 +83,17 @@ function parseStoredValue(rawValue: unknown): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
function toBooleanSetting(value: unknown): boolean | null {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return !Number.isNaN(value) && value !== 0;
|
||||
if (typeof value !== "string") return null;
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
||||
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function readNamespace(namespace: string): Record<string, unknown> {
|
||||
const db = getDbInstance();
|
||||
const rows = db
|
||||
@@ -119,6 +130,18 @@ function mergeTopLevelSections(target: UserDatabaseSettings, values: Record<stri
|
||||
}
|
||||
}
|
||||
|
||||
function mergeRuntimeLogSettings(target: UserDatabaseSettings, values: Record<string, unknown>) {
|
||||
const pipelineEnabled = toBooleanSetting(values.call_log_pipeline_enabled);
|
||||
if (pipelineEnabled !== null) {
|
||||
target.logs.callLogPipelineEnabled = pipelineEnabled;
|
||||
}
|
||||
|
||||
const legacyDetailedEnabled = toBooleanSetting(values.detailed_logs_enabled);
|
||||
if (legacyDetailedEnabled !== null) {
|
||||
target.logs.detailedLogsEnabled = legacyDetailedEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeDatabaseSettingsNamespace(
|
||||
target: UserDatabaseSettings,
|
||||
values: Record<string, unknown>
|
||||
@@ -195,6 +218,7 @@ export function getUserDatabaseSettings(): UserDatabaseSettings {
|
||||
|
||||
mergeTopLevelSections(settings, mainSettings);
|
||||
mergeDatabaseSettingsNamespace(settings, readNamespace(DATABASE_SETTINGS_NAMESPACE));
|
||||
mergeRuntimeLogSettings(settings, mainSettings);
|
||||
|
||||
return settings;
|
||||
}
|
||||
@@ -236,6 +260,14 @@ export function updateDatabaseSettings(
|
||||
const insert = db.prepare(
|
||||
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)"
|
||||
);
|
||||
const settingsInsert = db.prepare(
|
||||
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', ?, ?)"
|
||||
);
|
||||
|
||||
const requestedLogs = updates.logs as Partial<UserDatabaseSettings["logs"]> | undefined;
|
||||
const pipelineEnabled = requestedLogs?.callLogPipelineEnabled;
|
||||
const detailedEnabled = requestedLogs?.detailedLogsEnabled;
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
for (const section of DATABASE_SETTINGS_SECTIONS) {
|
||||
const sectionValues = nextSettings[section] as Record<string, unknown>;
|
||||
@@ -244,6 +276,10 @@ export function updateDatabaseSettings(
|
||||
insert.run(DATABASE_SETTINGS_NAMESPACE, `${section}.${key}`, JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
|
||||
if (pipelineEnabled !== undefined) {
|
||||
settingsInsert.run("call_log_pipeline_enabled", JSON.stringify(Boolean(pipelineEnabled)));
|
||||
}
|
||||
});
|
||||
tx();
|
||||
|
||||
|
||||
@@ -10,6 +10,66 @@
|
||||
import { generatePKCE, generateState } from "./utils/pkce";
|
||||
import { PROVIDERS } from "./providers/index";
|
||||
|
||||
const GOOGLE_BROWSER_PROVIDERS = new Set(["antigravity", "gemini-cli"]);
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||
if (!trimmed) return "";
|
||||
return trimmed.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function hasCustomGoogleOAuthCredentials(providerName, env = process.env) {
|
||||
if (providerName === "antigravity") {
|
||||
return !!env.ANTIGRAVITY_OAUTH_CLIENT_ID?.trim();
|
||||
}
|
||||
|
||||
if (providerName === "gemini-cli") {
|
||||
return !!env.GEMINI_CLI_OAUTH_CLIENT_ID?.trim() || !!env.GEMINI_OAUTH_CLIENT_ID?.trim();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Google providers default to localhost redirects so the embedded public
|
||||
* credentials keep working on out-of-the-box local installs. When operators
|
||||
* provide their own Google OAuth client IDs for a remote deployment, prefer the
|
||||
* public callback URL documented in .env.example / docs/README so the popup can
|
||||
* navigate back to OmniRoute instead of stalling on localhost.
|
||||
*/
|
||||
export function resolveBrowserOAuthRedirectUri(
|
||||
providerName,
|
||||
redirectUri,
|
||||
env = process.env
|
||||
) {
|
||||
if (!GOOGLE_BROWSER_PROVIDERS.has(providerName)) {
|
||||
return redirectUri;
|
||||
}
|
||||
|
||||
if (!hasCustomGoogleOAuthCredentials(providerName, env)) {
|
||||
return redirectUri;
|
||||
}
|
||||
|
||||
const publicBaseUrl =
|
||||
normalizeBaseUrl(env.NEXT_PUBLIC_BASE_URL) || normalizeBaseUrl(env.OMNIROUTE_PUBLIC_BASE_URL);
|
||||
|
||||
if (!publicBaseUrl) {
|
||||
return redirectUri;
|
||||
}
|
||||
|
||||
try {
|
||||
const requested = new URL(redirectUri);
|
||||
const isLocalhostRedirect = /^(localhost|127\.0\.0\.1)$/i.test(requested.hostname);
|
||||
if (!isLocalhostRedirect) {
|
||||
return redirectUri;
|
||||
}
|
||||
} catch {
|
||||
return redirectUri;
|
||||
}
|
||||
|
||||
return `${publicBaseUrl}/callback`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider handler
|
||||
*/
|
||||
|
||||
@@ -130,8 +130,8 @@ export interface ApplyCodexGlobalFastServiceTierOptions {
|
||||
*/
|
||||
model?: string | null;
|
||||
/**
|
||||
* Outbound request body. Per-request body.service_tier is left untouched if
|
||||
* already set.
|
||||
* Outbound request body. A valid per-request body.service_tier is left untouched
|
||||
* when already set.
|
||||
*/
|
||||
body?: Record<string, unknown> | null;
|
||||
}
|
||||
@@ -188,12 +188,11 @@ export function applyCodexGlobalFastServiceTier<T extends JsonRecord | null | un
|
||||
} as T;
|
||||
}
|
||||
|
||||
if (resolved.tier === "flex") {
|
||||
// Write the wire value directly to the outbound body when possible. The executor
|
||||
// also accepts requestDefaults.serviceTier = "flex" for downstream accounting.
|
||||
if (body && typeof body === "object" && !Array.isArray(body)) {
|
||||
(body as JsonRecord).service_tier = "flex";
|
||||
}
|
||||
if (body && typeof body === "object" && !Array.isArray(body)) {
|
||||
// Write the wire value directly to the outbound body when possible, so combo
|
||||
// request previews and logs show the same effective tier that the executor sends.
|
||||
// The executor also accepts requestDefaults.serviceTier for downstream accounting.
|
||||
(body as JsonRecord).service_tier = resolved.tier;
|
||||
}
|
||||
|
||||
// Intentional precedence: body service_tier > global mode > connection defaults.
|
||||
|
||||
@@ -381,9 +381,9 @@ export default function OAuthModal({
|
||||
// - Codex/OpenAI: always port 1455 (registered in OAuth app)
|
||||
// - Windsurf/Devin CLI (remote fallback): use localhost with OmniRoute port + /auth/callback
|
||||
// (on true localhost the callback server handles it; this is only reached on remote)
|
||||
// - Google OAuth providers (antigravity, gemini-cli): always localhost, regardless of
|
||||
// where OmniRoute is hosted — Google only accepts pre-registered localhost URIs with
|
||||
// the built-in credentials. Remote users must configure their own credentials.
|
||||
// - Google OAuth providers (antigravity, gemini-cli): default to localhost so the
|
||||
// bundled credentials keep working. The authorize route upgrades this to the public
|
||||
// callback when custom Google credentials + NEXT_PUBLIC_BASE_URL are configured.
|
||||
// - Other providers on remote: use actual origin (supports PUBLIC_URL env var)
|
||||
// - Localhost: use localhost:port
|
||||
let redirectUri: string;
|
||||
@@ -433,7 +433,7 @@ export default function OAuthModal({
|
||||
);
|
||||
}
|
||||
|
||||
setAuthData({ ...data, redirectUri });
|
||||
setAuthData({ ...data, redirectUri: data.redirectUri || redirectUri });
|
||||
|
||||
// For non-true-localhost (LAN IPs, remote) or manual fallback: use manual input mode (user pastes callback URL)
|
||||
if (!isTrueLocalhost || forceManual) {
|
||||
|
||||
@@ -217,6 +217,15 @@ export const MODEL_SPECS: Record<string, ModelSpec> = {
|
||||
aliases: ["kimi-k2.6-thinking", "kimi-for-coding"],
|
||||
},
|
||||
|
||||
// ── Qwen3.7 Max (Bailian multimodal — text/image/video) ─────────
|
||||
"qwen3.7-max": {
|
||||
maxOutputTokens: 8192,
|
||||
contextWindow: 200000,
|
||||
supportsThinking: true,
|
||||
supportsTools: true,
|
||||
supportsVision: true,
|
||||
},
|
||||
|
||||
// ── Xiaomi MiMo V2.5 (1M context, consensus across 7+ sync sources) ──
|
||||
"mimo-v2.5-pro": {
|
||||
maxOutputTokens: 131072,
|
||||
@@ -230,6 +239,12 @@ export const MODEL_SPECS: Record<string, ModelSpec> = {
|
||||
supportsTools: true,
|
||||
supportsVision: true,
|
||||
},
|
||||
"mimo-v2-pro": {
|
||||
maxOutputTokens: 131072,
|
||||
contextWindow: 262144,
|
||||
supportsTools: true,
|
||||
supportsVision: true,
|
||||
},
|
||||
"mimo-v2-omni": {
|
||||
maxOutputTokens: 131072,
|
||||
contextWindow: 262144,
|
||||
|
||||
@@ -438,6 +438,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
const checkModelAvailable = async (
|
||||
modelString: string,
|
||||
target?: {
|
||||
allowRateLimitedConnection?: boolean;
|
||||
connectionId?: string | null;
|
||||
allowedConnectionIds?: string[] | null;
|
||||
executionKey?: string | null;
|
||||
@@ -469,6 +470,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
resolvedModel,
|
||||
{
|
||||
sessionKey: sessionAffinityKey,
|
||||
...(target?.allowRateLimitedConnection ? { allowRateLimitedConnections: true } : {}),
|
||||
...(target?.connectionId ? { forcedConnectionId: target.connectionId } : {}),
|
||||
}
|
||||
);
|
||||
@@ -496,6 +498,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
b: any,
|
||||
m: string,
|
||||
target?: {
|
||||
allowRateLimitedConnection?: boolean;
|
||||
connectionId?: string | null;
|
||||
executionKey?: string | null;
|
||||
stepId?: string | null;
|
||||
@@ -520,6 +523,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
comboStepId: target?.stepId || null,
|
||||
comboExecutionKey: target?.executionKey || target?.stepId || null,
|
||||
skipUpstreamRetry: target?.failoverBeforeRetry ?? false,
|
||||
allowRateLimitedConnection: target?.allowRateLimitedConnection === true,
|
||||
preselectedCredentials: comboPreselectedCredentials.get(
|
||||
getComboCredentialCacheKey(m, target)
|
||||
),
|
||||
@@ -651,6 +655,7 @@ async function handleSingleModelChat(
|
||||
comboStepId?: string | null;
|
||||
comboExecutionKey?: string | null;
|
||||
skipUpstreamRetry?: boolean;
|
||||
allowRateLimitedConnection?: boolean;
|
||||
preselectedCredentials?: any;
|
||||
cachedSettings?: any;
|
||||
} = {},
|
||||
@@ -811,6 +816,9 @@ async function handleSingleModelChat(
|
||||
{
|
||||
sessionKey: runtimeOptions.sessionAffinityKey ?? runtimeOptions.sessionId ?? null,
|
||||
excludeConnectionIds: Array.from(excludedConnectionIds),
|
||||
...(runtimeOptions.allowRateLimitedConnection
|
||||
? { allowRateLimitedConnections: true }
|
||||
: {}),
|
||||
...(forceLiveComboTest
|
||||
? {
|
||||
allowSuppressedConnections: true,
|
||||
|
||||
@@ -94,6 +94,7 @@ interface RecoverableConnectionState {
|
||||
|
||||
interface CredentialSelectionOptions {
|
||||
allowSuppressedConnections?: boolean;
|
||||
allowRateLimitedConnections?: boolean;
|
||||
bypassQuotaPolicy?: boolean;
|
||||
forcedConnectionId?: string | null;
|
||||
excludeConnectionIds?: string[] | null;
|
||||
@@ -846,6 +847,8 @@ export async function getProviderCredentials(
|
||||
}
|
||||
|
||||
const allowSuppressedConnections = options.allowSuppressedConnections === true;
|
||||
const allowRateLimitedConnections =
|
||||
allowSuppressedConnections || options.allowRateLimitedConnections === true;
|
||||
const bypassQuotaPolicy = options.bypassQuotaPolicy === true;
|
||||
const forcedConnectionId =
|
||||
typeof options.forcedConnectionId === "string" && options.forcedConnectionId.trim().length > 0
|
||||
@@ -973,7 +976,7 @@ export async function getProviderCredentials(
|
||||
return false;
|
||||
}
|
||||
if (!allowSuppressedConnections) {
|
||||
if (isAccountUnavailable(c.rateLimitedUntil)) return false;
|
||||
if (!allowRateLimitedConnections && isAccountUnavailable(c.rateLimitedUntil)) return false;
|
||||
if (isTerminalConnectionStatus(c)) return false;
|
||||
if (provider === "codex" && isCodexScopeUnavailable(c, requestedModel)) return false;
|
||||
// Per-model lockout: if this specific model is locked on this connection, skip it
|
||||
|
||||
@@ -606,6 +606,47 @@ test("chat pipeline persists Codex responses cache and reasoning tokens to call
|
||||
assert.equal(callLog.tokens.reasoning, 13);
|
||||
});
|
||||
|
||||
test("chat pipeline applies global Codex priority service tier inside combos", async () => {
|
||||
await seedConnection("codex", { apiKey: "sk-codex-combo-priority" });
|
||||
await settingsDb.updateSettings({
|
||||
codexServiceTier: { enabled: true, tier: "priority" },
|
||||
});
|
||||
await combosDb.createCombo({
|
||||
name: "codex-priority-combo",
|
||||
strategy: "priority",
|
||||
config: { maxRetries: 0, retryDelayMs: 0 },
|
||||
models: ["codex/gpt-5.5"],
|
||||
});
|
||||
const fetchCalls = [];
|
||||
|
||||
globalThis.fetch = async (url, init: RequestInit = {}) => {
|
||||
fetchCalls.push({
|
||||
url: String(url),
|
||||
headers: toPlainHeaders(init.headers),
|
||||
body: init.body ? JSON.parse(String(init.body)) : null,
|
||||
});
|
||||
return buildOpenAIResponsesSSE({ text: "combo priority ok", model: "gpt-5.5" });
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
body: {
|
||||
model: "codex-priority-combo",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Use Codex combo priority" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const json = (await response.json()) as any;
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.match(fetchCalls[0].url, /\/responses$/);
|
||||
assert.equal(fetchCalls[0].headers.Authorization, "Bearer sk-codex-combo-priority");
|
||||
assert.equal(fetchCalls[0].body.service_tier, "priority");
|
||||
assert.equal(json.choices[0].message.content, "combo priority ok");
|
||||
});
|
||||
|
||||
test("chat pipeline applies Codex CLI fingerprint to OAuth responses requests", async () => {
|
||||
setCliCompatProviders(["codex"]);
|
||||
await seedConnection("codex", {
|
||||
@@ -950,12 +991,7 @@ test("chat pipeline sends Gemini CLI OAuth requests with native Cloud Code trans
|
||||
assert.equal(generateCall.body.requestId, undefined);
|
||||
assert.equal(generateCall.body.user_prompt_id, generateCall.body.request.session_id);
|
||||
const keys = Object.keys(generateCall.body).slice(0, 4);
|
||||
assert.deepEqual(keys.sort(), [
|
||||
"model",
|
||||
"project",
|
||||
"request",
|
||||
"user_prompt_id",
|
||||
]);
|
||||
assert.deepEqual(keys.sort(), ["model", "project", "request", "user_prompt_id"]);
|
||||
assert.equal(generateCall.body.request.sessionId, undefined);
|
||||
assert.match(generateCall.body.request.session_id, /^[0-9a-f-]{36}$/i);
|
||||
assert.equal(generateCall.body.request.contents.at(-1).parts[0].text, "Hello Gemini CLI");
|
||||
|
||||
@@ -495,3 +495,79 @@ test.skip("round-robin path fast-skip: round-robin combo also skips exhausted pr
|
||||
assert.equal(openaiCalls, 1, "round-robin should skip second openai target");
|
||||
assert.equal(anthropicCalls, 1);
|
||||
});
|
||||
|
||||
test("allow rate-limited connections after transient 429 on subsequent targets in same combo", async () => {
|
||||
const now = Date.now();
|
||||
await seedConnection("openai", {
|
||||
name: "openai-rate-limited-reused",
|
||||
apiKey: "sk-openai-rate-limited-reused",
|
||||
rateLimitedUntil: new Date(now + 60000).toISOString(),
|
||||
});
|
||||
|
||||
await seedConnection("openai", {
|
||||
name: "openai-fresh-429",
|
||||
apiKey: "sk-openai-fresh-429",
|
||||
});
|
||||
|
||||
await settingsDb.updateSettings({
|
||||
requestRetry: 0,
|
||||
maxRetryIntervalSec: 0,
|
||||
});
|
||||
|
||||
await combosDb.createCombo({
|
||||
name: "rate-limit-reuse-combo",
|
||||
strategy: "priority",
|
||||
config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 },
|
||||
models: [
|
||||
"openai/gpt-4o-mini",
|
||||
"openai/gpt-3.5-turbo",
|
||||
],
|
||||
});
|
||||
|
||||
let openaiCalls = 0;
|
||||
let usedApiKeys: string[] = [];
|
||||
|
||||
globalThis.fetch = async (_url: string, init: any = {}) => {
|
||||
const headers = toPlainHeaders(init.headers);
|
||||
const authHeader = headers.authorization ?? headers.Authorization;
|
||||
|
||||
openaiCalls += 1;
|
||||
if (authHeader === "Bearer sk-openai-fresh-429") {
|
||||
usedApiKeys.push("fresh");
|
||||
return new Response(JSON.stringify({ error: { message: "Too many requests" } }), {
|
||||
status: 429,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (authHeader === "Bearer sk-openai-rate-limited-reused") {
|
||||
usedApiKeys.push("rate-limited");
|
||||
return new Response(
|
||||
JSON.stringify({ choices: [{ message: { content: "rate limited reuse success" } }] }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`unexpected upstream headers: ${JSON.stringify(headers)}`);
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
body: {
|
||||
model: "rate-limit-reuse-combo",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "test rate limit reuse" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const body = (await response.json()) as any;
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.choices[0].message.content, "rate limited reuse success");
|
||||
assert.equal(openaiCalls, 2);
|
||||
assert.deepEqual(usedApiKeys, ["fresh", "rate-limited"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -106,15 +106,17 @@ test("Codex global service tier injects selected mode and can override connectio
|
||||
});
|
||||
|
||||
test("Codex global service tier matches provider-prefixed combo model ids", () => {
|
||||
const body: Record<string, unknown> = {};
|
||||
assert.deepEqual(
|
||||
applyCodexGlobalFastServiceTier(
|
||||
"codex",
|
||||
{ providerSpecificData: {} },
|
||||
{ codexServiceTier: { enabled: true, tier: "priority" } },
|
||||
{ model: "codex/gpt-5.5" }
|
||||
{ model: "codex/gpt-5.5", body }
|
||||
),
|
||||
{ providerSpecificData: { requestDefaults: { serviceTier: "priority" } } }
|
||||
);
|
||||
assert.equal(body.service_tier, "priority");
|
||||
|
||||
const unsupported = { providerSpecificData: {} };
|
||||
assert.equal(
|
||||
@@ -155,7 +157,7 @@ test("Codex global service tier only short-circuits on valid body service_tier",
|
||||
assert.deepEqual(injected, {
|
||||
providerSpecificData: { requestDefaults: { serviceTier: "priority" } },
|
||||
});
|
||||
assert.equal(invalidBody.service_tier, "invalid");
|
||||
assert.equal(invalidBody.service_tier, "priority");
|
||||
|
||||
const validBody: Record<string, unknown> = { service_tier: " Flex " };
|
||||
const unchanged = { providerSpecificData: {} };
|
||||
|
||||
@@ -11,6 +11,7 @@ process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const databaseSettings = await import("../../src/lib/db/databaseSettings.ts");
|
||||
const databaseSettingsRoute = await import("../../src/app/api/settings/database/route.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const cleanup = await import("../../src/lib/db/cleanup.ts");
|
||||
const aggregateHistory = await import("../../src/lib/usage/aggregateHistory.ts");
|
||||
|
||||
@@ -101,6 +102,23 @@ test("database settings reader supports legacy flat keys and lets nested saves w
|
||||
assert.equal(databaseSettings.getUserDatabaseSettings().retention.callLogs, 7);
|
||||
});
|
||||
|
||||
test("database log settings mirror the runtime pipeline toggle", async () => {
|
||||
await settingsDb.updateSettings({ call_log_pipeline_enabled: false });
|
||||
|
||||
assert.equal(databaseSettings.getUserDatabaseSettings().logs.callLogPipelineEnabled, false);
|
||||
|
||||
databaseSettings.updateDatabaseSettings({
|
||||
logs: {
|
||||
...databaseSettings.getUserDatabaseSettings().logs,
|
||||
callLogPipelineEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const settings = await settingsDb.getSettings();
|
||||
assert.equal(settings.call_log_pipeline_enabled, true);
|
||||
assert.equal(databaseSettings.getUserDatabaseSettings().logs.callLogPipelineEnabled, true);
|
||||
});
|
||||
|
||||
test("purgeDetailedLogs deletes request_detail_logs", async () => {
|
||||
const db = core.getDbInstance();
|
||||
db.prepare("INSERT INTO request_detail_logs (id, timestamp, duration_ms) VALUES (?, ?, ?)").run(
|
||||
|
||||
@@ -18,8 +18,10 @@ const providersModule = await import("../../src/lib/oauth/providers/index.ts");
|
||||
const oauthModule = await import("../../src/lib/oauth/constants/oauth.ts");
|
||||
const registryModule = await import("../../open-sse/config/providerRegistry.ts");
|
||||
const antigravityHeadersModule = await import("../../open-sse/services/antigravityHeaders.ts");
|
||||
const oauthHelpersModule = await import("../../src/lib/oauth/providers.ts");
|
||||
|
||||
const PROVIDERS = providersModule.default;
|
||||
const { resolveBrowserOAuthRedirectUri } = oauthHelpersModule;
|
||||
const {
|
||||
ANTIGRAVITY_CONFIG,
|
||||
CLAUDE_CONFIG,
|
||||
@@ -316,6 +318,44 @@ test("browser-based providers expose buildAuthUrl and return provider-specific a
|
||||
assert.equal(clineUrl.origin, "https://api.cline.bot");
|
||||
});
|
||||
|
||||
test("custom Google OAuth credentials switch Antigravity remote callbacks to NEXT_PUBLIC_BASE_URL", () => {
|
||||
const redirectUri = resolveBrowserOAuthRedirectUri(
|
||||
"antigravity",
|
||||
"http://localhost:20128/callback",
|
||||
{
|
||||
NEXT_PUBLIC_BASE_URL: "https://omniroute.example.com/",
|
||||
ANTIGRAVITY_OAUTH_CLIENT_ID: "custom-antigravity.apps.googleusercontent.com",
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(redirectUri, "https://omniroute.example.com/callback");
|
||||
});
|
||||
|
||||
test("custom Google OAuth credentials switch Gemini remote callbacks to OMNIROUTE_PUBLIC_BASE_URL", () => {
|
||||
const redirectUri = resolveBrowserOAuthRedirectUri(
|
||||
"gemini-cli",
|
||||
"http://127.0.0.1:20128/callback",
|
||||
{
|
||||
OMNIROUTE_PUBLIC_BASE_URL: "https://omniroute.example.com",
|
||||
GEMINI_CLI_OAUTH_CLIENT_ID: "custom-gemini.apps.googleusercontent.com",
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(redirectUri, "https://omniroute.example.com/callback");
|
||||
});
|
||||
|
||||
test("Google OAuth callbacks stay on localhost when no custom credentials are configured", () => {
|
||||
const redirectUri = resolveBrowserOAuthRedirectUri(
|
||||
"antigravity",
|
||||
"http://localhost:20128/callback",
|
||||
{
|
||||
NEXT_PUBLIC_BASE_URL: "https://omniroute.example.com",
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(redirectUri, "http://localhost:20128/callback");
|
||||
});
|
||||
|
||||
test("device and import-token providers expose the flow-specific fields expected by their configs", () => {
|
||||
const deviceProviders = ["qwen", "kimi-coding", "github", "kiro", "amazon-q", "kilocode"];
|
||||
|
||||
|
||||
@@ -253,6 +253,30 @@ describe("OpencodeExecutor", () => {
|
||||
});
|
||||
assert.deepEqual(fetchCalls[0].options.headers, result.headers);
|
||||
});
|
||||
|
||||
it("routes opencode-go catalog-only models to chat completions", async () => {
|
||||
// Register new models
|
||||
registerModel("opencode-go", { id: "qwen3.7-max", name: "Qwen3.7 Max" });
|
||||
registerModel("opencode-go", { id: "mimo-v2-pro", name: "MiMo-V2-Pro" });
|
||||
registerModel("opencode-go", { id: "mimo-v2-omni", name: "MiMo-V2-Omni" });
|
||||
registerModel("opencode-go", { id: "hy3-preview", name: "Hunyuan3 Preview" });
|
||||
|
||||
// qwen3.7-max
|
||||
const qwen37 = await goExecutor.execute(createInput("qwen3.7-max"));
|
||||
assert.equal(qwen37.url, "https://opencode.ai/zen/go/v1/chat/completions");
|
||||
|
||||
// mimo-v2-pro
|
||||
const mimoPro = await goExecutor.execute(createInput("mimo-v2-pro"));
|
||||
assert.equal(mimoPro.url, "https://opencode.ai/zen/go/v1/chat/completions");
|
||||
|
||||
// mimo-v2-omni
|
||||
const mimoOmni = await goExecutor.execute(createInput("mimo-v2-omni"));
|
||||
assert.equal(mimoOmni.url, "https://opencode.ai/zen/go/v1/chat/completions");
|
||||
|
||||
// hy3-preview
|
||||
const hy3 = await goExecutor.execute(createInput("hy3-preview"));
|
||||
assert.equal(hy3.url, "https://opencode.ai/zen/go/v1/chat/completions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("user-agent forwarding", () => {
|
||||
|
||||
@@ -608,6 +608,22 @@ test("getProviderCredentials retains rate-limited accounts when allowSuppressedC
|
||||
assert.equal(bypassed.connectionId, connection.id);
|
||||
});
|
||||
|
||||
test("getProviderCredentials retains rate-limited accounts when allowRateLimitedConnections is enabled", async () => {
|
||||
const connection = await seedConnection("openai", {
|
||||
name: "allow-rate-limit-option",
|
||||
rateLimitedUntil: futureIso(),
|
||||
});
|
||||
|
||||
const blocked = await auth.getProviderCredentials("openai");
|
||||
const bypassed = await auth.getProviderCredentials("openai", null, null, null, {
|
||||
allowRateLimitedConnections: true,
|
||||
});
|
||||
|
||||
assert.equal(blocked.allRateLimited, true);
|
||||
assert.equal(bypassed.connectionId, connection.id);
|
||||
});
|
||||
|
||||
|
||||
test("getProviderCredentials retains terminal accounts for combo live tests", async () => {
|
||||
const connection = await seedConnection("openai", {
|
||||
name: "suppressed-terminal",
|
||||
|
||||
Reference in New Issue
Block a user