diff --git a/.env.example b/.env.example index 16a71dc9f6..c7e4911e4a 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index b3b92bb1d4..3fa907b355 100644 --- a/CHANGELOG.md +++ b/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 + --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f125ea29da..d16220c52c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 ``` --- diff --git a/Dockerfile b/Dockerfile index 853d9dc4e9..f23cd7b074 100644 --- a/Dockerfile +++ b/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 diff --git a/README.md b/README.md index 084ca20ea1..321bc6e807 100644 --- a/README.md +++ b/README.md @@ -275,7 +275,7 @@ Result: 4 layers of fallback = zero downtime + also works with · Cline · Antigravity · Windsurf · AMP · Hermes · Qwen CLI · Roo · Continue · any OpenAI-compatible tool -📖 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) +📖 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) @@ -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 | -📖 [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) +📖 [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)
@@ -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)
@@ -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. @@ -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. @@ -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) @@ -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) @@ -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) @@ -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) @@ -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 |
diff --git a/docs/AUTO-COMBO.md b/docs/AUTO-COMBO.md deleted file mode 100644 index c042204b81..0000000000 --- a/docs/AUTO-COMBO.md +++ /dev/null @@ -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: - -# 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 | diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 02b40ebf9a..ddbbe4550b 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -1288,10 +1288,14 @@ export const REGISTRY: Record = { { 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 }, ], diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 743d0cb96d..63bb9f7dd2 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -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; 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(); + const transientRateLimitedProviders = new Set(); 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(); + const transientRateLimitedProviders = new Set(); // 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. diff --git a/scripts/build/build-next-isolated.mjs b/scripts/build/build-next-isolated.mjs index 97e6792d7e..c251de747e 100644 --- a/scripts/build/build-next-isolated.mjs +++ b/scripts/build/build-next-isolated.mjs @@ -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) { diff --git a/src/app/api/logs/detail/route.ts b/src/app/api/logs/detail/route.ts index 4a85321e42..90fa93ca2d 100644 --- a/src/app/api/logs/detail/route.ts +++ b/src/app/api/logs/detail/route.ts @@ -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, diff --git a/src/app/api/oauth/[provider]/[action]/route.ts b/src/app/api/oauth/[provider]/[action]/route.ts index 194601c698..56c74dd71b 100755 --- a/src/app/api/oauth/[provider]/[action]/route.ts +++ b/src/app/api/oauth/[provider]/[action]/route.ts @@ -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({ diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index f61f54bb88..0586e0290f 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.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": "等待授权", diff --git a/src/lib/db/databaseSettings.ts b/src/lib/db/databaseSettings.ts index f9fa21b109..4b67b073b2 100644 --- a/src/lib/db/databaseSettings.ts +++ b/src/lib/db/databaseSettings.ts @@ -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 { const db = getDbInstance(); const rows = db @@ -119,6 +130,18 @@ function mergeTopLevelSections(target: UserDatabaseSettings, values: Record) { + 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 @@ -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 | 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; @@ -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(); diff --git a/src/lib/oauth/providers.ts b/src/lib/oauth/providers.ts index 5709042630..3689b4aa47 100644 --- a/src/lib/oauth/providers.ts +++ b/src/lib/oauth/providers.ts @@ -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 */ diff --git a/src/lib/providers/codexFastTier.ts b/src/lib/providers/codexFastTier.ts index de796f2087..9fe973e467 100644 --- a/src/lib/providers/codexFastTier.ts +++ b/src/lib/providers/codexFastTier.ts @@ -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 | null; } @@ -188,12 +188,11 @@ export function applyCodexGlobalFastServiceTier global mode > connection defaults. diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index 8f021c54d4..df0c59b69f 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -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) { diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index 89bed6cf96..23785f561a 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -217,6 +217,15 @@ export const MODEL_SPECS: Record = { 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 = { supportsTools: true, supportsVision: true, }, + "mimo-v2-pro": { + maxOutputTokens: 131072, + contextWindow: 262144, + supportsTools: true, + supportsVision: true, + }, "mimo-v2-omni": { maxOutputTokens: 131072, contextWindow: 262144, diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index ca5da75c62..7c2988cfaa 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -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, diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 06e4e56550..79edfc1169 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -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 diff --git a/tests/integration/chat-pipeline.test.ts b/tests/integration/chat-pipeline.test.ts index 7476240b2c..2773023a11 100644 --- a/tests/integration/chat-pipeline.test.ts +++ b/tests/integration/chat-pipeline.test.ts @@ -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"); diff --git a/tests/integration/combo-provider-exhaustion.test.ts b/tests/integration/combo-provider-exhaustion.test.ts index 8957695b8b..2d20c96dbb 100644 --- a/tests/integration/combo-provider-exhaustion.test.ts +++ b/tests/integration/combo-provider-exhaustion.test.ts @@ -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"]); +}); + diff --git a/tests/unit/codex-fast-tier.test.ts b/tests/unit/codex-fast-tier.test.ts index 4aea837ccb..f411574345 100644 --- a/tests/unit/codex-fast-tier.test.ts +++ b/tests/unit/codex-fast-tier.test.ts @@ -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 = {}; 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 = { service_tier: " Flex " }; const unchanged = { providerSpecificData: {} }; diff --git a/tests/unit/database-settings-maintenance.test.ts b/tests/unit/database-settings-maintenance.test.ts index 299c7fa370..fa553eb687 100644 --- a/tests/unit/database-settings-maintenance.test.ts +++ b/tests/unit/database-settings-maintenance.test.ts @@ -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( diff --git a/tests/unit/oauth-providers-config.test.ts b/tests/unit/oauth-providers-config.test.ts index c277c5aea3..dc0c0d3103 100644 --- a/tests/unit/oauth-providers-config.test.ts +++ b/tests/unit/oauth-providers-config.test.ts @@ -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"]; diff --git a/tests/unit/opencode-executor.test.ts b/tests/unit/opencode-executor.test.ts index 986fc94bbe..d17243783a 100644 --- a/tests/unit/opencode-executor.test.ts +++ b/tests/unit/opencode-executor.test.ts @@ -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", () => { diff --git a/tests/unit/sse-auth.test.ts b/tests/unit/sse-auth.test.ts index f72348303e..5b57edbe46 100644 --- a/tests/unit/sse-auth.test.ts +++ b/tests/unit/sse-auth.test.ts @@ -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",