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"]); +});