From 48070ae768cbf65ed2eea03de544fd855c820299 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 04:14:01 -0300 Subject: [PATCH 01/19] chore(release): bump version to v3.8.5 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9be3a4f346..d44bbe85a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.8.4", + "version": "3.8.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.8.4", + "version": "3.8.5", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 57ae094113..0f63c94452 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.8.4", + "version": "3.8.5", "description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { From a80bb55ceae2b09c22daec20dff5dc85892491e4 Mon Sep 17 00:00:00 2001 From: "Thanet S." Date: Wed, 27 May 2026 14:19:04 +0700 Subject: [PATCH 02/19] fix(docker): rebuild better-sqlite3 after hardened install (#2772) Integrated into release/v3.8.5 --- Dockerfile | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 72e7a48213..853d9dc4e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,19 +12,19 @@ COPY scripts/build/postinstall.mjs ./scripts/build/postinstall.mjs COPY scripts/build/postinstallSupport.mjs ./scripts/build/postinstallSupport.mjs COPY scripts/build/native-binary-compat.mjs ./scripts/build/native-binary-compat.mjs ENV NPM_CONFIG_LEGACY_PEER_DEPS=true -# --ignore-scripts blocks the install/postinstall hooks of dependencies, -# closing the supply-chain attack surface where a transitive dep can run -# arbitrary code at install time. OmniRoute's own postinstall ( -# better-sqlite3 binary touchups, @swc/helpers copy) is only needed when -# a packaged app/node_modules is unpacked — inside the Docker builder we -# are doing a fresh native-platform install, so dropping the scripts is safe. +# --ignore-scripts blocks broad dependency install/postinstall hooks, closing +# the supply-chain attack surface where a transitive dep can run arbitrary code +# at install time. better-sqlite3 still needs a native binding for the target +# platform, so rebuild and smoke-test only that known runtime dependency below. # # We REQUIRE a committed package-lock.json so resolved dependency versions # are reproducible. RUN test -f package-lock.json \ || (echo "package-lock.json is required for reproducible Docker builds" >&2 && exit 1) RUN --mount=type=cache,target=/root/.npm \ - npm ci --no-audit --no-fund --legacy-peer-deps --ignore-scripts + npm ci --no-audit --no-fund --legacy-peer-deps --ignore-scripts \ + && npm rebuild better-sqlite3 \ + && node -e "require('better-sqlite3')(':memory:').close()" COPY . ./ RUN --mount=type=cache,target=/app/.next/cache \ @@ -58,6 +58,9 @@ COPY --from=builder /app/.next/static ./.next/static 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 +# Explicitly copy better-sqlite3 — native bindings are not reliably traced by +# Next.js standalone output, but bootstrap-env requires SQLite before startup. +COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3 # Explicitly copy pino transport dependencies — pino spawns a worker that requires # pino-abstract-transport at runtime; Next.js standalone trace does not capture it (#449) COPY --from=builder /app/node_modules/pino-abstract-transport ./node_modules/pino-abstract-transport @@ -78,6 +81,8 @@ 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 # COPYs so it covers files originally owned by root in the builder stage. From 1a157193dc966185c4d0affd20055cd33dd5f3ce Mon Sep 17 00:00:00 2001 From: "Thanet S." Date: Wed, 27 May 2026 14:19:39 +0700 Subject: [PATCH 03/19] ci: build Docker platforms on native runners (#2774) Integrated into release/v3.8.5 --- .github/workflows/docker-publish.yml | 183 +++++++++++++++++++++------ 1 file changed, 142 insertions(+), 41 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 454fc1f900..fd460e2516 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -30,9 +30,13 @@ permissions: packages: write jobs: - docker: - name: Build and Push Docker (multi-arch) + prepare: + name: Resolve Docker release metadata runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + promote_latest: ${{ steps.version.outputs.promote_latest }} + skip: ${{ steps.version.outputs.skip }} env: IMAGE_NAME: diegosouzapw/omniroute steps: @@ -114,77 +118,174 @@ jobs: echo "Publishing diegosouzapw/omniroute:$VERSION (promote_latest=$PROMOTE, skip=$SKIP)" - - name: Set up QEMU (for multi-arch builds) - if: steps.version.outputs.skip != 'true' - uses: docker/setup-qemu-action@v4 + build: + name: Build Docker (${{ matrix.platform }}) + needs: prepare + if: needs.prepare.outputs.skip != 'true' + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-24.04 + arch: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + arch: arm64 + env: + IMAGE_NAME: diegosouzapw/omniroute + GHCR_IMAGE_NAME: ghcr.io/diegosouzapw/omniroute + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }} + fetch-depth: 0 - name: Set up Docker Buildx - if: steps.version.outputs.skip != 'true' uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub - if: steps.version.outputs.skip != 'true' uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - if: steps.version.outputs.skip != 'true' uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Compute image tags - id: tags - if: steps.version.outputs.skip != 'true' - env: - VERSION: ${{ steps.version.outputs.version }} - PROMOTE_LATEST: ${{ steps.version.outputs.promote_latest }} - run: | - set -euo pipefail - TAGS="${IMAGE_NAME}:${VERSION}" - TAGS="${TAGS}"$'\n'"ghcr.io/diegosouzapw/omniroute:${VERSION}" - if [ "$PROMOTE_LATEST" = "true" ]; then - TAGS="${TAGS}"$'\n'"${IMAGE_NAME}:latest" - TAGS="${TAGS}"$'\n'"ghcr.io/diegosouzapw/omniroute:latest" - fi - { - echo "tags<> "$GITHUB_OUTPUT" - echo "Tags to push:" - echo "$TAGS" - - - name: Build and push multi-arch image - if: steps.version.outputs.skip != 'true' + - name: Build and push platform image by digest + id: build uses: docker/build-push-action@v7 with: context: . target: runner-base - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.tags.outputs.tags }} - cache-from: type=gha - cache-to: type=gha,mode=max + platforms: ${{ matrix.platform }} + outputs: type=image,push-by-digest=true,name-canonical=true,push=true + tags: | + ${{ env.IMAGE_NAME }} + ${{ env.GHCR_IMAGE_NAME }} + cache-from: type=gha,scope=docker-${{ matrix.arch }} + cache-to: type=gha,scope=docker-${{ matrix.arch }},mode=max no-cache: false env: DOCKER_BUILDKIT_INLINE_CACHE: 1 - - name: Inspect image - if: steps.version.outputs.skip != 'true' && steps.version.outputs.version != 'main' + - name: Export digest env: - VERSION: ${{ steps.version.outputs.version }} + DIGEST: ${{ steps.build.outputs.digest }} + run: | + set -euo pipefail + mkdir -p /tmp/digests + digest="${DIGEST#sha256:}" + touch "/tmp/digests/${digest}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ matrix.arch }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + name: Publish multi-arch manifests + needs: + - prepare + - build + if: needs.prepare.outputs.skip != 'true' + runs-on: ubuntu-latest + env: + IMAGE_NAME: diegosouzapw/omniroute + GHCR_IMAGE_NAME: ghcr.io/diegosouzapw/omniroute + VERSION: ${{ needs.prepare.outputs.version }} + PROMOTE_LATEST: ${{ needs.prepare.outputs.promote_latest }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }} + fetch-depth: 0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Download digests + uses: actions/download-artifact@v4 + with: + pattern: digests-* + path: /tmp/digests + merge-multiple: true + + - name: Create Docker Hub manifest + run: | + set -euo pipefail + + tags=(-t "${IMAGE_NAME}:${VERSION}") + if [ "$PROMOTE_LATEST" = "true" ]; then + tags+=(-t "${IMAGE_NAME}:latest") + fi + + refs=() + while IFS= read -r digest_file; do + refs+=("${IMAGE_NAME}@sha256:$(basename "$digest_file")") + done < <(find /tmp/digests -type f | sort) + + if [ "${#refs[@]}" -eq 0 ]; then + echo "No image digests were downloaded." >&2 + exit 1 + fi + + docker buildx imagetools create "${tags[@]}" "${refs[@]}" + + - name: Create GHCR manifest + run: | + set -euo pipefail + + tags=(-t "${GHCR_IMAGE_NAME}:${VERSION}") + if [ "$PROMOTE_LATEST" = "true" ]; then + tags+=(-t "${GHCR_IMAGE_NAME}:latest") + fi + + refs=() + while IFS= read -r digest_file; do + refs+=("${GHCR_IMAGE_NAME}@sha256:$(basename "$digest_file")") + done < <(find /tmp/digests -type f | sort) + + if [ "${#refs[@]}" -eq 0 ]; then + echo "No image digests were downloaded." >&2 + exit 1 + fi + + docker buildx imagetools create "${tags[@]}" "${refs[@]}" + + - name: Inspect image + if: needs.prepare.outputs.version != 'main' run: | docker buildx imagetools inspect "${IMAGE_NAME}:${VERSION}" - name: Update Docker Hub description # Only refresh README/description when we actually promote :latest # (avoids overwriting from main pushes or back-fill builds). - if: steps.version.outputs.skip != 'true' && steps.version.outputs.promote_latest == 'true' + if: needs.prepare.outputs.promote_latest == 'true' uses: peter-evans/dockerhub-description@v5 with: username: ${{ secrets.DOCKERHUB_USERNAME }} From 4321dc69afd88a9ff89f51f0000757a1cbb77aa1 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 04:32:31 -0300 Subject: [PATCH 04/19] docs(release): sync v3.8.5 documentation and metadata Update changelog entries, API reference version, package metadata, and localized LLM documentation for the 3.8.5 release. Refresh generated docs source mappings and architecture counts for current executors and OAuth providers. --- .source/browser.ts | 2 +- .source/server.ts | 46 ++++++++++----------- CHANGELOG.md | 14 +++++++ docs/architecture/ARCHITECTURE.md | 10 ++--- docs/architecture/CODEBASE_DOCUMENTATION.md | 4 +- docs/i18n/ar/llm.txt | 4 +- docs/i18n/az/llm.txt | 4 +- docs/i18n/bg/llm.txt | 4 +- docs/i18n/bn/llm.txt | 4 +- docs/i18n/cs/llm.txt | 4 +- docs/i18n/da/llm.txt | 4 +- docs/i18n/de/llm.txt | 4 +- docs/i18n/es/llm.txt | 4 +- docs/i18n/fa/llm.txt | 4 +- docs/i18n/fi/llm.txt | 4 +- docs/i18n/fr/llm.txt | 4 +- docs/i18n/gu/llm.txt | 4 +- docs/i18n/he/llm.txt | 4 +- docs/i18n/hi/llm.txt | 4 +- docs/i18n/hu/llm.txt | 4 +- docs/i18n/id/llm.txt | 4 +- docs/i18n/in/llm.txt | 4 +- docs/i18n/it/llm.txt | 4 +- docs/i18n/ja/llm.txt | 4 +- docs/i18n/ko/llm.txt | 4 +- docs/i18n/mr/llm.txt | 4 +- docs/i18n/ms/llm.txt | 4 +- docs/i18n/nl/llm.txt | 4 +- docs/i18n/no/llm.txt | 4 +- docs/i18n/phi/llm.txt | 4 +- docs/i18n/pl/llm.txt | 4 +- docs/i18n/pt-BR/llm.txt | 4 +- docs/i18n/pt/llm.txt | 4 +- docs/i18n/ro/llm.txt | 4 +- docs/i18n/ru/llm.txt | 4 +- docs/i18n/sk/llm.txt | 4 +- docs/i18n/sv/llm.txt | 4 +- docs/i18n/sw/llm.txt | 4 +- docs/i18n/ta/llm.txt | 4 +- docs/i18n/te/llm.txt | 4 +- docs/i18n/th/llm.txt | 4 +- docs/i18n/tr/llm.txt | 4 +- docs/i18n/uk-UA/llm.txt | 4 +- docs/i18n/ur/llm.txt | 4 +- docs/i18n/vi/llm.txt | 4 +- docs/i18n/zh-CN/llm.txt | 4 +- docs/reference/openapi.yaml | 2 +- electron/package-lock.json | 4 +- electron/package.json | 2 +- llm.txt | 4 +- open-sse/package.json | 2 +- package-lock.json | 2 +- 52 files changed, 135 insertions(+), 121 deletions(-) diff --git a/.source/browser.ts b/.source/browser.ts index 11f0eef61a..c3bc050c5b 100644 --- a/.source/browser.ts +++ b/.source/browser.ts @@ -7,6 +7,6 @@ const create = browser(); const browserCollections = { - docs: create.doc("docs", {"architecture/ARCHITECTURE.md": () => import("../docs/architecture/ARCHITECTURE.md?collection=docs"), "architecture/AUTHZ_GUIDE.md": () => import("../docs/architecture/AUTHZ_GUIDE.md?collection=docs"), "architecture/CODEBASE_DOCUMENTATION.md": () => import("../docs/architecture/CODEBASE_DOCUMENTATION.md?collection=docs"), "architecture/REPOSITORY_MAP.md": () => import("../docs/architecture/REPOSITORY_MAP.md?collection=docs"), "architecture/RESILIENCE_GUIDE.md": () => import("../docs/architecture/RESILIENCE_GUIDE.md?collection=docs"), "compression/COMPRESSION_ENGINES.md": () => import("../docs/compression/COMPRESSION_ENGINES.md?collection=docs"), "compression/COMPRESSION_GUIDE.md": () => import("../docs/compression/COMPRESSION_GUIDE.md?collection=docs"), "compression/COMPRESSION_LANGUAGE_PACKS.md": () => import("../docs/compression/COMPRESSION_LANGUAGE_PACKS.md?collection=docs"), "compression/COMPRESSION_RULES_FORMAT.md": () => import("../docs/compression/COMPRESSION_RULES_FORMAT.md?collection=docs"), "compression/RTK_COMPRESSION.md": () => import("../docs/compression/RTK_COMPRESSION.md?collection=docs"), "guides/DOCKER_GUIDE.md": () => import("../docs/guides/DOCKER_GUIDE.md?collection=docs"), "guides/ELECTRON_GUIDE.md": () => import("../docs/guides/ELECTRON_GUIDE.md?collection=docs"), "guides/FEATURES.md": () => import("../docs/guides/FEATURES.md?collection=docs"), "guides/I18N.md": () => import("../docs/guides/I18N.md?collection=docs"), "guides/KIRO_SETUP.md": () => import("../docs/guides/KIRO_SETUP.md?collection=docs"), "guides/PWA_GUIDE.md": () => import("../docs/guides/PWA_GUIDE.md?collection=docs"), "guides/SETUP_GUIDE.md": () => import("../docs/guides/SETUP_GUIDE.md?collection=docs"), "guides/TERMUX_GUIDE.md": () => import("../docs/guides/TERMUX_GUIDE.md?collection=docs"), "guides/TROUBLESHOOTING.md": () => import("../docs/guides/TROUBLESHOOTING.md?collection=docs"), "guides/UNINSTALL.md": () => import("../docs/guides/UNINSTALL.md?collection=docs"), "guides/USER_GUIDE.md": () => import("../docs/guides/USER_GUIDE.md?collection=docs"), "frameworks/A2A-SERVER.md": () => import("../docs/frameworks/A2A-SERVER.md?collection=docs"), "frameworks/AGENT_PROTOCOLS_GUIDE.md": () => import("../docs/frameworks/AGENT_PROTOCOLS_GUIDE.md?collection=docs"), "frameworks/CLOUD_AGENT.md": () => import("../docs/frameworks/CLOUD_AGENT.md?collection=docs"), "frameworks/EMBEDDED-SERVICES.md": () => import("../docs/frameworks/EMBEDDED-SERVICES.md?collection=docs"), "frameworks/EVALS.md": () => import("../docs/frameworks/EVALS.md?collection=docs"), "frameworks/GAMIFICATION.md": () => import("../docs/frameworks/GAMIFICATION.md?collection=docs"), "frameworks/MCP-SERVER.md": () => import("../docs/frameworks/MCP-SERVER.md?collection=docs"), "frameworks/MEMORY.md": () => import("../docs/frameworks/MEMORY.md?collection=docs"), "frameworks/OPENCODE.md": () => import("../docs/frameworks/OPENCODE.md?collection=docs"), "frameworks/SKILLS.md": () => import("../docs/frameworks/SKILLS.md?collection=docs"), "frameworks/WEBHOOKS.md": () => import("../docs/frameworks/WEBHOOKS.md?collection=docs"), "ops/COVERAGE_PLAN.md": () => import("../docs/ops/COVERAGE_PLAN.md?collection=docs"), "ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md": () => import("../docs/ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md?collection=docs"), "ops/FLY_IO_DEPLOYMENT_GUIDE.md": () => import("../docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md?collection=docs"), "ops/PROXY_GUIDE.md": () => import("../docs/ops/PROXY_GUIDE.md?collection=docs"), "ops/RELEASE_CHECKLIST.md": () => import("../docs/ops/RELEASE_CHECKLIST.md?collection=docs"), "ops/SQLITE_RUNTIME.md": () => import("../docs/ops/SQLITE_RUNTIME.md?collection=docs"), "ops/TUNNELS_GUIDE.md": () => import("../docs/ops/TUNNELS_GUIDE.md?collection=docs"), "ops/VM_DEPLOYMENT_GUIDE.md": () => import("../docs/ops/VM_DEPLOYMENT_GUIDE.md?collection=docs"), "reference/API_REFERENCE.md": () => import("../docs/reference/API_REFERENCE.md?collection=docs"), "reference/CLI-TOOLS.md": () => import("../docs/reference/CLI-TOOLS.md?collection=docs"), "reference/ENVIRONMENT.md": () => import("../docs/reference/ENVIRONMENT.md?collection=docs"), "reference/FREE_TIERS.md": () => import("../docs/reference/FREE_TIERS.md?collection=docs"), "reference/PROVIDER_REFERENCE.md": () => import("../docs/reference/PROVIDER_REFERENCE.md?collection=docs"), "routing/AUTO-COMBO.md": () => import("../docs/routing/AUTO-COMBO.md?collection=docs"), "routing/REASONING_REPLAY.md": () => import("../docs/routing/REASONING_REPLAY.md?collection=docs"), "security/CLI_TOKEN.md": () => import("../docs/security/CLI_TOKEN.md?collection=docs"), "security/CLI_TOKEN_AUTH.md": () => import("../docs/security/CLI_TOKEN_AUTH.md?collection=docs"), "security/COMPLIANCE.md": () => import("../docs/security/COMPLIANCE.md?collection=docs"), "security/ERROR_SANITIZATION.md": () => import("../docs/security/ERROR_SANITIZATION.md?collection=docs"), "security/GUARDRAILS.md": () => import("../docs/security/GUARDRAILS.md?collection=docs"), "security/PUBLIC_CREDS.md": () => import("../docs/security/PUBLIC_CREDS.md?collection=docs"), "security/ROUTE_GUARD_TIERS.md": () => import("../docs/security/ROUTE_GUARD_TIERS.md?collection=docs"), "security/STEALTH_GUIDE.md": () => import("../docs/security/STEALTH_GUIDE.md?collection=docs"), }), + docs: create.doc("docs", {"architecture/ARCHITECTURE.md": () => import("../docs/architecture/ARCHITECTURE.md?collection=docs"), "architecture/AUTHZ_GUIDE.md": () => import("../docs/architecture/AUTHZ_GUIDE.md?collection=docs"), "architecture/CODEBASE_DOCUMENTATION.md": () => import("../docs/architecture/CODEBASE_DOCUMENTATION.md?collection=docs"), "architecture/REPOSITORY_MAP.md": () => import("../docs/architecture/REPOSITORY_MAP.md?collection=docs"), "architecture/RESILIENCE_GUIDE.md": () => import("../docs/architecture/RESILIENCE_GUIDE.md?collection=docs"), "compression/COMPRESSION_ENGINES.md": () => import("../docs/compression/COMPRESSION_ENGINES.md?collection=docs"), "compression/COMPRESSION_GUIDE.md": () => import("../docs/compression/COMPRESSION_GUIDE.md?collection=docs"), "compression/COMPRESSION_LANGUAGE_PACKS.md": () => import("../docs/compression/COMPRESSION_LANGUAGE_PACKS.md?collection=docs"), "compression/COMPRESSION_RULES_FORMAT.md": () => import("../docs/compression/COMPRESSION_RULES_FORMAT.md?collection=docs"), "compression/RTK_COMPRESSION.md": () => import("../docs/compression/RTK_COMPRESSION.md?collection=docs"), "frameworks/A2A-SERVER.md": () => import("../docs/frameworks/A2A-SERVER.md?collection=docs"), "frameworks/AGENT_PROTOCOLS_GUIDE.md": () => import("../docs/frameworks/AGENT_PROTOCOLS_GUIDE.md?collection=docs"), "frameworks/CLOUD_AGENT.md": () => import("../docs/frameworks/CLOUD_AGENT.md?collection=docs"), "frameworks/EMBEDDED-SERVICES.md": () => import("../docs/frameworks/EMBEDDED-SERVICES.md?collection=docs"), "frameworks/EVALS.md": () => import("../docs/frameworks/EVALS.md?collection=docs"), "frameworks/GAMIFICATION.md": () => import("../docs/frameworks/GAMIFICATION.md?collection=docs"), "frameworks/MCP-SERVER.md": () => import("../docs/frameworks/MCP-SERVER.md?collection=docs"), "frameworks/MEMORY.md": () => import("../docs/frameworks/MEMORY.md?collection=docs"), "frameworks/OPENCODE.md": () => import("../docs/frameworks/OPENCODE.md?collection=docs"), "frameworks/SKILLS.md": () => import("../docs/frameworks/SKILLS.md?collection=docs"), "frameworks/WEBHOOKS.md": () => import("../docs/frameworks/WEBHOOKS.md?collection=docs"), "guides/DOCKER_GUIDE.md": () => import("../docs/guides/DOCKER_GUIDE.md?collection=docs"), "guides/ELECTRON_GUIDE.md": () => import("../docs/guides/ELECTRON_GUIDE.md?collection=docs"), "guides/FEATURES.md": () => import("../docs/guides/FEATURES.md?collection=docs"), "guides/I18N.md": () => import("../docs/guides/I18N.md?collection=docs"), "guides/KIRO_SETUP.md": () => import("../docs/guides/KIRO_SETUP.md?collection=docs"), "guides/PWA_GUIDE.md": () => import("../docs/guides/PWA_GUIDE.md?collection=docs"), "guides/SETUP_GUIDE.md": () => import("../docs/guides/SETUP_GUIDE.md?collection=docs"), "guides/TERMUX_GUIDE.md": () => import("../docs/guides/TERMUX_GUIDE.md?collection=docs"), "guides/TROUBLESHOOTING.md": () => import("../docs/guides/TROUBLESHOOTING.md?collection=docs"), "guides/UNINSTALL.md": () => import("../docs/guides/UNINSTALL.md?collection=docs"), "guides/USER_GUIDE.md": () => import("../docs/guides/USER_GUIDE.md?collection=docs"), "ops/COVERAGE_PLAN.md": () => import("../docs/ops/COVERAGE_PLAN.md?collection=docs"), "ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md": () => import("../docs/ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md?collection=docs"), "ops/FLY_IO_DEPLOYMENT_GUIDE.md": () => import("../docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md?collection=docs"), "ops/PROXY_GUIDE.md": () => import("../docs/ops/PROXY_GUIDE.md?collection=docs"), "ops/RELEASE_CHECKLIST.md": () => import("../docs/ops/RELEASE_CHECKLIST.md?collection=docs"), "ops/SQLITE_RUNTIME.md": () => import("../docs/ops/SQLITE_RUNTIME.md?collection=docs"), "ops/TUNNELS_GUIDE.md": () => import("../docs/ops/TUNNELS_GUIDE.md?collection=docs"), "ops/VM_DEPLOYMENT_GUIDE.md": () => import("../docs/ops/VM_DEPLOYMENT_GUIDE.md?collection=docs"), "reference/API_REFERENCE.md": () => import("../docs/reference/API_REFERENCE.md?collection=docs"), "reference/CLI-TOOLS.md": () => import("../docs/reference/CLI-TOOLS.md?collection=docs"), "reference/ENVIRONMENT.md": () => import("../docs/reference/ENVIRONMENT.md?collection=docs"), "reference/FREE_TIERS.md": () => import("../docs/reference/FREE_TIERS.md?collection=docs"), "reference/PROVIDER_REFERENCE.md": () => import("../docs/reference/PROVIDER_REFERENCE.md?collection=docs"), "routing/AUTO-COMBO.md": () => import("../docs/routing/AUTO-COMBO.md?collection=docs"), "routing/REASONING_REPLAY.md": () => import("../docs/routing/REASONING_REPLAY.md?collection=docs"), "security/CLI_TOKEN.md": () => import("../docs/security/CLI_TOKEN.md?collection=docs"), "security/CLI_TOKEN_AUTH.md": () => import("../docs/security/CLI_TOKEN_AUTH.md?collection=docs"), "security/COMPLIANCE.md": () => import("../docs/security/COMPLIANCE.md?collection=docs"), "security/ERROR_SANITIZATION.md": () => import("../docs/security/ERROR_SANITIZATION.md?collection=docs"), "security/GUARDRAILS.md": () => import("../docs/security/GUARDRAILS.md?collection=docs"), "security/PUBLIC_CREDS.md": () => import("../docs/security/PUBLIC_CREDS.md?collection=docs"), "security/ROUTE_GUARD_TIERS.md": () => import("../docs/security/ROUTE_GUARD_TIERS.md?collection=docs"), "security/STEALTH_GUIDE.md": () => import("../docs/security/STEALTH_GUIDE.md?collection=docs"), }), }; export default browserCollections; \ No newline at end of file diff --git a/.source/server.ts b/.source/server.ts index 923e119f5a..ea427fcb51 100644 --- a/.source/server.ts +++ b/.source/server.ts @@ -32,28 +32,28 @@ import * as __fd_glob_35 from "../docs/ops/PROXY_GUIDE.md?collection=docs" import * as __fd_glob_34 from "../docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md?collection=docs" import * as __fd_glob_33 from "../docs/ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md?collection=docs" import * as __fd_glob_32 from "../docs/ops/COVERAGE_PLAN.md?collection=docs" -import * as __fd_glob_31 from "../docs/frameworks/WEBHOOKS.md?collection=docs" -import * as __fd_glob_30 from "../docs/frameworks/SKILLS.md?collection=docs" -import * as __fd_glob_29 from "../docs/frameworks/OPENCODE.md?collection=docs" -import * as __fd_glob_28 from "../docs/frameworks/MEMORY.md?collection=docs" -import * as __fd_glob_27 from "../docs/frameworks/MCP-SERVER.md?collection=docs" -import * as __fd_glob_26 from "../docs/frameworks/GAMIFICATION.md?collection=docs" -import * as __fd_glob_25 from "../docs/frameworks/EVALS.md?collection=docs" -import * as __fd_glob_24 from "../docs/frameworks/EMBEDDED-SERVICES.md?collection=docs" -import * as __fd_glob_23 from "../docs/frameworks/CLOUD_AGENT.md?collection=docs" -import * as __fd_glob_22 from "../docs/frameworks/AGENT_PROTOCOLS_GUIDE.md?collection=docs" -import * as __fd_glob_21 from "../docs/frameworks/A2A-SERVER.md?collection=docs" -import * as __fd_glob_20 from "../docs/guides/USER_GUIDE.md?collection=docs" -import * as __fd_glob_19 from "../docs/guides/UNINSTALL.md?collection=docs" -import * as __fd_glob_18 from "../docs/guides/TROUBLESHOOTING.md?collection=docs" -import * as __fd_glob_17 from "../docs/guides/TERMUX_GUIDE.md?collection=docs" -import * as __fd_glob_16 from "../docs/guides/SETUP_GUIDE.md?collection=docs" -import * as __fd_glob_15 from "../docs/guides/PWA_GUIDE.md?collection=docs" -import * as __fd_glob_14 from "../docs/guides/KIRO_SETUP.md?collection=docs" -import * as __fd_glob_13 from "../docs/guides/I18N.md?collection=docs" -import * as __fd_glob_12 from "../docs/guides/FEATURES.md?collection=docs" -import * as __fd_glob_11 from "../docs/guides/ELECTRON_GUIDE.md?collection=docs" -import * as __fd_glob_10 from "../docs/guides/DOCKER_GUIDE.md?collection=docs" +import * as __fd_glob_31 from "../docs/guides/USER_GUIDE.md?collection=docs" +import * as __fd_glob_30 from "../docs/guides/UNINSTALL.md?collection=docs" +import * as __fd_glob_29 from "../docs/guides/TROUBLESHOOTING.md?collection=docs" +import * as __fd_glob_28 from "../docs/guides/TERMUX_GUIDE.md?collection=docs" +import * as __fd_glob_27 from "../docs/guides/SETUP_GUIDE.md?collection=docs" +import * as __fd_glob_26 from "../docs/guides/PWA_GUIDE.md?collection=docs" +import * as __fd_glob_25 from "../docs/guides/KIRO_SETUP.md?collection=docs" +import * as __fd_glob_24 from "../docs/guides/I18N.md?collection=docs" +import * as __fd_glob_23 from "../docs/guides/FEATURES.md?collection=docs" +import * as __fd_glob_22 from "../docs/guides/ELECTRON_GUIDE.md?collection=docs" +import * as __fd_glob_21 from "../docs/guides/DOCKER_GUIDE.md?collection=docs" +import * as __fd_glob_20 from "../docs/frameworks/WEBHOOKS.md?collection=docs" +import * as __fd_glob_19 from "../docs/frameworks/SKILLS.md?collection=docs" +import * as __fd_glob_18 from "../docs/frameworks/OPENCODE.md?collection=docs" +import * as __fd_glob_17 from "../docs/frameworks/MEMORY.md?collection=docs" +import * as __fd_glob_16 from "../docs/frameworks/MCP-SERVER.md?collection=docs" +import * as __fd_glob_15 from "../docs/frameworks/GAMIFICATION.md?collection=docs" +import * as __fd_glob_14 from "../docs/frameworks/EVALS.md?collection=docs" +import * as __fd_glob_13 from "../docs/frameworks/EMBEDDED-SERVICES.md?collection=docs" +import * as __fd_glob_12 from "../docs/frameworks/CLOUD_AGENT.md?collection=docs" +import * as __fd_glob_11 from "../docs/frameworks/AGENT_PROTOCOLS_GUIDE.md?collection=docs" +import * as __fd_glob_10 from "../docs/frameworks/A2A-SERVER.md?collection=docs" import * as __fd_glob_9 from "../docs/compression/RTK_COMPRESSION.md?collection=docs" import * as __fd_glob_8 from "../docs/compression/COMPRESSION_RULES_FORMAT.md?collection=docs" import * as __fd_glob_7 from "../docs/compression/COMPRESSION_LANGUAGE_PACKS.md?collection=docs" @@ -72,4 +72,4 @@ const create = server({"doc":{"passthroughs":["extractedReferences"]}}); -export const docs = await create.docs("docs", "docs", {"meta.json": __fd_glob_55, "architecture/meta.json": __fd_glob_56, "compression/meta.json": __fd_glob_57, "frameworks/meta.json": __fd_glob_58, "guides/meta.json": __fd_glob_59, "ops/meta.json": __fd_glob_60, "reference/meta.json": __fd_glob_61, "reference/openapi.yaml": __fd_glob_62, "routing/meta.json": __fd_glob_63, "security/meta.json": __fd_glob_64, }, {"architecture/ARCHITECTURE.md": __fd_glob_0, "architecture/AUTHZ_GUIDE.md": __fd_glob_1, "architecture/CODEBASE_DOCUMENTATION.md": __fd_glob_2, "architecture/REPOSITORY_MAP.md": __fd_glob_3, "architecture/RESILIENCE_GUIDE.md": __fd_glob_4, "compression/COMPRESSION_ENGINES.md": __fd_glob_5, "compression/COMPRESSION_GUIDE.md": __fd_glob_6, "compression/COMPRESSION_LANGUAGE_PACKS.md": __fd_glob_7, "compression/COMPRESSION_RULES_FORMAT.md": __fd_glob_8, "compression/RTK_COMPRESSION.md": __fd_glob_9, "guides/DOCKER_GUIDE.md": __fd_glob_10, "guides/ELECTRON_GUIDE.md": __fd_glob_11, "guides/FEATURES.md": __fd_glob_12, "guides/I18N.md": __fd_glob_13, "guides/KIRO_SETUP.md": __fd_glob_14, "guides/PWA_GUIDE.md": __fd_glob_15, "guides/SETUP_GUIDE.md": __fd_glob_16, "guides/TERMUX_GUIDE.md": __fd_glob_17, "guides/TROUBLESHOOTING.md": __fd_glob_18, "guides/UNINSTALL.md": __fd_glob_19, "guides/USER_GUIDE.md": __fd_glob_20, "frameworks/A2A-SERVER.md": __fd_glob_21, "frameworks/AGENT_PROTOCOLS_GUIDE.md": __fd_glob_22, "frameworks/CLOUD_AGENT.md": __fd_glob_23, "frameworks/EMBEDDED-SERVICES.md": __fd_glob_24, "frameworks/EVALS.md": __fd_glob_25, "frameworks/GAMIFICATION.md": __fd_glob_26, "frameworks/MCP-SERVER.md": __fd_glob_27, "frameworks/MEMORY.md": __fd_glob_28, "frameworks/OPENCODE.md": __fd_glob_29, "frameworks/SKILLS.md": __fd_glob_30, "frameworks/WEBHOOKS.md": __fd_glob_31, "ops/COVERAGE_PLAN.md": __fd_glob_32, "ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md": __fd_glob_33, "ops/FLY_IO_DEPLOYMENT_GUIDE.md": __fd_glob_34, "ops/PROXY_GUIDE.md": __fd_glob_35, "ops/RELEASE_CHECKLIST.md": __fd_glob_36, "ops/SQLITE_RUNTIME.md": __fd_glob_37, "ops/TUNNELS_GUIDE.md": __fd_glob_38, "ops/VM_DEPLOYMENT_GUIDE.md": __fd_glob_39, "reference/API_REFERENCE.md": __fd_glob_40, "reference/CLI-TOOLS.md": __fd_glob_41, "reference/ENVIRONMENT.md": __fd_glob_42, "reference/FREE_TIERS.md": __fd_glob_43, "reference/PROVIDER_REFERENCE.md": __fd_glob_44, "routing/AUTO-COMBO.md": __fd_glob_45, "routing/REASONING_REPLAY.md": __fd_glob_46, "security/CLI_TOKEN.md": __fd_glob_47, "security/CLI_TOKEN_AUTH.md": __fd_glob_48, "security/COMPLIANCE.md": __fd_glob_49, "security/ERROR_SANITIZATION.md": __fd_glob_50, "security/GUARDRAILS.md": __fd_glob_51, "security/PUBLIC_CREDS.md": __fd_glob_52, "security/ROUTE_GUARD_TIERS.md": __fd_glob_53, "security/STEALTH_GUIDE.md": __fd_glob_54, }); \ No newline at end of file +export const docs = await create.docs("docs", "docs", {"meta.json": __fd_glob_55, "architecture/meta.json": __fd_glob_56, "compression/meta.json": __fd_glob_57, "frameworks/meta.json": __fd_glob_58, "guides/meta.json": __fd_glob_59, "ops/meta.json": __fd_glob_60, "reference/meta.json": __fd_glob_61, "reference/openapi.yaml": __fd_glob_62, "routing/meta.json": __fd_glob_63, "security/meta.json": __fd_glob_64, }, {"architecture/ARCHITECTURE.md": __fd_glob_0, "architecture/AUTHZ_GUIDE.md": __fd_glob_1, "architecture/CODEBASE_DOCUMENTATION.md": __fd_glob_2, "architecture/REPOSITORY_MAP.md": __fd_glob_3, "architecture/RESILIENCE_GUIDE.md": __fd_glob_4, "compression/COMPRESSION_ENGINES.md": __fd_glob_5, "compression/COMPRESSION_GUIDE.md": __fd_glob_6, "compression/COMPRESSION_LANGUAGE_PACKS.md": __fd_glob_7, "compression/COMPRESSION_RULES_FORMAT.md": __fd_glob_8, "compression/RTK_COMPRESSION.md": __fd_glob_9, "frameworks/A2A-SERVER.md": __fd_glob_10, "frameworks/AGENT_PROTOCOLS_GUIDE.md": __fd_glob_11, "frameworks/CLOUD_AGENT.md": __fd_glob_12, "frameworks/EMBEDDED-SERVICES.md": __fd_glob_13, "frameworks/EVALS.md": __fd_glob_14, "frameworks/GAMIFICATION.md": __fd_glob_15, "frameworks/MCP-SERVER.md": __fd_glob_16, "frameworks/MEMORY.md": __fd_glob_17, "frameworks/OPENCODE.md": __fd_glob_18, "frameworks/SKILLS.md": __fd_glob_19, "frameworks/WEBHOOKS.md": __fd_glob_20, "guides/DOCKER_GUIDE.md": __fd_glob_21, "guides/ELECTRON_GUIDE.md": __fd_glob_22, "guides/FEATURES.md": __fd_glob_23, "guides/I18N.md": __fd_glob_24, "guides/KIRO_SETUP.md": __fd_glob_25, "guides/PWA_GUIDE.md": __fd_glob_26, "guides/SETUP_GUIDE.md": __fd_glob_27, "guides/TERMUX_GUIDE.md": __fd_glob_28, "guides/TROUBLESHOOTING.md": __fd_glob_29, "guides/UNINSTALL.md": __fd_glob_30, "guides/USER_GUIDE.md": __fd_glob_31, "ops/COVERAGE_PLAN.md": __fd_glob_32, "ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md": __fd_glob_33, "ops/FLY_IO_DEPLOYMENT_GUIDE.md": __fd_glob_34, "ops/PROXY_GUIDE.md": __fd_glob_35, "ops/RELEASE_CHECKLIST.md": __fd_glob_36, "ops/SQLITE_RUNTIME.md": __fd_glob_37, "ops/TUNNELS_GUIDE.md": __fd_glob_38, "ops/VM_DEPLOYMENT_GUIDE.md": __fd_glob_39, "reference/API_REFERENCE.md": __fd_glob_40, "reference/CLI-TOOLS.md": __fd_glob_41, "reference/ENVIRONMENT.md": __fd_glob_42, "reference/FREE_TIERS.md": __fd_glob_43, "reference/PROVIDER_REFERENCE.md": __fd_glob_44, "routing/AUTO-COMBO.md": __fd_glob_45, "routing/REASONING_REPLAY.md": __fd_glob_46, "security/CLI_TOKEN.md": __fd_glob_47, "security/CLI_TOKEN_AUTH.md": __fd_glob_48, "security/COMPLIANCE.md": __fd_glob_49, "security/ERROR_SANITIZATION.md": __fd_glob_50, "security/GUARDRAILS.md": __fd_glob_51, "security/PUBLIC_CREDS.md": __fd_glob_52, "security/ROUTE_GUARD_TIERS.md": __fd_glob_53, "security/STEALTH_GUIDE.md": __fd_glob_54, }); \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 21ca75968a..1656817ca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## [Unreleased] +--- + +## [3.8.5] — 2026-05-27 + +### 🔧 Bug Fixes + +- **docker:** rebuild `better-sqlite3` native bindings after hardened install to resolve container startup crash (#2772 — thanks @thanet-s) + +### ⚡ 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) + +--- + ## [3.8.4] — 2026-05-26 ### 🔒 Security diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 3f0b8e8cfd..74c3e28e4c 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -17,13 +17,13 @@ It provides a single OpenAI-compatible endpoint (`/v1/*`) and routes traffic acr Core capabilities: -- OpenAI-compatible API surface for CLI/tools (177 providers, 38 executors) +- OpenAI-compatible API surface for CLI/tools (177 providers, 45 executors) - Request/response translation across provider formats - Model combo fallback (multi-model sequence) - Structured combo steps (`provider + model + connection`) with runtime ordering by `compositeTiers` - Account-level fallback (multi-account per provider) - Quota preflight and quota-aware P2C account selection in the main chat path -- OAuth + API-key provider connection management (14 OAuth modules) +- OAuth + API-key provider connection management (15 OAuth modules) - Embedding generation via `/v1/embeddings` (6 providers, 9 models) - Image generation via `/v1/images/generations` (10+ providers, 20+ models) - Audio transcription via `/v1/audio/transcriptions` (7 providers) @@ -66,7 +66,7 @@ Core capabilities: - Prompt injection guard middleware - Prompt compression pipeline with Caveman, RTK, stacked pipelines, compression combos, language packs, and analytics - ACP (Agent Communication Protocol) registry -- Modular OAuth providers (14 individual modules under `src/lib/oauth/providers/`) +- Modular OAuth providers (15 individual modules under `src/lib/oauth/providers/`) - Uninstall/full-uninstall scripts - OAuth environment repair action - WebSocket bridge for OpenAI-compatible WS clients (`/v1/ws`) @@ -321,10 +321,10 @@ Domain layer modules: - Eval runner: `src/lib/domain/evalRunner.ts` - Domain state persistence: `src/lib/db/domainState.ts` — SQLite CRUD for fallback chains, budgets, cost history, lockout state, circuit breakers -OAuth provider modules (14 individual files under `src/lib/oauth/providers/`): +OAuth provider modules (15 individual files under `src/lib/oauth/providers/`): - Registry index: `src/lib/oauth/providers/index.ts` -- Individual providers: `claude.ts`, `codex.ts`, `gemini.ts`, `antigravity.ts`, `qoder.ts`, `qwen.ts`, `kimi-coding.ts`, `github.ts`, `kiro.ts`, `cursor.ts`, `kilocode.ts`, `cline.ts`, `windsurf.ts`, `gitlab-duo.ts` +- Individual providers: `claude.ts`, `codex.ts`, `gemini.ts`, `antigravity.ts`, `qoder.ts`, `qwen.ts`, `kimi-coding.ts`, `github.ts`, `kiro.ts`, `cursor.ts`, `kilocode.ts`, `cline.ts`, `windsurf.ts`, `gitlab-duo.ts`, `trae.ts` - Thin wrapper: `src/lib/oauth/providers.ts` — re-exports from individual modules ## 5) Embedded Services (v3.8.4) diff --git a/docs/architecture/CODEBASE_DOCUMENTATION.md b/docs/architecture/CODEBASE_DOCUMENTATION.md index c794ec75a8..091da95dd8 100644 --- a/docs/architecture/CODEBASE_DOCUMENTATION.md +++ b/docs/architecture/CODEBASE_DOCUMENTATION.md @@ -451,7 +451,7 @@ open-sse/ ├── types.d.ts ├── config/ Provider registries, header profiles, identity, … ├── handlers/ Request handlers (chat, embeddings, audio, image, …) -├── executors/ 38 provider-specific HTTP executors +├── executors/ 45 provider-specific HTTP executors ├── translator/ Format conversion (OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro) ├── transformer/ Responses API ↔ Chat Completions stream transformer ├── services/ 80+ service modules (combos, fallback, quotas, identity, …) @@ -481,7 +481,7 @@ open-sse/ ### 4.2 `open-sse/executors/` -38 provider executors, each extending `BaseExecutor` (`base.ts`): +45 provider executors, each extending `BaseExecutor` (`base.ts`): `antigravity`, `azure-openai`, `blackbox-web`, `chatgpt-web`, `cliproxyapi`, `cloudflare-ai`, `codex`, `commandCode`, `cursor`, `default`, `devin-cli`, diff --git a/docs/i18n/ar/llm.txt b/docs/i18n/ar/llm.txt index 2d7b911135..cf07c6e39c 100644 --- a/docs/i18n/ar/llm.txt +++ b/docs/i18n/ar/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/az/llm.txt b/docs/i18n/az/llm.txt index 8fbda13d00..1c3e452045 100644 --- a/docs/i18n/az/llm.txt +++ b/docs/i18n/az/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/bg/llm.txt b/docs/i18n/bg/llm.txt index 8fbda13d00..1c3e452045 100644 --- a/docs/i18n/bg/llm.txt +++ b/docs/i18n/bg/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/bn/llm.txt b/docs/i18n/bn/llm.txt index a1ee3c6fce..c9d3f52d0f 100644 --- a/docs/i18n/bn/llm.txt +++ b/docs/i18n/bn/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/cs/llm.txt b/docs/i18n/cs/llm.txt index 89d6f56ccc..d83086912f 100644 --- a/docs/i18n/cs/llm.txt +++ b/docs/i18n/cs/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/da/llm.txt b/docs/i18n/da/llm.txt index 98e33c465b..8aa1a5dbe7 100644 --- a/docs/i18n/da/llm.txt +++ b/docs/i18n/da/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/de/llm.txt b/docs/i18n/de/llm.txt index 1064baca99..1fb2ca42fe 100644 --- a/docs/i18n/de/llm.txt +++ b/docs/i18n/de/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/es/llm.txt b/docs/i18n/es/llm.txt index 3de91a1f93..b23001b78a 100644 --- a/docs/i18n/es/llm.txt +++ b/docs/i18n/es/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/fa/llm.txt b/docs/i18n/fa/llm.txt index 88ee537c00..9ee1dd6096 100644 --- a/docs/i18n/fa/llm.txt +++ b/docs/i18n/fa/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/fi/llm.txt b/docs/i18n/fi/llm.txt index 565644dde4..68346a61f3 100644 --- a/docs/i18n/fi/llm.txt +++ b/docs/i18n/fi/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/fr/llm.txt b/docs/i18n/fr/llm.txt index 41772c229f..9b17ca0ba1 100644 --- a/docs/i18n/fr/llm.txt +++ b/docs/i18n/fr/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/gu/llm.txt b/docs/i18n/gu/llm.txt index 305d8f2940..b30fcb9dd5 100644 --- a/docs/i18n/gu/llm.txt +++ b/docs/i18n/gu/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/he/llm.txt b/docs/i18n/he/llm.txt index 9af5e6e11f..179b185f40 100644 --- a/docs/i18n/he/llm.txt +++ b/docs/i18n/he/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/hi/llm.txt b/docs/i18n/hi/llm.txt index 45a55c87f1..7f9de7eac0 100644 --- a/docs/i18n/hi/llm.txt +++ b/docs/i18n/hi/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/hu/llm.txt b/docs/i18n/hu/llm.txt index 724a881f97..5e3e76d8a4 100644 --- a/docs/i18n/hu/llm.txt +++ b/docs/i18n/hu/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/id/llm.txt b/docs/i18n/id/llm.txt index dcf770c476..789a065336 100644 --- a/docs/i18n/id/llm.txt +++ b/docs/i18n/id/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/in/llm.txt b/docs/i18n/in/llm.txt index 72b4d0a625..0117e08093 100644 --- a/docs/i18n/in/llm.txt +++ b/docs/i18n/in/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/it/llm.txt b/docs/i18n/it/llm.txt index a578c52aac..dcd38d593c 100644 --- a/docs/i18n/it/llm.txt +++ b/docs/i18n/it/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/ja/llm.txt b/docs/i18n/ja/llm.txt index 274e6af3cf..d59d33c068 100644 --- a/docs/i18n/ja/llm.txt +++ b/docs/i18n/ja/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/ko/llm.txt b/docs/i18n/ko/llm.txt index 4bed696ce5..88b061e7de 100644 --- a/docs/i18n/ko/llm.txt +++ b/docs/i18n/ko/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/mr/llm.txt b/docs/i18n/mr/llm.txt index 08aaed595b..502ca19e51 100644 --- a/docs/i18n/mr/llm.txt +++ b/docs/i18n/mr/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/ms/llm.txt b/docs/i18n/ms/llm.txt index 19d442c047..3e98f9e756 100644 --- a/docs/i18n/ms/llm.txt +++ b/docs/i18n/ms/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/nl/llm.txt b/docs/i18n/nl/llm.txt index 4c409c9a4c..b22ff420e0 100644 --- a/docs/i18n/nl/llm.txt +++ b/docs/i18n/nl/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/no/llm.txt b/docs/i18n/no/llm.txt index 39637d59f2..cdd5d80c99 100644 --- a/docs/i18n/no/llm.txt +++ b/docs/i18n/no/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/phi/llm.txt b/docs/i18n/phi/llm.txt index 6aa6c74d90..e1d2502840 100644 --- a/docs/i18n/phi/llm.txt +++ b/docs/i18n/phi/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/pl/llm.txt b/docs/i18n/pl/llm.txt index 64b1c0dc6e..a0e273caea 100644 --- a/docs/i18n/pl/llm.txt +++ b/docs/i18n/pl/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/pt-BR/llm.txt b/docs/i18n/pt-BR/llm.txt index 09c212f6af..d3704d3dd6 100644 --- a/docs/i18n/pt-BR/llm.txt +++ b/docs/i18n/pt-BR/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/pt/llm.txt b/docs/i18n/pt/llm.txt index 5326a252f0..544e4181a9 100644 --- a/docs/i18n/pt/llm.txt +++ b/docs/i18n/pt/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/ro/llm.txt b/docs/i18n/ro/llm.txt index d4ba9e1fd2..ad2459667f 100644 --- a/docs/i18n/ro/llm.txt +++ b/docs/i18n/ro/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/ru/llm.txt b/docs/i18n/ru/llm.txt index b6141c9a3d..594fa8cf9a 100644 --- a/docs/i18n/ru/llm.txt +++ b/docs/i18n/ru/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/sk/llm.txt b/docs/i18n/sk/llm.txt index d25838bca7..a540f6f899 100644 --- a/docs/i18n/sk/llm.txt +++ b/docs/i18n/sk/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/sv/llm.txt b/docs/i18n/sv/llm.txt index 7bbeddd0d1..0c1978f5b3 100644 --- a/docs/i18n/sv/llm.txt +++ b/docs/i18n/sv/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/sw/llm.txt b/docs/i18n/sw/llm.txt index 0558ad728a..55ddb2e1f5 100644 --- a/docs/i18n/sw/llm.txt +++ b/docs/i18n/sw/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/ta/llm.txt b/docs/i18n/ta/llm.txt index b4baab8668..73699fd3c7 100644 --- a/docs/i18n/ta/llm.txt +++ b/docs/i18n/ta/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/te/llm.txt b/docs/i18n/te/llm.txt index 25ec92230f..51189a3e45 100644 --- a/docs/i18n/te/llm.txt +++ b/docs/i18n/te/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/th/llm.txt b/docs/i18n/th/llm.txt index a60aa1000d..65bd0a7f17 100644 --- a/docs/i18n/th/llm.txt +++ b/docs/i18n/th/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/tr/llm.txt b/docs/i18n/tr/llm.txt index 7644906269..26cc60228b 100644 --- a/docs/i18n/tr/llm.txt +++ b/docs/i18n/tr/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/uk-UA/llm.txt b/docs/i18n/uk-UA/llm.txt index ef58d991bb..b301833be2 100644 --- a/docs/i18n/uk-UA/llm.txt +++ b/docs/i18n/uk-UA/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/ur/llm.txt b/docs/i18n/ur/llm.txt index fd045d0f7e..30e186b109 100644 --- a/docs/i18n/ur/llm.txt +++ b/docs/i18n/ur/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/vi/llm.txt b/docs/i18n/vi/llm.txt index 0316a800bc..1e6d0a50a5 100644 --- a/docs/i18n/vi/llm.txt +++ b/docs/i18n/vi/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/i18n/zh-CN/llm.txt b/docs/i18n/zh-CN/llm.txt index f6d97b9204..ff64cc3c48 100644 --- a/docs/i18n/zh-CN/llm.txt +++ b/docs/i18n/zh-CN/llm.txt @@ -12,7 +12,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -283,7 +283,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index 6ffd673952..1bd8d0dc14 100644 --- a/docs/reference/openapi.yaml +++ b/docs/reference/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.8.4 + version: 3.8.5 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, diff --git a/electron/package-lock.json b/electron/package-lock.json index 7c7f0c1d59..d45700fcf6 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute-desktop", - "version": "3.8.4", + "version": "3.8.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute-desktop", - "version": "3.8.4", + "version": "3.8.5", "license": "MIT", "dependencies": { "electron-updater": "^6.8.6" diff --git a/electron/package.json b/electron/package.json index fbef38aa4b..0fa6294848 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.8.4", + "version": "3.8.5", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/llm.txt b/llm.txt index 8626dff1aa..99c9c94b41 100644 --- a/llm.txt +++ b/llm.txt @@ -8,7 +8,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.3 +**Current version:** 3.8.5 ## Tech Stack @@ -279,7 +279,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.3) +## Key Features (v3.8.5) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/open-sse/package.json b/open-sse/package.json index 0a875e313d..94ad6ee29c 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.8.4", + "version": "3.8.5", "description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration", "type": "module", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index d44bbe85a1..44383309c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21210,7 +21210,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.8.4" + "version": "3.8.5" } } } From 5373b97ced13035f61fe91c2cc949aa140f33bbb Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 04:32:40 -0300 Subject: [PATCH 05/19] =?UTF-8?q?chore(release):=20bump=20to=20v3.8.5=20?= =?UTF-8?q?=E2=80=94=20changelog,=20docs,=20version=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1656817ca5..b7c984292f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ - **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) +### 🏆 Hall de Contribuidores + +Um agradecimento especial a todos que contribuíram com código, revisões e testes para este release: +@thanet-s + --- ## [3.8.4] — 2026-05-26 From 07fd7f20ccee68d341b70b1a6cfef9211a72c22a Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 04:36:15 -0300 Subject: [PATCH 06/19] chore(release): translate Hall of Contributors to English in workflows and changelog --- .agents/skills/generate-release/SKILL.md | 6 +++--- .agents/workflows/generate-release-ag.md | 6 +++--- CHANGELOG.md | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/skills/generate-release/SKILL.md b/.agents/skills/generate-release/SKILL.md index 88f5e81f93..fffecc3c62 100644 --- a/.agents/skills/generate-release/SKILL.md +++ b/.agents/skills/generate-release/SKILL.md @@ -123,9 +123,9 @@ Keep an empty `## [Unreleased]` section above it, separated by a horizontal rule - ... -### 🏆 Hall de Contribuidores +### 🏆 Hall of Contributors -Um agradecimento especial a todos que contribuíram com código, revisões e testes para este release: +A special thanks to everyone who contributed code, reviews, and tests for this release: @user1, @user2 --- @@ -133,7 +133,7 @@ Um agradecimento especial a todos que contribuíram com código, revisões e tes ## [3.6.9] — 2026-04-19 ``` -> **🔴 HALL DE CONTRIBUIDORES RULE**: You MUST parse all the PR author mentions (e.g., `(thanks @username)`) from the new version's changelog items, deduplicate them, and append them as a "Hall de Contribuidores" section at the end of the new release block, exactly as shown above. +> **🔴 HALL OF CONTRIBUTORS RULE**: You MUST parse all the PR author mentions (e.g., `(thanks @username)`) from the new version's changelog items, deduplicate them, and append them as a "Hall of Contributors" section at the end of the new release block, exactly as shown above. ### 5. Update openapi.yaml version ⚠️ MANDATORY diff --git a/.agents/workflows/generate-release-ag.md b/.agents/workflows/generate-release-ag.md index 1164a27001..c1ae6a8d5b 100644 --- a/.agents/workflows/generate-release-ag.md +++ b/.agents/workflows/generate-release-ag.md @@ -117,9 +117,9 @@ Keep an empty `## [Unreleased]` section above it, separated by a horizontal rule - ... -### 🏆 Hall de Contribuidores +### 🏆 Hall of Contributors -Um agradecimento especial a todos que contribuíram com código, revisões e testes para este release: +A special thanks to everyone who contributed code, reviews, and tests for this release: @user1, @user2 --- @@ -127,7 +127,7 @@ Um agradecimento especial a todos que contribuíram com código, revisões e tes ## [3.6.9] — 2026-04-19 ``` -> **🔴 HALL DE CONTRIBUIDORES RULE**: You MUST parse all the PR author mentions (e.g., `(thanks @username)`) from the new version's changelog items, deduplicate them, and append them as a "Hall de Contribuidores" section at the end of the new release block, exactly as shown above. +> **🔴 HALL OF CONTRIBUTORS RULE**: You MUST parse all the PR author mentions (e.g., `(thanks @username)`) from the new version's changelog items, deduplicate them, and append them as a "Hall of Contributors" section at the end of the new release block, exactly as shown above. ### 5. Update openapi.yaml version ⚠️ MANDATORY diff --git a/CHANGELOG.md b/CHANGELOG.md index b7c984292f..1b0343981d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,9 @@ - **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) -### 🏆 Hall de Contribuidores +### 🏆 Hall of Contributors -Um agradecimento especial a todos que contribuíram com código, revisões e testes para este release: +A special thanks to everyone who contributed code, reviews, and tests for this release: @thanet-s --- From 744039403d953e1a2a18ed6e4ebf5bdbf07f5a64 Mon Sep 17 00:00:00 2001 From: Randi <55005611+rdself@users.noreply.github.com> Date: Wed, 27 May 2026 04:54:00 -0400 Subject: [PATCH 07/19] fix(combos): make target timeout configurable (#2775) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge PR #2775 — fix(combos): make target timeout configurable --- docs/guides/USER_GUIDE.md | 3 ++ docs/reference/ENVIRONMENT.md | 5 ++ open-sse/services/combo.ts | 35 ++++++++---- open-sse/services/comboConfig.ts | 22 ++++++++ src/app/(dashboard)/dashboard/combos/page.tsx | 54 +++++++++++++++++-- .../settings/components/ComboDefaultsTab.tsx | 35 ++++++++++++ src/shared/utils/runtimeTimeouts.ts | 1 + src/shared/validation/schemas.ts | 2 + tests/unit/combo-config.test.ts | 36 ++++++++++++- 9 files changed, 178 insertions(+), 15 deletions(-) diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index b7dd44ca52..2cf8b50ff5 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -973,6 +973,9 @@ Configure per-combo balancing in **Dashboard → Combos → Create/Edit → Stra | **Cost-Optimized** | Routes to the cheapest available model (uses pricing table) | Global combo defaults can be set in **Dashboard → Settings → Routing → Combo Defaults**. +Combo target timeouts inherit the current request timeout by default. Use **Target timeout +(seconds)** on combo defaults or an individual combo only when a shorter per-target limit should +trigger faster fallback. --- diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index d4dc0b6b83..c7d86cf066 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -549,6 +549,11 @@ REQUEST_TIMEOUT_MS (global override) | `OMNIROUTE_PPLX_TLS_TIMEOUT_MS` | `30000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`perplexityTlsClient.ts`). | | `OMNIROUTE_PPLX_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | +Combo target attempts inherit the resolved upstream request timeout (`FETCH_TIMEOUT_MS`, or +`REQUEST_TIMEOUT_MS` when it supplies the fetch default). Set `targetTimeoutMs` in a combo, +combo defaults, or provider override only to make combo fallback faster; values above the +current upstream timeout are capped to the upstream timeout. + ### Circuit Breaker Thresholds Provider-level circuit breaker tuning. Defaults reflect the scaled values used since v3.6 for 500+ connections. diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 2ed8c81fcb..743d0cb96d 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -14,7 +14,7 @@ import { isProviderFailureCode, isProviderExhaustedReason, } from "./accountFallback.ts"; -import { RateLimitReason } from "../config/constants.ts"; +import { FETCH_TIMEOUT_MS, RateLimitReason } from "../config/constants.ts"; import { errorResponse, unavailableResponse } from "../utils/error.ts"; import { clamp01 } from "../utils/number.ts"; import { @@ -23,7 +23,11 @@ import { recordComboShadowRequest, getComboMetrics, } from "./comboMetrics.ts"; -import { resolveComboConfig, getDefaultComboConfig } from "./comboConfig.ts"; +import { + resolveComboConfig, + getDefaultComboConfig, + resolveComboTargetTimeoutMs, +} from "./comboConfig.ts"; import { maybeGenerateHandoff, resolveContextRelayConfig, @@ -106,7 +110,6 @@ function isAllAccountsRateLimitedResponse( const MAX_COMBO_DEPTH = 3; const MAX_FALLBACK_WAIT_MS = 5000; const MAX_GLOBAL_ATTEMPTS = 30; -const COMBO_MODEL_TIMEOUT_MS = 30_000; // 30s per model attempt within a combo (default FETCH_TIMEOUT_MS=600s) function resolveDelayMs(value: unknown, fallback: number): number { const numericValue = Number(value); @@ -2582,9 +2585,17 @@ export async function handleComboChat({ : handleSingleModel; // ───────────────────────────────────────────────────────────────────────── + // Use config cascade before dispatch so all strategies, pinned context routes, + // and round-robin targets share the same timeout policy. + const config = settings + ? resolveComboConfig(combo, settings) + : { ...getDefaultComboConfig(), ...(combo.config || {}) }; + const comboTargetTimeoutMs = resolveComboTargetTimeoutMs(config, FETCH_TIMEOUT_MS); + // ── Per-model timeout wrapper ──────────────────────────────────────────── - // Default FETCH_TIMEOUT_MS is 600s per model. For combos, we use a shorter - // per-model timeout so slow/hanging models don't block fallback. + // Combo target timeouts inherit FETCH_TIMEOUT_MS by default. Operators can + // configure targetTimeoutMs to shorten fallback latency, but never to extend + // beyond the current upstream request timeout. // // The timeoutController is forwarded to the inner caller via target.modelAbortSignal. // When the timeout fires we (a) resolve the race with a synthetic 524 and @@ -2596,6 +2607,12 @@ export async function handleComboChat({ modelStr: string, target?: SingleModelTarget ): Promise => { + if (comboTargetTimeoutMs <= 0) { + return handleSingleModelWrapped(b, modelStr, target).catch((err) => + errorResponse(502, err?.message ?? "Upstream model error") + ); + } + const timeoutController = new AbortController(); let timeoutId: ReturnType | undefined; let timedOut = false; @@ -2604,7 +2621,7 @@ export async function handleComboChat({ timedOut = true; log.warn( "COMBO", - `Model ${modelStr} exceeded ${COMBO_MODEL_TIMEOUT_MS}ms timeout — falling back` + `Model ${modelStr} exceeded ${comboTargetTimeoutMs}ms timeout — falling back` ); // Abort the inner request so its upstream fetch is cancelled and // downstream cooldown/breaker/usage mutations don't continue mutating @@ -2616,7 +2633,7 @@ export async function handleComboChat({ headers: { "Content-Type": "application/json" }, }) ); - }, COMBO_MODEL_TIMEOUT_MS); + }, comboTargetTimeoutMs); }); const targetWithSignal = { ...(target ?? {}), @@ -2663,10 +2680,6 @@ export async function handleComboChat({ }); } - // Use config cascade if settings provided - const config = settings - ? resolveComboConfig(combo, settings) - : { ...getDefaultComboConfig(), ...(combo.config || {}) }; const maxRetries = config.maxRetries ?? 1; const retryDelayMs = resolveDelayMs(config.retryDelayMs, 2000); const fallbackDelayMs = resolveDelayMs(config.fallbackDelayMs, 0); diff --git a/open-sse/services/comboConfig.ts b/open-sse/services/comboConfig.ts index 86887f64d6..6c2c1a6994 100644 --- a/open-sse/services/comboConfig.ts +++ b/open-sse/services/comboConfig.ts @@ -5,6 +5,8 @@ * Most specific wins. */ +import { MAX_TIMER_TIMEOUT_MS } from "../../src/shared/utils/runtimeTimeouts.ts"; + const DEFAULT_COMBO_CONFIG = { strategy: "priority", maxRetries: 1, @@ -79,6 +81,26 @@ function isRecord(value: unknown): value is ComboConfigRecord { return !!value && typeof value === "object" && !Array.isArray(value); } +function normalizePositiveTimeoutMs(value: unknown): number { + const numericValue = Number(value); + if (!Number.isFinite(numericValue) || numericValue <= 0) return 0; + return Math.min(Math.floor(numericValue), MAX_TIMER_TIMEOUT_MS); +} + +export function resolveComboTargetTimeoutMs( + config: Record | null | undefined, + upstreamTimeoutMs: number +): number { + const inheritedTimeoutMs = normalizePositiveTimeoutMs(upstreamTimeoutMs); + const configuredTimeoutMs = isRecord(config) + ? normalizePositiveTimeoutMs(config.targetTimeoutMs) + : 0; + + if (configuredTimeoutMs <= 0) return inheritedTimeoutMs; + if (inheritedTimeoutMs <= 0) return configuredTimeoutMs; + return Math.min(configuredTimeoutMs, inheritedTimeoutMs); +} + /** * Resolve effective config for a combo, applying cascade: * DEFAULT_COMBO_CONFIG → settings.comboDefaults → settings.providerOverrides[provider] → combo.config diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 409b86ad19..b204bca1e4 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -159,6 +159,8 @@ const ADVANCED_FIELD_HELP_FALLBACK = { "How long a request can wait for a round-robin model slot before timing out. This queue is separate from any account-only concurrency cap.", failoverBeforeRetry: "When enabled, a 429 from the upstream triggers immediate target failover instead of retrying the same URL first.", + targetTimeoutMs: + "Optional combo target timeout. Empty inherits the current request timeout; larger values are capped to that timeout.", maxSetRetries: "Number of times to retry the full target set when every target fails. 0 = no set-level retry.", setRetryDelayMs: @@ -170,6 +172,20 @@ const LEGACY_COMBO_RESILIENCE_KEYS = new Set([ "healthCheckEnabled", "healthCheckTimeoutMs", ]); +const MS_PER_SECOND = 1000; + +function msToOptionalSecondsInput(value) { + const ms = Number(value); + if (!Number.isFinite(ms) || ms <= 0) return ""; + return String(Math.round(ms / MS_PER_SECOND)); +} + +function secondsInputToOptionalMs(value, maxSeconds = 86400) { + if (!value) return undefined; + const seconds = Number(value); + if (!Number.isFinite(seconds) || seconds <= 0) return undefined; + return Math.min(maxSeconds, Math.round(seconds)) * MS_PER_SECOND; +} function sanitizeComboRuntimeConfig(config) { if (!config || typeof config !== "object") return {}; @@ -1429,10 +1445,12 @@ function StrategyRecommendationsPanel({ strategy, onApply, showNudge }) { ); } -function FieldLabelWithHelp({ label, help, showHelp = true }) { +function FieldLabelWithHelp({ label, help, showHelp = true, htmlFor = undefined }) { return (
- + {showHelp && ( @@ -2691,7 +2709,9 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo configToSave.concurrencyPerModel = config.concurrencyPerModel; if (config.queueTimeoutMs !== undefined) configToSave.queueTimeoutMs = config.queueTimeoutMs; } - if (Object.keys(configToSave).length > 0) { + const hasConfigToSave = Object.keys(configToSave).length > 0; + const hadExistingConfig = Object.keys(sanitizeComboRuntimeConfig(combo?.config)).length > 0; + if (hasConfigToSave || (isEdit && hadExistingConfig)) { saveData.config = configToSave; } @@ -3566,6 +3586,34 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none" />
+
+ + + setConfig({ + ...config, + targetTimeoutMs: secondsInputToOptionalMs(e.target.value), + }) + } + className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none" + /> +
{/* failoverBeforeRetry + maxSetRetries + setRetryDelayMs */}
diff --git a/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx index 82d97fd0b7..6f73726865 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx @@ -27,12 +27,24 @@ function msToSeconds(value: unknown): number { return Math.round(ms / MS_PER_SECOND); } +function msToOptionalSecondsInput(value: unknown): string { + const ms = Number(value); + if (!Number.isFinite(ms) || ms <= 0) return ""; + return String(Math.round(ms / MS_PER_SECOND)); +} + function secondsInputToMs(value: string, maxSeconds: number): number { const seconds = Number(value); if (!Number.isFinite(seconds) || seconds <= 0) return 0; return Math.min(maxSeconds, Math.round(seconds)) * MS_PER_SECOND; } +function secondsInputToOptionalMs(value: string, maxSeconds = 86400): number | undefined { + const seconds = Number(value); + if (!Number.isFinite(seconds) || seconds <= 0) return undefined; + return Math.min(maxSeconds, Math.round(seconds)) * MS_PER_SECOND; +} + function translateOrFallback( t: ReturnType, key: string, @@ -339,7 +351,30 @@ export default function ComboDefaultsTab() { className="text-sm" /> ))} + + setComboDefaults((prev) => ({ + ...prev, + targetTimeoutMs: secondsInputToOptionalMs(e.target.value), + })) + } + className="text-sm" + />
+

