mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
committed by
GitHub
parent
9145339a06
commit
f8e726fd1c
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
183
.github/workflows/docker-publish.yml
vendored
183
.github/workflows/docker-publish.yml
vendored
@@ -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
@@ -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, });
|
||||
25
CHANGELOG.md
25
CHANGELOG.md
@@ -2,6 +2,31 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## [3.8.5] — 2026-05-27
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **auth:** support restricting API keys to specific endpoint categories (e.g., chat only, search only, embeddings only) with full dashboard configuration and centralized policy enforcement (#2777 — thanks @hijak)
|
||||
- **batch:** recover stale `in_progress` and `finalizing` batches back to `validating` state on startup, reset counters, and apply a configurable concurrency limit (`BATCH_MAX_CONCURRENT`) (#2755 — thanks @hartmark)
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
- **docker:** rebuild `better-sqlite3` native bindings after hardened install to resolve container startup crash (#2772 — thanks @thanet-s)
|
||||
- **combos:** make combo target timeout configurable, inheriting resolved request timeout by default and clamping values so they only shorten fallback latency (#2775 — thanks @rdself)
|
||||
|
||||
### ⚡ Performance / CI
|
||||
|
||||
- **ci:** build Docker platforms on native runners (linux/amd64 on ubuntu-24.04 and linux/arm64 on ubuntu-24.04-arm) instead of emulated QEMU, reducing build times significantly (#2774 — thanks @thanet-s)
|
||||
|
||||
### 🏆 Hall of Contributors
|
||||
|
||||
A special thanks to everyone who contributed code, reviews, and tests for this release:
|
||||
@hartmark, @hijak, @rdself, @thanet-s
|
||||
|
||||
---
|
||||
|
||||
## [3.8.4] — 2026-05-26
|
||||
|
||||
### 🔒 Security
|
||||
|
||||
19
Dockerfile
19
Dockerfile
@@ -12,19 +12,19 @@ COPY scripts/build/postinstall.mjs ./scripts/build/postinstall.mjs
|
||||
COPY scripts/build/postinstallSupport.mjs ./scripts/build/postinstallSupport.mjs
|
||||
COPY scripts/build/native-binary-compat.mjs ./scripts/build/native-binary-compat.mjs
|
||||
ENV NPM_CONFIG_LEGACY_PEER_DEPS=true
|
||||
# --ignore-scripts blocks the install/postinstall hooks of dependencies,
|
||||
# closing the supply-chain attack surface where a transitive dep can run
|
||||
# arbitrary code at install time. OmniRoute's own postinstall (
|
||||
# better-sqlite3 binary touchups, @swc/helpers copy) is only needed when
|
||||
# a packaged app/node_modules is unpacked — inside the Docker builder we
|
||||
# are doing a fresh native-platform install, so dropping the scripts is safe.
|
||||
# --ignore-scripts blocks broad dependency install/postinstall hooks, closing
|
||||
# the supply-chain attack surface where a transitive dep can run arbitrary code
|
||||
# at install time. better-sqlite3 still needs a native binding for the target
|
||||
# platform, so rebuild and smoke-test only that known runtime dependency below.
|
||||
#
|
||||
# We REQUIRE a committed package-lock.json so resolved dependency versions
|
||||
# are reproducible.
|
||||
RUN test -f package-lock.json \
|
||||
|| (echo "package-lock.json is required for reproducible Docker builds" >&2 && exit 1)
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci --no-audit --no-fund --legacy-peer-deps --ignore-scripts
|
||||
npm ci --no-audit --no-fund --legacy-peer-deps --ignore-scripts \
|
||||
&& npm rebuild better-sqlite3 \
|
||||
&& node -e "require('better-sqlite3')(':memory:').close()"
|
||||
|
||||
COPY . ./
|
||||
RUN --mount=type=cache,target=/app/.next/cache \
|
||||
@@ -58,6 +58,9 @@ COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
# Explicitly copy @swc/helpers — not always traced by standalone output but needed at runtime
|
||||
COPY --from=builder /app/node_modules/@swc/helpers ./node_modules/@swc/helpers
|
||||
# Explicitly copy better-sqlite3 — native bindings are not reliably traced by
|
||||
# Next.js standalone output, but bootstrap-env requires SQLite before startup.
|
||||
COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
|
||||
# Explicitly copy pino transport dependencies — pino spawns a worker that requires
|
||||
# pino-abstract-transport at runtime; Next.js standalone trace does not capture it (#449)
|
||||
COPY --from=builder /app/node_modules/pino-abstract-transport ./node_modules/pino-abstract-transport
|
||||
@@ -78,6 +81,8 @@ COPY --from=builder /app/scripts/build/runtime-env.mjs ./build/runtime-env.mjs
|
||||
COPY --from=builder /app/scripts/build/bootstrap-env.mjs ./build/bootstrap-env.mjs
|
||||
COPY --from=builder /app/scripts/dev/healthcheck.mjs ./healthcheck.mjs
|
||||
|
||||
RUN node -e "require('better-sqlite3')(':memory:').close()"
|
||||
|
||||
# Hand /app over to the baked-in `node` non-root user (UID/GID 1000) so the
|
||||
# runtime process never holds root privileges. The chown happens after all
|
||||
# COPYs so it covers files originally owned by root in the builder stage.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
4
electron/package-lock.json
generated
4
electron/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute-desktop",
|
||||
"version": "3.8.4",
|
||||
"version": "3.8.5",
|
||||
"description": "OmniRoute Desktop Application",
|
||||
"main": "main.js",
|
||||
"author": {
|
||||
|
||||
4
llm.txt
4
llm.txt
@@ -8,7 +8,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
|
||||
**Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost.
|
||||
|
||||
**Current version:** 3.8.3
|
||||
**Current version:** 3.8.5
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -279,7 +279,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
└── .env.example # Environment variable template
|
||||
```
|
||||
|
||||
## Key Features (v3.8.3)
|
||||
## Key Features (v3.8.5)
|
||||
|
||||
### Core Proxy
|
||||
- **177 AI providers** with automatic format translation
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
6
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
132
src/shared/constants/endpointCategories.ts
Normal file
132
src/shared/constants/endpointCategories.ts
Normal 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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
115
tests/unit/endpoint-categories.test.ts
Normal file
115
tests/unit/endpoint-categories.test.ts
Normal 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");
|
||||
});
|
||||
210
tests/unit/endpoint-restrictions-policy.test.ts
Normal file
210
tests/unit/endpoint-restrictions-policy.test.ts
Normal 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"]);
|
||||
});
|
||||
Reference in New Issue
Block a user