From f8e726fd1c60ad24c4226de6ae63aafc9074f6ac Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 27 May 2026 06:22:15 -0300 Subject: [PATCH 1/2] Release v3.8.5 (#2776) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(release): bump version to v3.8.5 * fix(docker): rebuild better-sqlite3 after hardened install (#2772) Integrated into release/v3.8.5 * ci: build Docker platforms on native runners (#2774) Integrated into release/v3.8.5 * 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. * chore(release): bump to v3.8.5 β€” changelog, docs, version sync * chore(release): translate Hall of Contributors to English in workflows and changelog * fix(combos): make target timeout configurable (#2775) Merge PR #2775 β€” fix(combos): make target timeout configurable * feat: fix so restart of server restarts batch jobs instead of failing them (#2755) Merge PR #2755 β€” feat: fix so restart of server restarts batch jobs instead of failing them * chore(release): update changelog with merged PRs notes and credits * feat(api): add endpoint restrictions for client API keys (#2777) Merge PR #2777 β€” feat(api): add endpoint restrictions for client API keys * chore(release): update changelog with PR #2777 entry and contributor credit --------- Co-authored-by: Thanet S. Co-authored-by: Randi <55005611+rdself@users.noreply.github.com> Co-authored-by: Markus Hartung Co-authored-by: Jack <5443152+hijak@users.noreply.github.com> --- .agents/skills/generate-release/SKILL.md | 6 +- .agents/workflows/generate-release-ag.md | 6 +- .dockerignore | 9 +- .github/workflows/docker-publish.yml | 183 +++++++++++---- .source/browser.ts | 2 +- .source/server.ts | 46 ++-- CHANGELOG.md | 25 +++ Dockerfile | 19 +- docs/architecture/ARCHITECTURE.md | 10 +- docs/architecture/CODEBASE_DOCUMENTATION.md | 4 +- docs/guides/USER_GUIDE.md | 3 + 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/ENVIRONMENT.md | 5 + docs/reference/openapi.yaml | 2 +- electron/package-lock.json | 4 +- electron/package.json | 2 +- llm.txt | 4 +- open-sse/package.json | 2 +- open-sse/services/combo.ts | 35 ++- open-sse/services/comboConfig.ts | 22 ++ package-lock.json | 6 +- package.json | 2 +- .../api-manager/ApiManagerPageClient.tsx | 103 ++++++++- src/app/(dashboard)/dashboard/combos/page.tsx | 54 ++++- .../settings/components/ComboDefaultsTab.tsx | 35 +++ 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/utils/runtimeTimeouts.ts | 1 + src/shared/validation/schemas.ts | 6 +- src/sse/handlers/chat.ts | 110 +++------ tests/unit/combo-config.test.ts | 36 ++- tests/unit/endpoint-categories.test.ts | 115 ++++++++++ .../unit/endpoint-restrictions-policy.test.ts | 210 ++++++++++++++++++ 75 files changed, 1133 insertions(+), 282 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/.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/.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/.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 }} 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..b3b92bb1d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ ## [Unreleased] +--- + +## [3.8.5] β€” 2026-05-27 + +### ✨ New Features + +- **auth:** support restricting API keys to specific endpoint categories (e.g., chat only, search only, embeddings only) with full dashboard configuration and centralized policy enforcement (#2777 β€” thanks @hijak) +- **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 + +- **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 of Contributors + +A special thanks to everyone who contributed code, reviews, and tests for this release: +@hartmark, @hijak, @rdself, @thanet-s + +--- + ## [3.8.4] β€” 2026-05-26 ### πŸ”’ Security 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. 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/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/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/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/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/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/package-lock.json b/package-lock.json index 9be3a4f346..44383309c2 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": [ @@ -21210,7 +21210,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.8.4" + "version": "3.8.5" } } } 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": { 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 */}
{/* 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/i18n/messages/en.json b/src/i18n/messages/en.json index f1a7419292..650a4b3dbe 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1470,6 +1470,9 @@ "keyCreatedNote": "Copy and store this key now β€” it won't be shown again.", "done": "Done", "savePermissions": "Save Permissions", + "endpointRestrictions": "Allowed Endpoints", + "allEndpointsAllowed": "This key can access all API endpoints.", + "endpointsRestricted": "Restricted to {count} endpoint{count, plural, one {} other {s}}.", "autoResolve": "Auto-Resolve", "autoResolveDesc": "Auto-resolve ambiguous model names to native provider for this API key.", "keyActive": "Key Active", diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts index 73d2171eec..f41e30e9a0 100644 --- a/src/lib/db/apiKeys.ts +++ b/src/lib/db/apiKeys.ts @@ -59,6 +59,7 @@ interface ApiKeyMetadata { scopes: string[]; isBanned: boolean; keyHash: string | null; + allowedEndpoints: string[]; } interface ApiKeyRow extends JsonRecord { @@ -119,6 +120,7 @@ interface ApiKeyView extends JsonRecord { scopes: string[]; isBanned?: boolean; expiresAt?: string | null; + allowedEndpoints: string[]; } // LRU cache for API key validation (valid keys only) @@ -153,6 +155,7 @@ const API_KEY_COLUMN_FALLBACKS = [ { name: "rate_limits", definition: "rate_limits TEXT" }, { name: "is_banned", definition: "is_banned INTEGER NOT NULL DEFAULT 0" }, { name: "key_hash", definition: "key_hash TEXT" }, + { name: "allowed_endpoints", definition: "allowed_endpoints TEXT" }, ] as const; // Cache for model permission checks @@ -352,7 +355,7 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements { "SELECT id, expires_at, revoked_at, is_active, is_banned FROM api_keys WHERE key = ? OR key_hash = ?" ); _stmtGetKeyMetadata = db.prepare( - "SELECT id, name, machine_id, allowed_models, allowed_combos, allowed_connections, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, throttle_delay_ms, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash FROM api_keys WHERE key = ? OR key_hash = ?" + "SELECT id, name, machine_id, allowed_models, allowed_combos, allowed_connections, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, throttle_delay_ms, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash, allowed_endpoints FROM api_keys WHERE key = ? OR key_hash = ?" ); _stmtInsertKey = db.prepare( "INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at, key_prefix, key_hash, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" @@ -397,6 +400,7 @@ export async function getApiKeys() { camelRow.rateLimits = parseRateLimits(camelRow.rateLimits); camelRow.isBanned = parseIsBanned(camelRow.isBanned); camelRow.scopes = parseStringList((camelRow as JsonRecord).scopes); + camelRow.allowedEndpoints = parseStringList((camelRow as JsonRecord).allowedEndpoints); if (typeof camelRow.id === "string" && camelRow.id.length > 0) { setNoLog(camelRow.id, camelRow.noLog === true); } @@ -420,6 +424,7 @@ export async function getApiKeyById(id: string) { camelRow.rateLimits = parseRateLimits(camelRow.rateLimits); camelRow.isBanned = parseIsBanned(camelRow.isBanned); camelRow.scopes = parseStringList((camelRow as JsonRecord).scopes); + camelRow.allowedEndpoints = parseStringList((camelRow as JsonRecord).allowedEndpoints); if (typeof camelRow.id === "string" && camelRow.id.length > 0) { setNoLog(camelRow.id, camelRow.noLog === true); } @@ -655,6 +660,7 @@ export async function updateApiKeyPermissions( // T08: max concurrent sessions for this key (0 = unlimited) maxSessions?: number | null; scopes?: string[] | null; + allowedEndpoints?: string[] | null; } ) { const db = getDbInstance() as ApiKeysDbLike; @@ -680,6 +686,7 @@ export async function updateApiKeyPermissions( expiresAt: update.expiresAt, maxSessions: (update as { maxSessions?: number | null }).maxSessions, scopes: (update as { scopes?: string[] | null }).scopes, + allowedEndpoints: (update as { allowedEndpoints?: string[] | null }).allowedEndpoints, }; if ( @@ -698,7 +705,8 @@ export async function updateApiKeyPermissions( normalized.isBanned === undefined && normalized.expiresAt === undefined && (normalized as Record).maxSessions === undefined && - (normalized as Record).scopes === undefined + (normalized as Record).scopes === undefined && + (normalized as Record).allowedEndpoints === undefined ) { return false; } @@ -805,6 +813,17 @@ export async function updateApiKeyPermissions( params.maxSessions = typeof maxSessionsUpdate === "number" ? Math.max(0, maxSessionsUpdate) : 0; } + const allowedEndpointsUpdate = (normalized as Record).allowedEndpoints; + if (allowedEndpointsUpdate !== undefined) { + updates.push("allowed_endpoints = @allowedEndpoints"); + const nextEndpoints: string[] = Array.isArray(allowedEndpointsUpdate) + ? (allowedEndpointsUpdate as unknown[]).filter( + (s): s is string => typeof s === "string" + ) + : []; + (params as Record).allowedEndpoints = JSON.stringify(nextEndpoints); + } + const scopesUpdate = (normalized as Record).scopes; const nextScopes: string[] = Array.isArray(scopesUpdate) ? (scopesUpdate as unknown[]).filter((s): s is string => typeof s === "string") @@ -1151,6 +1170,7 @@ export async function getApiKeyMetadata( isBanned: false, keyHash: null, scopes: ["manage"], + allowedEndpoints: [], }; } @@ -1205,6 +1225,9 @@ export async function getApiKeyMetadata( scopes: parseStringList((record as JsonRecord).scopes), isBanned: parseIsBanned(record.is_banned ?? (record as JsonRecord).isBanned), keyHash: (record.key_hash ?? (record as JsonRecord).keyHash) as string | null, + allowedEndpoints: parseStringList( + (record as JsonRecord).allowed_endpoints ?? (record as JsonRecord).allowedEndpoints + ), }; if (!metadata.id) { diff --git a/src/shared/constants/endpointCategories.ts b/src/shared/constants/endpointCategories.ts new file mode 100644 index 0000000000..509b534282 --- /dev/null +++ b/src/shared/constants/endpointCategories.ts @@ -0,0 +1,132 @@ +/** + * Endpoint Category Definitions β€” API key endpoint restrictions. + * + * Each category maps a stable ID to a set of `/v1/` route prefixes. + * The `resolveEndpointCategory()` function maps an incoming request path + * to its category for policy enforcement. + * + * Empty `allowedEndpoints` on a key = all endpoints allowed (backward compatible). + * + * @module shared/constants/endpointCategories + */ + +export interface EndpointCategory { + id: string; + label: string; + description: string; + prefixes: string[]; +} + +export const ENDPOINT_CATEGORIES: readonly EndpointCategory[] = [ + { + id: "chat", + label: "Chat / Messages", + description: "Chat completions, text completions, messages, and responses", + prefixes: [ + "/v1/chat/completions", + "/v1/completions", + "/v1/messages", + "/v1/responses", + ], + }, + { + id: "search", + label: "Web Search", + description: "Web search and search analytics", + prefixes: ["/v1/search"], + }, + { + id: "embeddings", + label: "Embeddings", + description: "Text embeddings generation", + prefixes: ["/v1/embeddings"], + }, + { + id: "images", + label: "Images", + description: "Image generation and editing", + prefixes: ["/v1/images"], + }, + { + id: "audio", + label: "Audio / Speech", + description: "Text-to-speech and speech-to-text", + prefixes: ["/v1/audio"], + }, + { + id: "video", + label: "Video", + description: "Video generation", + prefixes: ["/v1/videos"], + }, + { + id: "music", + label: "Music", + description: "Music generation", + prefixes: ["/v1/music"], + }, + { + id: "rerank", + label: "Rerank", + description: "Document reranking", + prefixes: ["/v1/rerank"], + }, + { + id: "models", + label: "Models", + description: "List available models (read-only)", + prefixes: ["/v1/models"], + }, + { + id: "moderations", + label: "Moderations", + description: "Content moderation", + prefixes: ["/v1/moderations"], + }, + { + id: "batches", + label: "Batch Processing", + description: "Batch API operations", + prefixes: ["/v1/batches"], + }, + { + id: "files", + label: "Files", + description: "File upload and management", + prefixes: ["/v1/files"], + }, + { + id: "web-fetch", + label: "Web Fetch", + description: "Web page fetching", + prefixes: ["/v1/web"], + }, + { + id: "agents", + label: "Agents / A2A", + description: "Agent-to-agent protocol and task execution", + prefixes: ["/v1/agents"], + }, +] as const; + +/** + * Sorted longest-prefix-first so the most specific match wins + * (e.g. `/v1/chat/completions` before `/v1/chat`). + */ +const SORTED_PREFIXES: readonly { prefix: string; categoryId: string }[] = + ENDPOINT_CATEGORIES.flatMap((cat) => + cat.prefixes.map((prefix) => ({ prefix, categoryId: cat.id })) + ).sort((a, b) => b.prefix.length - a.prefix.length); + +/** + * Map a request pathname to its endpoint category ID. + * Returns `null` if the path doesn't match any category (e.g. management routes). + */ +export function resolveEndpointCategory(pathname: string): string | null { + for (const { prefix, categoryId } of SORTED_PREFIXES) { + if (pathname === prefix || pathname.startsWith(prefix + "/")) { + return categoryId; + } + } + return null; +} diff --git a/src/shared/utils/apiKeyPolicy.ts b/src/shared/utils/apiKeyPolicy.ts index f9060f8df0..5ec03199f6 100644 --- a/src/shared/utils/apiKeyPolicy.ts +++ b/src/shared/utils/apiKeyPolicy.ts @@ -16,6 +16,7 @@ import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import * as log from "@/sse/utils/logger"; import { checkRateLimit, RateLimitRule } from "./rateLimiter"; +import { resolveEndpointCategory } from "@/shared/constants/endpointCategories"; // Default to no per-key request cap. API keys can still opt into explicit // limits via Settings/API Manager, while provider/account quota controls remain @@ -74,6 +75,7 @@ export interface ApiKeyMetadata { throttleDelayMs?: number | null; maxSessions?: number | null; rateLimits?: RateLimitRule[] | null; + allowedEndpoints?: string[]; } /** @@ -294,6 +296,26 @@ export async function enforceApiKeyPolicy( } } + // ── Check 2.5: Endpoint restriction ── + if (apiKeyInfo.allowedEndpoints && apiKeyInfo.allowedEndpoints.length > 0) { + try { + const url = new URL(request.url); + const category = resolveEndpointCategory(url.pathname); + if (category && !apiKeyInfo.allowedEndpoints.includes(category)) { + return { + apiKey, + apiKeyInfo, + rejection: errorResponse( + HTTP_STATUS.FORBIDDEN, + `Endpoint category "${category}" is not allowed for this API key` + ), + }; + } + } catch { + // URL parse failure β€” fail open, let other checks decide + } + } + // ── Check 3: Model restriction ── let requestedComboName: string | null = null; if (modelStr && apiKeyInfo.allowedCombos && apiKeyInfo.allowedCombos.length > 0) { 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..16e76e516e 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(), @@ -1756,6 +1758,7 @@ export const updateKeyPermissionsSchema = z ]) .optional(), scopes: z.array(z.string().trim().min(1).max(64)).max(16).optional(), + allowedEndpoints: z.array(z.string().trim().min(1).max(64)).max(20).optional(), }) .superRefine((value, ctx) => { if ( @@ -1772,7 +1775,8 @@ export const updateKeyPermissionsSchema = z value.maxSessions === undefined && value.accessSchedule === undefined && value.rateLimits === undefined && - value.scopes === undefined + value.scopes === undefined && + value.allowedEndpoints === undefined ) { ctx.addIssue({ code: z.ZodIssueCode.custom, 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)) ) { 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", () => { diff --git a/tests/unit/endpoint-categories.test.ts b/tests/unit/endpoint-categories.test.ts new file mode 100644 index 0000000000..b64a63e8a8 --- /dev/null +++ b/tests/unit/endpoint-categories.test.ts @@ -0,0 +1,115 @@ +/** + * Unit tests for endpoint category resolution and API key endpoint restrictions. + * + * Tests: + * 1. resolveEndpointCategory β€” path β†’ category mapping + * 2. enforceApiKeyPolicy β€” endpoint restriction enforcement + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +// ─── resolveEndpointCategory: pure function tests ───────────────────────── +// Import the pure resolver without DB dependencies + +const { resolveEndpointCategory } = await import( + "../../src/shared/constants/endpointCategories.ts" +); + +test("resolveEndpointCategory: maps /v1/chat/completions to 'chat'", () => { + assert.equal(resolveEndpointCategory("/v1/chat/completions"), "chat"); +}); + +test("resolveEndpointCategory: maps /v1/completions to 'chat'", () => { + assert.equal(resolveEndpointCategory("/v1/completions"), "chat"); +}); + +test("resolveEndpointCategory: maps /v1/messages to 'chat'", () => { + assert.equal(resolveEndpointCategory("/v1/messages"), "chat"); +}); + +test("resolveEndpointCategory: maps /v1/responses to 'chat'", () => { + assert.equal(resolveEndpointCategory("/v1/responses"), "chat"); +}); + +test("resolveEndpointCategory: maps /v1/search to 'search'", () => { + assert.equal(resolveEndpointCategory("/v1/search"), "search"); +}); + +test("resolveEndpointCategory: maps /v1/search/analytics to 'search'", () => { + assert.equal(resolveEndpointCategory("/v1/search/analytics"), "search"); +}); + +test("resolveEndpointCategory: maps /v1/embeddings to 'embeddings'", () => { + assert.equal(resolveEndpointCategory("/v1/embeddings"), "embeddings"); +}); + +test("resolveEndpointCategory: maps /v1/images/generations to 'images'", () => { + assert.equal(resolveEndpointCategory("/v1/images/generations"), "images"); +}); + +test("resolveEndpointCategory: maps /v1/images/edits to 'images'", () => { + assert.equal(resolveEndpointCategory("/v1/images/edits"), "images"); +}); + +test("resolveEndpointCategory: maps /v1/audio/speech to 'audio'", () => { + assert.equal(resolveEndpointCategory("/v1/audio/speech"), "audio"); +}); + +test("resolveEndpointCategory: maps /v1/audio/transcriptions to 'audio'", () => { + assert.equal(resolveEndpointCategory("/v1/audio/transcriptions"), "audio"); +}); + +test("resolveEndpointCategory: maps /v1/videos/generations to 'video'", () => { + assert.equal(resolveEndpointCategory("/v1/videos/generations"), "video"); +}); + +test("resolveEndpointCategory: maps /v1/music/generations to 'music'", () => { + assert.equal(resolveEndpointCategory("/v1/music/generations"), "music"); +}); + +test("resolveEndpointCategory: maps /v1/rerank to 'rerank'", () => { + assert.equal(resolveEndpointCategory("/v1/rerank"), "rerank"); +}); + +test("resolveEndpointCategory: maps /v1/models to 'models'", () => { + assert.equal(resolveEndpointCategory("/v1/models"), "models"); +}); + +test("resolveEndpointCategory: maps /v1/moderations to 'moderations'", () => { + assert.equal(resolveEndpointCategory("/v1/moderations"), "moderations"); +}); + +test("resolveEndpointCategory: maps /v1/batches to 'batches'", () => { + assert.equal(resolveEndpointCategory("/v1/batches"), "batches"); +}); + +test("resolveEndpointCategory: maps /v1/files to 'files'", () => { + assert.equal(resolveEndpointCategory("/v1/files"), "files"); +}); + +test("resolveEndpointCategory: maps /v1/web/fetch to 'web-fetch'", () => { + assert.equal(resolveEndpointCategory("/v1/web/fetch"), "web-fetch"); +}); + +test("resolveEndpointCategory: maps /v1/agents/tasks to 'agents'", () => { + assert.equal(resolveEndpointCategory("/v1/agents/tasks"), "agents"); +}); + +test("resolveEndpointCategory: returns null for unknown path", () => { + assert.equal(resolveEndpointCategory("/v1/unknown"), null); +}); + +test("resolveEndpointCategory: returns null for management /api/keys", () => { + assert.equal(resolveEndpointCategory("/api/keys"), null); +}); + +test("resolveEndpointCategory: returns null for root path", () => { + assert.equal(resolveEndpointCategory("/"), null); +}); + +test("resolveEndpointCategory: handles sub-paths under category", () => { + assert.equal(resolveEndpointCategory("/v1/files/some-file-id"), "files"); + assert.equal(resolveEndpointCategory("/v1/batches/batch-123"), "batches"); + assert.equal(resolveEndpointCategory("/v1/responses/some/path"), "chat"); +}); diff --git a/tests/unit/endpoint-restrictions-policy.test.ts b/tests/unit/endpoint-restrictions-policy.test.ts new file mode 100644 index 0000000000..5c5bba8ec5 --- /dev/null +++ b/tests/unit/endpoint-restrictions-policy.test.ts @@ -0,0 +1,210 @@ +/** + * Unit tests for API key endpoint restriction enforcement through enforceApiKeyPolicy. + * + * These tests require the full DB stack (same pattern as api-key-policy.test.ts). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ep-policy-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "task-ep-policy-secret"; + +const coreDb = await import("../../src/lib/db/core.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const costRules = await import("../../src/domain/costRules.ts"); +const rateLimiter = await import("../../src/shared/utils/rateLimiter.ts"); + +rateLimiter.setRateLimiterTestMode(true); + +async function resetStorage() { + apiKeysDb.resetApiKeyState(); + costRules.resetCostData(); + coreDb.resetDbInstance(); + + for (let attempt = 0; attempt < 10; attempt++) { + try { + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } + break; + } catch (error: any) { + if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) { + await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); + } else { + throw error; + } + } + } + + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function loadPolicy(label: string) { + const modulePath = path.join(process.cwd(), "src/shared/utils/apiKeyPolicy.ts"); + return import(`${pathToFileURL(modulePath).href}?case=${label}-${Date.now()}`); +} + +async function createKeyWithEndpoints(allowedEndpoints: string[]) { + const created = await apiKeysDb.createApiKey("EP Test Key", "machine-ep"); + if (allowedEndpoints.length > 0) { + await apiKeysDb.updateApiKeyPermissions(created.id, { allowedEndpoints }); + } + return created; +} + +function makeRequest(url: string, apiKey?: string) { + return new Request(url, { + method: "POST", + headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, + }); +} + +async function readErrorMessage(response: Response) { + const body = (await response.json()) as any; + return body.error.message as string; +} + +test.beforeEach(async () => { + delete process.env.DEFAULT_RATE_LIMIT_PER_DAY; + await resetStorage(); +}); + +test.after(async () => { + apiKeysDb.resetApiKeyState(); + costRules.resetCostData(); + coreDb.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ─── Policy tests ───────────────────────────────────────────────────────── + +test("no restriction β€” all endpoints allowed", async () => { + const policy = await loadPolicy("no-endpoint-restriction"); + const key = await createKeyWithEndpoints([]); + + const request = makeRequest("http://localhost/v1/chat/completions", key.key); + const result = await policy.enforceApiKeyPolicy(request, "gpt-4"); + + assert.equal(result.rejection, null); +}); + +test("search-only key allows /v1/search", async () => { + const policy = await loadPolicy("search-only-allowed"); + const key = await createKeyWithEndpoints(["search"]); + + const request = makeRequest("http://localhost/v1/search", key.key); + const result = await policy.enforceApiKeyPolicy(request, "search"); + + assert.equal(result.rejection, null); +}); + +test("search-only key blocks /v1/chat/completions", async () => { + const policy = await loadPolicy("search-blocks-chat"); + const key = await createKeyWithEndpoints(["search"]); + + const request = makeRequest("http://localhost/v1/chat/completions", key.key); + const result = await policy.enforceApiKeyPolicy(request, "gpt-4"); + + assert.ok(result.rejection, "Should reject the request"); + assert.equal(result.rejection.status, 403); + const msg = await readErrorMessage(result.rejection); + assert.ok(msg.includes("chat"), `Error message should mention 'chat', got: ${msg}`); +}); + +test("chat+embeddings key allows /v1/embeddings", async () => { + const policy = await loadPolicy("chat-emb-allowed"); + const key = await createKeyWithEndpoints(["chat", "embeddings"]); + + const request = makeRequest("http://localhost/v1/embeddings", key.key); + const result = await policy.enforceApiKeyPolicy(request, "text-embedding-3"); + + assert.equal(result.rejection, null); +}); + +test("chat-only key blocks /v1/embeddings", async () => { + const policy = await loadPolicy("chat-blocks-emb"); + const key = await createKeyWithEndpoints(["chat"]); + + const request = makeRequest("http://localhost/v1/embeddings", key.key); + const result = await policy.enforceApiKeyPolicy(request, "text-embedding-3"); + + assert.ok(result.rejection, "Should reject the request"); + assert.equal(result.rejection.status, 403); + const msg = await readErrorMessage(result.rejection); + assert.ok( + msg.includes("embeddings"), + `Error message should mention 'embeddings', got: ${msg}` + ); +}); + +test("search-only key blocks /v1/images/generations", async () => { + const policy = await loadPolicy("search-blocks-images"); + const key = await createKeyWithEndpoints(["search"]); + + const request = makeRequest("http://localhost/v1/images/generations", key.key); + const result = await policy.enforceApiKeyPolicy(request, "dall-e-3"); + + assert.ok(result.rejection, "Should reject the request"); + assert.equal(result.rejection.status, 403); +}); + +test("no API key β€” endpoint check skipped", async () => { + const policy = await loadPolicy("no-key-endpoint"); + + const request = makeRequest("http://localhost/v1/chat/completions"); + const result = await policy.enforceApiKeyPolicy(request, "gpt-4"); + + assert.equal(result.rejection, null); +}); + +test("search-only key allows /v1/search/analytics", async () => { + const policy = await loadPolicy("search-analytics-allowed"); + const key = await createKeyWithEndpoints(["search"]); + + const request = makeRequest("http://localhost/v1/search/analytics", key.key); + const result = await policy.enforceApiKeyPolicy(request, "analytics"); + + assert.equal(result.rejection, null); +}); + +// ─── DB persistence tests ────────────────────────────────────────────────── + +test("updateApiKeyPermissions: persists allowedEndpoints", async () => { + const key = await apiKeysDb.createApiKey("EP Persist Key", "machine-persist"); + await apiKeysDb.updateApiKeyPermissions(key.id, { + allowedEndpoints: ["search", "embeddings"], + }); + + const meta = await apiKeysDb.getApiKeyMetadata(key.key); + assert.ok(meta, "Metadata should exist"); + assert.deepEqual(meta.allowedEndpoints, ["search", "embeddings"]); +}); + +test("updateApiKeyPermissions: empty allowedEndpoints", async () => { + const key = await apiKeysDb.createApiKey("EP All Key", "machine-all"); + await apiKeysDb.updateApiKeyPermissions(key.id, { + allowedEndpoints: [], + }); + + const meta = await apiKeysDb.getApiKeyMetadata(key.key); + assert.ok(meta, "Metadata should exist"); + assert.deepEqual(meta.allowedEndpoints, []); +}); + +test("getApiKeys: returns allowedEndpoints in listing", async () => { + const key = await apiKeysDb.createApiKey("EP List Key", "machine-list"); + await apiKeysDb.updateApiKeyPermissions(key.id, { + allowedEndpoints: ["chat"], + }); + + const keys = await apiKeysDb.getApiKeys(); + const found = keys.find((k: any) => k.id === key.id); + assert.ok(found, "Key should be in listing"); + assert.deepEqual(found.allowedEndpoints, ["chat"]); +}); From b5cc0993dfdc71c624a01b21c85c689ac49f151d Mon Sep 17 00:00:00 2001 From: Jan Leon Date: Wed, 27 May 2026 13:13:22 +0200 Subject: [PATCH 2/2] fix(combo): allow rate-limited provider connections after transient 429s --- open-sse/services/combo.ts | 31 +++++++++++++++++++++++++------ src/sse/handlers/chat.ts | 8 ++++++++ src/sse/services/auth.ts | 5 ++++- 3 files changed, 37 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