+ {translateOrFallback( + t, + "targetTimeoutHint", + "Combo targets inherit the current request timeout by default. Set a lower value here only when you want faster fallback." + )} +

diff --git a/src/shared/utils/runtimeTimeouts.ts b/src/shared/utils/runtimeTimeouts.ts index 3e656fd9a0..446f151c4b 100644 --- a/src/shared/utils/runtimeTimeouts.ts +++ b/src/shared/utils/runtimeTimeouts.ts @@ -8,6 +8,7 @@ type ReadTimeoutOptions = { export const DEFAULT_FETCH_TIMEOUT_MS = 600_000; export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 600_000; +export const MAX_TIMER_TIMEOUT_MS = 2_147_483_647; export const DEFAULT_SSE_HEARTBEAT_INTERVAL_MS = 15_000; export const DEFAULT_STREAM_READINESS_TIMEOUT_MS = 80_000; export const DEFAULT_FETCH_CONNECT_TIMEOUT_MS = 30_000; diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 1ae332ade8..142abfe1d1 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -9,6 +9,7 @@ import { COMBO_CONFIG_MODES } from "@/shared/constants/comboConfigMode"; import { providerAllowsOptionalApiKey } from "@/shared/constants/providers"; import { HIDEABLE_SIDEBAR_ITEM_IDS } from "@/shared/constants/sidebarVisibility"; import { isForbiddenUpstreamHeaderName } from "@/shared/constants/upstreamHeaders"; +import { MAX_TIMER_TIMEOUT_MS } from "@/shared/utils/runtimeTimeouts"; function isHttpUrl(value: string): boolean { try { @@ -570,6 +571,7 @@ const comboRuntimeConfigSchema = z retryDelayMs: z.coerce.number().int().min(0).max(60000).optional(), fallbackDelayMs: z.coerce.number().int().min(0).max(60000).optional(), timeoutMs: z.coerce.number().int().min(1000).optional(), + targetTimeoutMs: z.coerce.number().int().min(0).max(MAX_TIMER_TIMEOUT_MS).optional(), concurrencyPerModel: z.coerce.number().int().min(1).max(20).optional(), queueTimeoutMs: z.coerce.number().int().min(1000).max(120000).optional(), healthCheckEnabled: z.boolean().optional(), diff --git a/tests/unit/combo-config.test.ts b/tests/unit/combo-config.test.ts index acc15cc0ec..65600fdb0a 100644 --- a/tests/unit/combo-config.test.ts +++ b/tests/unit/combo-config.test.ts @@ -1,10 +1,11 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { resolveComboConfig, getDefaultComboConfig } = +const { resolveComboConfig, getDefaultComboConfig, resolveComboTargetTimeoutMs } = await import("../../open-sse/services/comboConfig.ts"); const { createComboSchema, updateComboDefaultsSchema } = await import("../../src/shared/validation/schemas.ts"); +const { MAX_TIMER_TIMEOUT_MS } = await import("../../src/shared/utils/runtimeTimeouts.ts"); test("getDefaultComboConfig returns a fresh copy of the defaults", () => { const first = getDefaultComboConfig(); @@ -41,10 +42,12 @@ test("resolveComboConfig applies the full cascade from defaults to combo overrid comboDefaults: { strategy: "round-robin", timeoutMs: 120000, + targetTimeoutMs: 90000, }, providerOverrides: { openai: { timeoutMs: 60000, + targetTimeoutMs: 45000, retryDelayMs: 500, fallbackDelayMs: 100, }, @@ -57,6 +60,7 @@ test("resolveComboConfig applies the full cascade from defaults to combo overrid assert.equal(result.retryDelayMs, 500); assert.equal(result.fallbackDelayMs, 100); assert.equal(result.maxRetries, 4); + assert.equal(result.targetTimeoutMs, 45000); assert.ok(!("timeoutMs" in result)); assert.ok(!("healthCheckEnabled" in result)); }); @@ -122,16 +126,46 @@ test("updateComboDefaultsSchema accepts arbitrarily large timeout defaults and p const parsed = updateComboDefaultsSchema.parse({ comboDefaults: { timeoutMs: 3600000, + targetTimeoutMs: 30000, }, providerOverrides: { anthropic: { timeoutMs: 5400000, + targetTimeoutMs: 45000, }, }, }); assert.equal(parsed.comboDefaults.timeoutMs, 3600000); + assert.equal(parsed.comboDefaults.targetTimeoutMs, 30000); assert.equal(parsed.providerOverrides.anthropic.timeoutMs, 5400000); + assert.equal(parsed.providerOverrides.anthropic.targetTimeoutMs, 45000); +}); + +test("resolveComboTargetTimeoutMs inherits the upstream timeout and only shortens it", () => { + assert.equal(resolveComboTargetTimeoutMs({}, 600000), 600000); + assert.equal(resolveComboTargetTimeoutMs({ targetTimeoutMs: 30000 }, 600000), 30000); + assert.equal(resolveComboTargetTimeoutMs({ targetTimeoutMs: 900000 }, 600000), 600000); + assert.equal(resolveComboTargetTimeoutMs({ targetTimeoutMs: 0 }, 600000), 600000); + assert.equal(resolveComboTargetTimeoutMs({ targetTimeoutMs: 30000 }, 0), 30000); + assert.equal(resolveComboTargetTimeoutMs({}, 0), 0); + assert.equal( + resolveComboTargetTimeoutMs({ targetTimeoutMs: 999999999999 }, 0), + MAX_TIMER_TIMEOUT_MS + ); + assert.equal(resolveComboTargetTimeoutMs({}, 999999999999), MAX_TIMER_TIMEOUT_MS); +}); + +test("combo timeout schema rejects values beyond the safe timer limit", () => { + const result = createComboSchema.safeParse({ + name: "unsafe-timeout", + models: ["openai/gpt-4"], + config: { + targetTimeoutMs: MAX_TIMER_TIMEOUT_MS + 1, + }, + }); + + assert.equal(result.success, false); }); test("resolveComboConfig preserves explicit empty handoffProviders overrides", () => { From 84a2bc5fbcdd24c8e437fba042c40dc820f6c54b Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Wed, 27 May 2026 10:55:56 +0200 Subject: [PATCH 08/19] feat: fix so restart of server restarts batch jobs instead of failing them (#2755) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge PR #2755 — feat: fix so restart of server restarts batch jobs instead of failing them --- .dockerignore | 9 ++-- src/sse/handlers/chat.ts | 110 ++++++++++++--------------------------- 2 files changed, 36 insertions(+), 83 deletions(-) diff --git a/.dockerignore b/.dockerignore index 0a050c9dc1..171c9d4631 100644 --- a/.dockerignore +++ b/.dockerignore @@ -38,14 +38,11 @@ playwright-report blob-report # Documentation -# Issue #2348: The Dashboard Docs viewer reads markdown from `/app/docs` at -# runtime. The previous `docs/*` block hid every file except openapi.yaml, -# so the in-product help screen failed with ENOENT for every page. # Translations (~51 MB) are excluded — the Docs viewer reads English sources. -# v3.8.3+ (fumadocs-mdx): `npm run build` webpack-bundles docs — keep assets referenced -# from English markdown (docs/diagrams/exported/*.svg, docs/screenshots/*.png). +# Screenshots (~1.7 MB) and SVGs (~250 KB) are needed at build time for MDX +# image resolution (fumadocs-mdx bundles them). +# Raster sources under docs/diagrams/ only (exported SVGs are required). docs/i18n/** -# Raster sources under docs/diagrams/ only (exported SVGs are required at build time). docs/diagrams/**/*.png docs/diagrams/**/*.jpg docs/diagrams/**/*.jpeg diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 8517453ab8..ca5da75c62 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -123,7 +123,7 @@ const COMBOS_CACHE_TTL_MS = 10_000; async function getCombosCachedForChat(): Promise { const now = Date.now(); - if (combosCachePromise !== null && now - combosCacheTs < COMBOS_CACHE_TTL_MS) { + if (combosCachePromise && now - combosCacheTs < COMBOS_CACHE_TTL_MS) { return combosCachePromise; } @@ -153,27 +153,6 @@ function intersectAllowedConnectionIds(primary: unknown, secondary: unknown): st const PROVIDER_BREAKER_FAILURE_STATUSES = new Set([408, 500, 502, 503, 504]); -/** - * Wrap a Request-like object so callers reading `.signal` see a merged signal - * combining the original request signal with an extra per-call signal (e.g. a - * combo per-model timeout). Returns the original request unchanged when no - * extra signal is provided, so the hot path stays a no-op. - */ -function wrapRequestWithExtraSignal(request: any, extraSignal: AbortSignal | null) { - if (!extraSignal || !request) return request; - const baseSignal: AbortSignal | null | undefined = request?.signal ?? null; - const mergedSignal: AbortSignal = baseSignal - ? AbortSignal.any([baseSignal, extraSignal]) - : extraSignal; - return new Proxy(request, { - get(target, prop, receiver) { - if (prop === "signal") return mergedSignal; - const value = target[prop]; - return typeof value === "function" ? value.bind(target) : value; - }, - }); -} - /** * Handle chat completion request * Supports: OpenAI, Claude, Gemini, OpenAI Responses API formats @@ -221,9 +200,6 @@ export async function handleChat(request: any, clientRawRequest: any = null) { // Log request endpoint and model const url = new URL(request.url); - // `let` because the middleware-hook pipeline (line ~319) may reassign this - // when a hook rewrites the target model. Previously declared `const`, which - // broke turbopack/strict-mode builds (PR [PR #2670](file:///home/diegosouzapw/dev/proxys/OmniRoute/package.json#L2670) regression). let modelStr = body.model; // Count messages (support both messages[] and input[] formats) @@ -332,7 +308,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) { >, model: modelStr, combo: undefined, - apiKeyInfo: apiKeyInfo as unknown as Record | undefined, + apiKeyInfo: apiKeyInfo as Record | undefined, log, }); @@ -493,11 +469,6 @@ export async function handleChat(request: any, clientRawRequest: any = null) { resolvedModel, { sessionKey: sessionAffinityKey, - sessionAffinityTtlMs: Number.isFinite( - Number((settings as any)?.codexSessionAffinityTtlMs) - ) - ? Number((settings as any).codexSessionAffinityTtlMs) - : null, ...(target?.connectionId ? { forcedConnectionId: target.connectionId } : {}), } ); @@ -530,15 +501,13 @@ export async function handleChat(request: any, clientRawRequest: any = null) { stepId?: string | null; allowedConnectionIds?: string[] | null; failoverBeforeRetry?: boolean; - trafficType?: "production" | "shadow"; - modelAbortSignal?: AbortSignal | null; } ) => handleSingleModelChat( b, m, clientRawRequest, - wrapRequestWithExtraSignal(request, target?.modelAbortSignal ?? null), + request, combo.name, apiKeyInfo, telemetry, @@ -551,7 +520,6 @@ export async function handleChat(request: any, clientRawRequest: any = null) { comboStepId: target?.stepId || null, comboExecutionKey: target?.executionKey || target?.stepId || null, skipUpstreamRetry: target?.failoverBeforeRetry ?? false, - trafficType: target?.trafficType === "shadow" ? "shadow" : "production", preselectedCredentials: comboPreselectedCredentials.get( getComboCredentialCacheKey(m, target) ), @@ -566,7 +534,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) { allCombos, apiKeyAllowedConnections: apiKeyInfo?.allowedConnections ?? null, relayOptions: - combo.strategy === "context-relay" || combo.strategy === "auto" + combo.strategy === "context-relay" ? { sessionId, config: relayConfig, @@ -685,7 +653,6 @@ async function handleSingleModelChat( skipUpstreamRetry?: boolean; preselectedCredentials?: any; cachedSettings?: any; - trafficType?: "production" | "shadow"; } = {}, comboStrategy: string | null = null, isCombo: boolean = false @@ -709,12 +676,21 @@ async function handleSingleModelChat( return handleComboChat({ body, combo: redirectCombo, - handleSingleModel: (b: any, m: string, target?: any) => + handleSingleModel: ( + b: any, + m: string, + target?: { + connectionId?: string | null; + executionKey?: string | null; + stepId?: string | null; + failoverBeforeRetry?: boolean; + } + ) => handleSingleModelChat( b, m, clientRawRequest, - wrapRequestWithExtraSignal(request, target?.modelAbortSignal ?? null), + request, redirectCombo.name ?? modelStr, apiKeyInfo, telemetry, @@ -726,7 +702,6 @@ async function handleSingleModelChat( comboStepId: null, comboExecutionKey: null, skipUpstreamRetry: target?.failoverBeforeRetry ?? false, - trafficType: target?.trafficType === "shadow" ? "shadow" : "production", }, redirectCombo.strategy ?? "priority", false @@ -742,7 +717,6 @@ async function handleSingleModelChat( const { provider, model, sourceFormat, targetFormat, extendedContext, apiFormat } = resolved; const forceLiveComboTest = runtimeOptions.forceLiveComboTest === true; - const isShadowTraffic = runtimeOptions.trafficType === "shadow"; const hasForcedConnection = typeof runtimeOptions.forcedConnectionId === "string" && runtimeOptions.forcedConnectionId.trim().length > 0; @@ -837,11 +811,6 @@ async function handleSingleModelChat( { sessionKey: runtimeOptions.sessionAffinityKey ?? runtimeOptions.sessionId ?? null, excludeConnectionIds: Array.from(excludedConnectionIds), - sessionAffinityTtlMs: Number.isFinite( - Number((runtimeOptions.cachedSettings as any)?.codexSessionAffinityTtlMs) - ) - ? Number((runtimeOptions.cachedSettings as any).codexSessionAffinityTtlMs) - : null, ...(forceLiveComboTest ? { allowSuppressedConnections: true, @@ -953,11 +922,7 @@ async function handleSingleModelChat( ...(workspaceId ? { workspaceId } : {}), }); } - if ( - !isShadowTraffic && - runtimeOptions.sessionId && - body?._omnirouteInternalRequest !== "context-handoff" - ) { + if (runtimeOptions.sessionId && body?._omnirouteInternalRequest !== "context-handoff") { touchSession(runtimeOptions.sessionId, credentials.connectionId); startQuotaMonitor( runtimeOptions.sessionId, @@ -972,7 +937,7 @@ async function handleSingleModelChat( // 4. Execute chat via core after breaker gate checks (with optional TLS tracking) if (telemetry) telemetry.startPhase("connect"); const { result, tlsFingerprintUsed } = await executeChatWithBreaker({ - bypassCircuitBreaker: forceLiveComboTest || hasForcedConnection || isShadowTraffic, + bypassCircuitBreaker: forceLiveComboTest || hasForcedConnection, breaker, body: requestBody, provider, @@ -994,7 +959,6 @@ async function handleSingleModelChat( providerProfile, cachedSettings: runtimeOptions.cachedSettings, skipUpstreamRetry: runtimeOptions.skipUpstreamRetry ?? false, - trafficType: isShadowTraffic ? "shadow" : "production", }); if (telemetry) telemetry.endPhase(); @@ -1021,13 +985,11 @@ async function handleSingleModelChat( }); if (result.success) { - if (!isShadowTraffic) { - clearModelLock(provider, credentials.connectionId, model); - } - if (!forceLiveComboTest && !isShadowTraffic) { + clearModelLock(provider, credentials.connectionId, model); + if (!forceLiveComboTest) { breaker._onSuccess(); } - if (!isShadowTraffic && injectedHandoff && runtimeOptions.sessionId && comboName) { + if (injectedHandoff && runtimeOptions.sessionId && comboName) { deleteHandoff(runtimeOptions.sessionId, comboName); } if (telemetry) telemetry.startPhase("finalize"); @@ -1111,7 +1073,7 @@ async function handleSingleModelChat( // Emergency fallback for budget exhaustion (402 / billing / quota keywords): // reroute to a free model (default provider/model: nvidia + openai/gpt-oss-120b) exactly once. - if (!isShadowTraffic && !isCombo && !runtimeOptions.emergencyFallbackTried) { + if (!runtimeOptions.emergencyFallbackTried) { const fallbackDecision = shouldUseFallback( Number(result.status || 0), String(result.error || ""), @@ -1178,11 +1140,7 @@ async function handleSingleModelChat( // Daily quota lockout overrides subsequent rate_limited lockout, ensuring lockout until tomorrow 0:00 let dailyQuotaExhausted = false; const errorStr = String(result.error || ""); - if (isCombo && comboStrategy !== "context-relay" && result.status === 429) { - return result.response; - } - - if (!isShadowTraffic && result.status === 429 && isDailyQuotaExhausted(errorStr)) { + if (result.status === 429 && isDailyQuotaExhausted(errorStr)) { // Parse which model is quota-limited const match = errorStr.match(/today's quota for model ([^,]+)/); const limitedModel = match ? match[1].trim() : model; @@ -1214,7 +1172,7 @@ async function handleSingleModelChat( // 7. Mark account as quota-exhausted only for explicit long-window quota signals. // A plain 429/high-traffic response should trigger fallback/cooldown, not poison // quotaCache as exhausted for 5 minutes while usage quota may still be available. - if (!isShadowTraffic && !dailyQuotaExhausted) { + if (!dailyQuotaExhausted) { const passthroughModels = credentials.providerSpecificData?.passthroughModels; const failureKind = result.status === 429 @@ -1238,17 +1196,16 @@ async function handleSingleModelChat( const is401 = result.status === 401; const skipConnectionDisable = is401 && hasExtraKeys; - const { shouldFallback, cooldownMs } = - skipConnectionDisable || isShadowTraffic - ? { shouldFallback: false, cooldownMs: 0 } - : await markAccountUnavailable( - credentials.connectionId, - result.status, - result.error, - provider, - model, - providerProfile - ); + const { shouldFallback, cooldownMs } = skipConnectionDisable + ? { shouldFallback: false, cooldownMs: 0 } + : await markAccountUnavailable( + credentials.connectionId, + result.status, + result.error, + provider, + model, + providerProfile + ); if (shouldFallback) { if (Number.isFinite(cooldownMs) && cooldownMs > 0) { @@ -1266,7 +1223,6 @@ async function handleSingleModelChat( if ( !forceLiveComboTest && - !isShadowTraffic && !isCombo && PROVIDER_BREAKER_FAILURE_STATUSES.has(Number(result.status)) ) { From 30753d4dd59a3d052ff309ab94c8567425ab9c9d Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 05:56:32 -0300 Subject: [PATCH 09/19] chore(release): update changelog with merged PRs notes and credits --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b0343981d..f2956fd2d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,14 @@ ## [3.8.5] — 2026-05-27 +### ✨ New Features + +- **batch:** recover stale `in_progress` and `finalizing` batches back to `validating` state on startup, reset counters, and apply a configurable concurrency limit (`BATCH_MAX_CONCURRENT`) (#2755 — thanks @hartmark) + ### 🔧 Bug Fixes - **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) ### ⚡ Performance / CI @@ -17,7 +22,7 @@ ### 🏆 Hall of Contributors A special thanks to everyone who contributed code, reviews, and tests for this release: -@thanet-s +@hartmark, @rdself, @thanet-s --- From af5635e422cd439849e756aa4608e29d426396bc Mon Sep 17 00:00:00 2001 From: Jack <5443152+hijak@users.noreply.github.com> Date: Wed, 27 May 2026 10:20:40 +0100 Subject: [PATCH 10/19] feat(api): add endpoint restrictions for client API keys (#2777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge PR #2777 — feat(api): add endpoint restrictions for client API keys --- .../api-manager/ApiManagerPageClient.tsx | 103 ++++++++- src/i18n/messages/en.json | 3 + src/lib/db/apiKeys.ts | 27 ++- src/shared/constants/endpointCategories.ts | 132 +++++++++++ src/shared/utils/apiKeyPolicy.ts | 22 ++ src/shared/validation/schemas.ts | 4 +- tests/unit/endpoint-categories.test.ts | 115 ++++++++++ .../unit/endpoint-restrictions-policy.test.ts | 210 ++++++++++++++++++ 8 files changed, 610 insertions(+), 6 deletions(-) create mode 100644 src/shared/constants/endpointCategories.ts create mode 100644 tests/unit/endpoint-categories.test.ts create mode 100644 tests/unit/endpoint-restrictions-policy.test.ts diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index f045ad2144..81ffb76516 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -5,6 +5,7 @@ import { Card, Button, Input, Modal, CardSkeleton } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { useTranslations } from "next-intl"; import { getProviderDisplayName } from "@/lib/display/names"; +import { ENDPOINT_CATEGORIES } from "@/shared/constants/endpointCategories"; import ApiKeyFilterBar from "./components/ApiKeyFilterBar"; import { isKeyActive, @@ -88,6 +89,7 @@ interface ApiKey { accessSchedule?: AccessSchedule | null; rateLimits?: Array<{ limit: number; window: number }> | null; scopes?: string[]; + allowedEndpoints?: string[]; createdAt: string; } @@ -461,7 +463,8 @@ export default function ApiManagerPageClient() { maxSessions: number, accessSchedule: AccessSchedule | null, rateLimits: Array<{ limit: number; window: number }> | null, - scopes: string[] + scopes: string[], + allowedEndpoints: string[] ) => { if (!editingKey || !editingKey.id) return; @@ -521,6 +524,7 @@ export default function ApiManagerPageClient() { accessSchedule, rateLimits, scopes, + allowedEndpoints, }), }); @@ -1161,7 +1165,8 @@ const PermissionsModal = memo(function PermissionsModal({ maxSessions: number, accessSchedule: AccessSchedule | null, rateLimits: Array<{ limit: number; window: number }> | null, - scopes: string[] + scopes: string[], + allowedEndpoints: string[] ) => void; }) { const t = useTranslations("apiManager"); @@ -1218,6 +1223,10 @@ const PermissionsModal = memo(function PermissionsModal({ return new Set(); }); + const initialEndpoints = Array.isArray(apiKey?.allowedEndpoints) ? apiKey.allowedEndpoints : []; + const [selectedEndpoints, setSelectedEndpoints] = useState(initialEndpoints); + const [allowAllEndpoints, setAllowAllEndpoints] = useState(initialEndpoints.length === 0); + // Memoize callbacks to prevent child re-renders const handleToggleModel = useCallback( (modelId: string) => { @@ -1304,6 +1313,16 @@ const PermissionsModal = memo(function PermissionsModal({ [allowAllConnections] ); + const handleToggleEndpoint = useCallback( + (categoryId: string) => { + if (allowAllEndpoints) return; + setSelectedEndpoints((prev) => + prev.includes(categoryId) ? prev.filter((e) => e !== categoryId) : [...prev, categoryId] + ); + }, + [allowAllEndpoints] + ); + const handleSave = useCallback(() => { // Clear previous inline errors setNameError(null); @@ -1351,7 +1370,8 @@ const PermissionsModal = memo(function PermissionsModal({ maxSessions, schedule, rateLimits.length > 0 ? rateLimits : null, - manageEnabled ? ["manage"] : [] + manageEnabled ? ["manage"] : [], + allowAllEndpoints ? [] : selectedEndpoints ); }, [ onSave, @@ -1376,6 +1396,8 @@ const PermissionsModal = memo(function PermissionsModal({ scheduleDays, scheduleTz, rateLimits, + allowAllEndpoints, + selectedEndpoints, t, ]); @@ -2155,6 +2177,81 @@ const PermissionsModal = memo(function PermissionsModal({
)} + {/* Allowed Endpoints Section */} +
+
+
+

{t("endpointRestrictions")}

+

+ {allowAllEndpoints + ? t("allEndpointsAllowed") + : t("endpointsRestricted", { + count: selectedEndpoints.length, + })} +

+
+
+ + +
+
+ {!allowAllEndpoints && ( +
+ {ENDPOINT_CATEGORIES.map((cat) => { + const isSelected = selectedEndpoints.includes(cat.id); + return ( + + ); + })} +
+ )} +
+ {/* Actions */}
-📖 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 | From af96e33184ddfaddb053223256bad260dcfbd267 Mon Sep 17 00:00:00 2001 From: Jan Leon Date: Wed, 27 May 2026 09:48:17 -0300 Subject: [PATCH 14/19] fix(codex): apply global service tiers to combo request bodies --- src/lib/providers/codexFastTier.ts | 15 ++++---- tests/integration/chat-pipeline.test.ts | 48 +++++++++++++++++++++---- tests/unit/codex-fast-tier.test.ts | 6 ++-- 3 files changed, 53 insertions(+), 16 deletions(-) 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/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/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: {} }; From 279e9c47bc2a8512bd09228b0d185edac6d9ddf8 Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Wed, 27 May 2026 09:48:35 -0300 Subject: [PATCH 15/19] fix: speedup docker creation by reducing steps and bunch up copy operations --- .env.example | 2 + Dockerfile | 51 ++++++------- scripts/build/build-next-isolated.mjs | 105 ++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 26 deletions(-) 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/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/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) { From e6ea8f13d9fcf069af161e90ea350d42afb23154 Mon Sep 17 00:00:00 2001 From: Jan Leon Date: Wed, 27 May 2026 09:48:38 -0300 Subject: [PATCH 16/19] fix: keep database log settings in sync with the pipeline toggle --- src/app/api/logs/detail/route.ts | 9 +++++ src/lib/db/databaseSettings.ts | 36 +++++++++++++++++++ .../database-settings-maintenance.test.ts | 18 ++++++++++ 3 files changed, 63 insertions(+) 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/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/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( From 3a5ad60eb244d45a1d8dd65ee702960fb000fe51 Mon Sep 17 00:00:00 2001 From: Jan Leon Date: Wed, 27 May 2026 09:49:24 -0300 Subject: [PATCH 17/19] fix: allow rate-limited provider connections after transient 429s (with unit & integration tests) --- open-sse/services/combo.ts | 31 ++++++-- src/sse/handlers/chat.ts | 8 ++ src/sse/services/auth.ts | 5 +- .../combo-provider-exhaustion.test.ts | 76 +++++++++++++++++++ tests/unit/sse-auth.test.ts | 16 ++++ 5 files changed, 129 insertions(+), 7 deletions(-) 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/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/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/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", From 0700705040f2706caa36784d8ca7a97e4e4e10ef Mon Sep 17 00:00:00 2001 From: akarray Date: Wed, 27 May 2026 09:49:28 -0300 Subject: [PATCH 18/19] fix: use public callbacks for remote Google OAuth with custom creds --- .../api/oauth/[provider]/[action]/route.ts | 5 +- src/lib/oauth/providers.ts | 60 +++++++++++++++++++ src/shared/components/OAuthModal.tsx | 8 +-- tests/unit/oauth-providers-config.test.ts | 40 +++++++++++++ 4 files changed, 108 insertions(+), 5 deletions(-) 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/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/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/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"]; From ade053c78b2dd62205827c6ca3bf1186b364cb2c Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 09:49:46 -0300 Subject: [PATCH 19/19] chore: update CHANGELOG.md for v3.8.5 with integrated PRs --- CHANGELOG.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 + ---