Release v3.8.5 (#2776)

* chore(release): bump version to v3.8.5

* fix(docker): rebuild better-sqlite3 after hardened install (#2772)

Integrated into release/v3.8.5

* ci: build Docker platforms on native runners (#2774)

Integrated into release/v3.8.5

* docs(release): sync v3.8.5 documentation and metadata

Update changelog entries, API reference version, package metadata, and
localized LLM documentation for the 3.8.5 release. Refresh generated
docs source mappings and architecture counts for current executors and
OAuth providers.

* chore(release): bump to v3.8.5 — changelog, docs, version sync

* chore(release): translate Hall of Contributors to English in workflows and changelog

* fix(combos): make target timeout configurable (#2775)

Merge PR #2775 — fix(combos): make target timeout configurable

* feat: fix so restart of server restarts batch jobs instead of failing them (#2755)

Merge PR #2755 — feat: fix so restart of server restarts batch jobs instead of failing them

* chore(release): update changelog with merged PRs notes and credits

* feat(api): add endpoint restrictions for client API keys (#2777)

Merge PR #2777 — feat(api): add endpoint restrictions for client API keys

* chore(release): update changelog with PR #2777 entry and contributor credit

---------

Co-authored-by: Thanet S. <cho.112543@gmail.com>
Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
Co-authored-by: Markus Hartung <mail@hartmark.se>
Co-authored-by: Jack <5443152+hijak@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-05-27 06:22:15 -03:00
committed by GitHub
parent 9145339a06
commit f8e726fd1c
75 changed files with 1133 additions and 282 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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<<EOF"
echo "$TAGS"
echo "EOF"
} >> "$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 }}

File diff suppressed because one or more lines are too long

View File

@@ -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<typeof Config, import("fumadocs-mdx/runtime/types").Intern
}
}>({"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, });
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, });

View File

@@ -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

View File

@@ -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.

View File

@@ -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)

View File

@@ -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`,

View File

@@ -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.
---

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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,

View File

@@ -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"

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute-desktop",
"version": "3.8.4",
"version": "3.8.5",
"description": "OmniRoute Desktop Application",
"main": "main.js",
"author": {

View File

@@ -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

View File

@@ -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",

View File

@@ -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<Response> => {
if (comboTargetTimeoutMs <= 0) {
return handleSingleModelWrapped(b, modelStr, target).catch((err) =>
errorResponse(502, err?.message ?? "Upstream model error")
);
}
const timeoutController = new AbortController();
let timeoutId: ReturnType<typeof setTimeout> | 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);

View File

@@ -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<string, unknown> | 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

6
package-lock.json generated
View File

@@ -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"
}
}
}

View File

@@ -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": {

View File

@@ -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<string[]>(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({
</div>
)}
{/* Allowed Endpoints Section */}
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{t("endpointRestrictions")}</p>
<p className="text-xs text-text-muted">
{allowAllEndpoints
? t("allEndpointsAllowed")
: t("endpointsRestricted", {
count: selectedEndpoints.length,
})}
</p>
</div>
<div className="flex gap-1 p-0.5 bg-surface rounded-md">
<button
onClick={() => {
setAllowAllEndpoints(true);
setSelectedEndpoints([]);
}}
className={`px-2 py-1 rounded text-xs font-medium transition-all ${
allowAllEndpoints
? "bg-primary text-white"
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
{t("all")}
</button>
<button
onClick={() => setAllowAllEndpoints(false)}
className={`px-2 py-1 rounded text-xs font-medium transition-all ${
!allowAllEndpoints
? "bg-primary text-white"
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
{t("restrict")}
</button>
</div>
</div>
{!allowAllEndpoints && (
<div className="flex flex-col gap-1 max-h-48 overflow-y-auto">
{ENDPOINT_CATEGORIES.map((cat) => {
const isSelected = selectedEndpoints.includes(cat.id);
return (
<button
key={cat.id}
onClick={() => handleToggleEndpoint(cat.id)}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs transition-all ${
isSelected
? "bg-primary/10 text-primary"
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
}`}
>
<div
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
isSelected ? "bg-primary border-primary" : "border-border"
}`}
>
{isSelected && (
<span className="material-symbols-outlined text-white text-[10px]">
check
</span>
)}
</div>
<span className="truncate flex-1">{cat.label}</span>
<span className="text-[10px] text-text-muted shrink-0 truncate max-w-[140px]">
{cat.description}
</span>
</button>
);
})}
</div>
)}
</div>
{/* Actions */}
<div className="flex gap-2">
<Button onClick={handleSave} fullWidth>

View File

@@ -159,6 +159,8 @@ const ADVANCED_FIELD_HELP_FALLBACK = {
"How long a request can wait for a round-robin model slot before timing out. This queue is separate from any account-only concurrency cap.",
failoverBeforeRetry:
"When enabled, a 429 from the upstream triggers immediate target failover instead of retrying the same URL first.",
targetTimeoutMs:
"Optional combo target timeout. Empty inherits the current request timeout; larger values are capped to that timeout.",
maxSetRetries:
"Number of times to retry the full target set when every target fails. 0 = no set-level retry.",
setRetryDelayMs:
@@ -170,6 +172,20 @@ const LEGACY_COMBO_RESILIENCE_KEYS = new Set([
"healthCheckEnabled",
"healthCheckTimeoutMs",
]);
const MS_PER_SECOND = 1000;
function msToOptionalSecondsInput(value) {
const ms = Number(value);
if (!Number.isFinite(ms) || ms <= 0) return "";
return String(Math.round(ms / MS_PER_SECOND));
}
function secondsInputToOptionalMs(value, maxSeconds = 86400) {
if (!value) return undefined;
const seconds = Number(value);
if (!Number.isFinite(seconds) || seconds <= 0) return undefined;
return Math.min(maxSeconds, Math.round(seconds)) * MS_PER_SECOND;
}
function sanitizeComboRuntimeConfig(config) {
if (!config || typeof config !== "object") return {};
@@ -1429,10 +1445,12 @@ function StrategyRecommendationsPanel({ strategy, onApply, showNudge }) {
);
}
function FieldLabelWithHelp({ label, help, showHelp = true }) {
function FieldLabelWithHelp({ label, help, showHelp = true, htmlFor = undefined }) {
return (
<div className="flex items-center gap-1 mb-0.5">
<label className="text-[10px] text-text-muted">{label}</label>
<label htmlFor={htmlFor} className="text-[10px] text-text-muted">
{label}
</label>
{showHelp && (
<Tooltip position="bottom" content={help}>
<span className="material-symbols-outlined text-[12px] text-text-muted cursor-help">
@@ -2691,7 +2709,9 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
configToSave.concurrencyPerModel = config.concurrencyPerModel;
if (config.queueTimeoutMs !== undefined) configToSave.queueTimeoutMs = config.queueTimeoutMs;
}
if (Object.keys(configToSave).length > 0) {
const hasConfigToSave = Object.keys(configToSave).length > 0;
const hadExistingConfig = Object.keys(sanitizeComboRuntimeConfig(combo?.config)).length > 0;
if (hasConfigToSave || (isEdit && hadExistingConfig)) {
saveData.config = configToSave;
}
@@ -3566,6 +3586,34 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none"
/>
</div>
<div>
<FieldLabelWithHelp
label={getI18nOrFallback(t, "targetTimeout", "Target timeout (seconds)")}
help={getI18nOrFallback(
t,
"advancedHelp.targetTimeoutMs",
ADVANCED_FIELD_HELP_FALLBACK.targetTimeoutMs
)}
showHelp={!isExpertMode}
htmlFor="combo-target-timeout-ms"
/>
<input
id="combo-target-timeout-ms"
type="number"
min="1"
max="86400"
step="1"
value={msToOptionalSecondsInput(config.targetTimeoutMs)}
placeholder={getI18nOrFallback(t, "inheritRequestTimeout", "inherit")}
onChange={(e) =>
setConfig({
...config,
targetTimeoutMs: secondsInputToOptionalMs(e.target.value),
})
}
className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none"
/>
</div>
</div>
{/* failoverBeforeRetry + maxSetRetries + setRetryDelayMs */}
<div className="grid grid-cols-2 gap-2 pt-2 border-t border-black/5 dark:border-white/5">

View File

@@ -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<typeof useTranslations>,
key: string,
@@ -339,7 +351,30 @@ export default function ComboDefaultsTab() {
className="text-sm"
/>
))}
<Input
label={translateOrFallback(t, "targetTimeout", "Target timeout (seconds)")}
type="number"
min={1}
max={86400}
step={1}
value={msToOptionalSecondsInput(comboDefaults.targetTimeoutMs)}
placeholder={translateOrFallback(t, "inheritRequestTimeout", "Inherit request timeout")}
onChange={(e) =>
setComboDefaults((prev) => ({
...prev,
targetTimeoutMs: secondsInputToOptionalMs(e.target.value),
}))
}
className="text-sm"
/>
</div>
<p className="text-xs text-text-muted">
{translateOrFallback(
t,
"targetTimeoutHint",
"Combo targets inherit the current request timeout by default. Set a lower value here only when you want faster fallback."
)}
</p>
<div className="grid grid-cols-1 gap-3 pt-3 border-t border-border/50">
<div>

View File

@@ -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",

View File

@@ -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<ApiKeyRow>(
"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<string, unknown>).maxSessions === undefined &&
(normalized as Record<string, unknown>).scopes === undefined
(normalized as Record<string, unknown>).scopes === undefined &&
(normalized as Record<string, unknown>).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<string, unknown>).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<string, unknown>).allowedEndpoints = JSON.stringify(nextEndpoints);
}
const scopesUpdate = (normalized as Record<string, unknown>).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) {

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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,

View File

@@ -123,7 +123,7 @@ const COMBOS_CACHE_TTL_MS = 10_000;
async function getCombosCachedForChat(): Promise<unknown[]> {
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<string, unknown> | undefined,
apiKeyInfo: apiKeyInfo as Record<string, unknown> | 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))
) {

View File

@@ -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", () => {

View File

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

View File

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