Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6f6520a79 | |||
| cc2bb4d719 | |||
| 3859f1c9ae | |||
| 5f8d774e19 | |||
| 538a3e855c | |||
| 03f2ef1e2b | |||
| 237d0746cf | |||
| 33b6c58087 | |||
| e96b023d04 | |||
| 7ac1d4621b | |||
| a2d7cbe8fe | |||
| c74ed29739 | |||
| 6c8501f122 | |||
| 941e945f74 | |||
| f2844d59e4 | |||
| 047ff187f6 | |||
| 1136c40811 | |||
| 5a78dc864f | |||
| 15c98c3048 | |||
| 0a5b005ce5 | |||
| 4d64e64127 | |||
| 5470c70cd0 | |||
| 47959ee395 | |||
| 7c34c178cd | |||
| ac7cb41483 | |||
| 0ab388b88e | |||
| 54448902f1 | |||
| 12107a02fd | |||
| eace06efdc | |||
| ee0afa1eec | |||
| 83cdd0dafe | |||
| 5be025f1d1 | |||
| c651842ea1 | |||
| 423abe6788 | |||
| 4003c38fd1 | |||
| 3e0c322fd4 | |||
| 7fcdd4abdd | |||
| 3f3280b2d4 | |||
| aae2399631 | |||
| 03bd2b6803 | |||
| c54a57838e | |||
| 1a099ea2f2 | |||
| 13c45807ef | |||
| 00df10c29a | |||
| 41d91d628a | |||
| 605c3f9be1 | |||
| 2f0894c220 |
@@ -4,16 +4,36 @@ description: Create a new release, bump version up to 1.x.10 threshold, update c
|
||||
|
||||
# Generate Release Workflow
|
||||
|
||||
Bump version, finalize CHANGELOG, commit, tag, push, publish to npm, and create GitHub release.
|
||||
Bump version, finalize CHANGELOG, commit, open a **PR to main** and wait for user confirmation before tagging, publishing, and deploying.
|
||||
|
||||
> **VERSION RULE: Always use PATCH bumps (2.x.y → 2.x.y+1)**
|
||||
> NEVER use `npm version minor` or `npm version major`.
|
||||
> Always use: `npm version patch --no-git-tag-version`
|
||||
> The threshold rule: when `y` reaches 10, bump to `2.(x+1).0` — e.g. `2.1.10` → `2.2.0`.
|
||||
|
||||
## Steps
|
||||
---
|
||||
|
||||
### 1. Determine new version
|
||||
## ⚠️ Two-Phase Flow
|
||||
|
||||
```
|
||||
Phase 1 (automated): bump → docs → i18n → commit → push → open PR
|
||||
↕ 🛑 STOP: Notify user, wait for PR confirmation
|
||||
Phase 2 (post-merge): tag → publish → GitHub release → Docker → deploy
|
||||
```
|
||||
|
||||
**NEVER push directly to main or create tags before the user confirms the PR.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Pre-Merge
|
||||
|
||||
### 1. Create release branch
|
||||
|
||||
```bash
|
||||
git checkout -b release/v2.x.y
|
||||
```
|
||||
|
||||
### 2. Determine new version
|
||||
|
||||
Check current version in `package.json` and increment the **patch** number only:
|
||||
|
||||
@@ -27,11 +47,6 @@ Version format: `2.x.y` — examples:
|
||||
- `2.1.9` → `2.1.10` (patch)
|
||||
- `2.1.10` → `2.2.0` (minor threshold — do manually with `sed`)
|
||||
|
||||
```bash
|
||||
# ALWAYS use patch:
|
||||
npm version patch --no-git-tag-version
|
||||
```
|
||||
|
||||
> **⚠️ ATOMIC COMMIT RULE — Version bump MUST happen before committing feature files.**
|
||||
>
|
||||
> **CORRECT order:**
|
||||
@@ -53,7 +68,7 @@ npm version patch --no-git-tag-version
|
||||
> This ensures that `git show v2.x.y` always contains both code changes and the version bump together.
|
||||
> The GitHub release tag will point to a commit that includes ALL changes for that version.
|
||||
|
||||
### 2. Regenerate lock file (REQUIRED after version bump)
|
||||
### 3. Regenerate lock file (REQUIRED after version bump)
|
||||
|
||||
**Mandatory** — skipping causes `@swc/helpers` lock mismatch and CI failures:
|
||||
|
||||
@@ -61,7 +76,7 @@ npm version patch --no-git-tag-version
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. Finalize CHANGELOG.md
|
||||
### 4. Finalize CHANGELOG.md
|
||||
|
||||
Replace `[Unreleased]` header with the new version and date.
|
||||
Keep an empty `## [Unreleased]` section above it.
|
||||
@@ -74,7 +89,7 @@ Keep an empty `## [Unreleased]` section above it.
|
||||
## [2.x.y] — YYYY-MM-DD
|
||||
```
|
||||
|
||||
### 4. Update openapi.yaml version ⚠️ MANDATORY
|
||||
### 5. Update openapi.yaml version ⚠️ MANDATORY
|
||||
|
||||
> **CI will fail** if `docs/openapi.yaml` version ≠ `package.json` version (`check:docs-sync` enforces this).
|
||||
|
||||
@@ -84,33 +99,97 @@ Keep an empty `## [Unreleased]` section above it.
|
||||
VERSION=$(node -p "require('./package.json').version") && sed -i "s/ version: .*/ version: $VERSION/" docs/openapi.yaml && echo "✓ openapi.yaml → $VERSION"
|
||||
```
|
||||
|
||||
### 5. Stage, commit, and tag
|
||||
### 6. Update README.md and i18n docs
|
||||
|
||||
Run `/update-docs` workflow steps to:
|
||||
|
||||
- Update feature table rows in `README.md`
|
||||
- Sync changes to all 29 language `docs/i18n/*/README.md` files
|
||||
- Update `docs/FEATURES.md` if Settings section changed
|
||||
|
||||
### 7. Run tests
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
All tests must pass before creating the PR.
|
||||
|
||||
### 8. Stage, commit, and push
|
||||
|
||||
// turbo-all
|
||||
|
||||
```bash
|
||||
git add package.json package-lock.json CHANGELOG.md docs/openapi.yaml
|
||||
git add -A
|
||||
git commit -m "chore(release): v2.x.y — summary of changes"
|
||||
git push origin release/v2.x.y
|
||||
```
|
||||
|
||||
### 9. Open PR to main
|
||||
|
||||
```bash
|
||||
gh pr create \
|
||||
--repo diegosouzapw/OmniRoute \
|
||||
--base main \
|
||||
--head release/v2.x.y \
|
||||
--title "chore(release): v2.x.y — summary" \
|
||||
--body "## 🚀 Release v2.x.y
|
||||
|
||||
### Changes
|
||||
...
|
||||
|
||||
### Tests
|
||||
- X/X tests pass
|
||||
|
||||
### ⚠️ After merging: run Phase 2 steps to tag, publish, and deploy."
|
||||
```
|
||||
|
||||
### 10. 🛑 STOP — Notify User & Await PR Confirmation
|
||||
|
||||
**This is a mandatory stop point.** Use `notify_user` with `BlockedOnUser: true`:
|
||||
|
||||
Inform the user:
|
||||
|
||||
- PR URL
|
||||
- Summary of changes
|
||||
- Test results
|
||||
- List of files changed
|
||||
|
||||
**DO NOT proceed to Phase 2 until the user confirms the PR looks good and merges it.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Post-Merge (only after user confirms)
|
||||
|
||||
> Run these steps only AFTER the user has merged the PR.
|
||||
|
||||
### 11. Pull main and create tag
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull origin main
|
||||
git tag -a v2.x.y -m "Release v2.x.y"
|
||||
```
|
||||
|
||||
### 6. Push to GitHub
|
||||
### 12. Push tag to GitHub
|
||||
|
||||
```bash
|
||||
git push origin main --tags
|
||||
git push origin --tags
|
||||
```
|
||||
|
||||
### 7. Create GitHub release
|
||||
### 13. Create GitHub release
|
||||
|
||||
```bash
|
||||
gh release create v2.x.y --title "v2.x.y — summary" --notes "..."
|
||||
```
|
||||
|
||||
### 8. 🐳 Trigger Docker Hub build (MANDATORY — keep npm and Docker in sync)
|
||||
### 14. 🐳 Trigger Docker Hub build (MANDATORY — keep npm and Docker in sync)
|
||||
|
||||
> **CRITICAL**: Docker Hub and npm MUST always publish the same version.
|
||||
> The Docker image is built automatically via GitHub Actions when a new tag is pushed.
|
||||
> After pushing the tag in step 5-6, **verify the workflow runs**:
|
||||
> After pushing the tag in step 11-12, **verify the workflow runs**:
|
||||
|
||||
```bash
|
||||
# Verify the Docker workflow triggered
|
||||
@@ -129,7 +208,7 @@ If the Docker build was not triggered automatically, trigger it manually:
|
||||
gh workflow run docker-publish.yml --repo diegosouzapw/OmniRoute --ref v2.x.y
|
||||
```
|
||||
|
||||
### 9. Deploy to BOTH VPS environments (MANDATORY)
|
||||
### 15. Deploy to BOTH VPS environments (MANDATORY)
|
||||
|
||||
> Always deploy to **both** environments after every release.
|
||||
> See `/deploy-vps` workflow for detailed steps.
|
||||
@@ -151,18 +230,27 @@ curl -s -o /dev/null -w "LOCAL: HTTP %{http_code}\n" http://192.168.0.15:20128/
|
||||
curl -s -o /dev/null -w "AKAMAI: HTTP %{http_code}\n" http://69.164.221.35:20128/
|
||||
```
|
||||
|
||||
### 16. Clean up release branch
|
||||
|
||||
```bash
|
||||
git branch -d release/v2.x.y
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Always run `/update-docs` BEFORE this workflow (ensures CHANGELOG and README are current)
|
||||
- The `prepublishOnly` script runs `npm run build:cli` automatically during `npm publish`
|
||||
- After npm publish, verify with `npm info omniroute version`
|
||||
- Lock file sync errors are caused by skipping `npm install` after version bump
|
||||
- Use `gh auth switch -u diegosouzapw` if git push fails with wrong account
|
||||
|
||||
## Known CI Pitfalls
|
||||
|
||||
| CI failure | Cause | Fix |
|
||||
| ------------------------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `[docs-sync] FAIL - OpenAPI version differs from package.json` | Skipped step 4 — `docs/openapi.yaml` version not updated | Run step 4 (`sed -i ...`) and commit |
|
||||
| `[docs-sync] FAIL - OpenAPI version differs from package.json` | Skipped step 5 — `docs/openapi.yaml` version not updated | Run step 5 (`sed -i ...`) and commit |
|
||||
| `[docs-sync] FAIL - CHANGELOG.md first section must be "## [Unreleased]"` | `## [Unreleased]` missing or not at top of CHANGELOG | Add `## [Unreleased]\n\n---\n` before the first versioned `## [x.y.z]` |
|
||||
| Electron Linux `.deb` build fails (`FpmTarget` error) | `fpm` Ruby gem not installed on `ubuntu-latest` runner | Already fixed in `electron-release.yml` (`gem install fpm` step) |
|
||||
| Docker Hub `502 error writing layer blob` | Transient Docker Hub network error during ARM64 push | Re-run the Docker publish workflow; no code change needed |
|
||||
|
||||
@@ -21,18 +21,18 @@ jobs:
|
||||
IMAGE_NAME: diegosouzapw/omniroute
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
||||
|
||||
- name: Set up QEMU (for multi-arch builds)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
echo "Publishing Docker image: $IMAGE_NAME:$VERSION"
|
||||
|
||||
- name: Build and push multi-arch image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
target: runner-base
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
docker buildx imagetools inspect "${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Update Docker Hub description
|
||||
uses: peter-evans/dockerhub-description@v4
|
||||
uses: peter-evans/dockerhub-description@v5
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
+111
@@ -4,6 +4,117 @@
|
||||
|
||||
---
|
||||
|
||||
## [2.9.2] — 2026-03-21
|
||||
|
||||
> Sprint: Fix media transcription (Deepgram/HuggingFace Content-Type, language detection) and TTS error display.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(transcription)**: Deepgram and HuggingFace audio transcription now correctly map `video/mp4` → `audio/mp4` and other media MIME types via new `resolveAudioContentType()` helper. Previously, uploading `.mp4` files consistently returned "No speech detected" because Deepgram was receiving `Content-Type: video/mp4`.
|
||||
- **fix(transcription)**: Added `detect_language=true` to Deepgram requests — auto-detects audio language (Portuguese, Spanish, etc.) instead of defaulting to English. Fixes non-English transcriptions returning empty or garbage results.
|
||||
- **fix(transcription)**: Added `punctuate=true` to Deepgram requests for higher-quality transcription output with correct punctuation.
|
||||
- **fix(tts)**: `[object Object]` error display in Text-to-Speech responses fixed in both `audioSpeech.ts` and `audioTranscription.ts`. The `upstreamErrorResponse()` function now correctly extracts nested string messages from providers like ElevenLabs that return `{ error: { message: "...", status_code: 401 } }` instead of a flat error string.
|
||||
|
||||
### 🧪 Tests
|
||||
|
||||
- Test suite: **821 tests, 0 failures** (unchanged)
|
||||
|
||||
### Triaged Issues
|
||||
|
||||
- **#508** — Tool call format regression: requested proxy logs and provider chain info (`needs-info`)
|
||||
- **#510** — Windows CLI healthcheck path: requested shell/Node version info (`needs-info`)
|
||||
- **#485** — Kiro MCP tool calls: closed as external Kiro issue (not OmniRoute)
|
||||
- **#442** — Baseten /models endpoint: closed (documented manual workaround)
|
||||
- **#464** — Key provisioning API: acknowledged as roadmap item
|
||||
|
||||
---
|
||||
|
||||
## [2.9.1] — 2026-03-21
|
||||
|
||||
> Sprint: Fix SSE omniModel data loss, merge per-protocol model compatibility.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **#511** — Critical: `<omniModel>` tag was sent after `finish_reason:stop` in SSE streams, causing data loss. Tag is now injected into the first non-empty content chunk, guaranteeing delivery before SDKs close the connection.
|
||||
|
||||
### Merged PRs
|
||||
|
||||
- **PR #512** (@zhangqiang8vip): Per-protocol model compatibility — `normalizeToolCallId` and `preserveOpenAIDeveloperRole` can now be configured per client protocol (OpenAI, Claude, Responses API). New `compatByProtocol` field in model config with Zod validation.
|
||||
|
||||
### Triaged Issues
|
||||
|
||||
- **#510** — Windows CLI healthcheck_failed: requested PATH/version info
|
||||
- **#509** — Turbopack Electron regression: upstream Next.js bug, documented workarounds
|
||||
- **#508** — macOS black screen: suggested `--disable-gpu` workaround
|
||||
|
||||
---
|
||||
|
||||
## [2.9.0] — 2026-03-20
|
||||
|
||||
> Sprint: Cross-platform machineId fix, per-API-key rate limits, streaming context cache, Alibaba DashScope, search analytics, ZWS v5, and 8 issues closed.
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **feat(search)**: Search Analytics tab in `/dashboard/analytics` — provider breakdown, cache hit rate, cost tracking. New API: `GET /api/v1/search/analytics` (#feat/search-provider-routing)
|
||||
- **feat(provider)**: Alibaba Cloud DashScope added with custom endpoint path validation — configurable `chatPath` and `modelsPath` per node (#feat/custom-endpoint-paths)
|
||||
- **feat(api)**: Per-API-key request-count limits — `max_requests_per_day` and `max_requests_per_minute` columns with in-memory sliding-window enforcement returning HTTP 429 (#452)
|
||||
- **feat(dev)**: ZWS v5 — HMR leak fix (485 DB connections → 1), memory 2.4GB → 195MB, `globalThis` singletons, Edge Runtime warning fix (@zhangqiang8vip)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(#506)**: Cross-platform `machineId` — `getMachineIdRaw()` rewritten with try/catch waterfall (Windows REG.exe → macOS ioreg → Linux file read → hostname → `os.hostname()`). Eliminates `process.platform` branching that Next.js bundler dead-code-eliminated, fixing `'head' is not recognized` on Windows. Also fixes #466.
|
||||
- **fix(#493)**: Custom provider model naming — removed incorrect prefix stripping in `DefaultExecutor.transformRequest()` that mangled org-scoped model IDs like `zai-org/GLM-5-FP8`.
|
||||
- **fix(#490)**: Streaming + context cache protection — `TransformStream` intercepts SSE to inject `<omniModel>` tag before `[DONE]` marker, enabling context cache protection for streaming responses.
|
||||
- **fix(#458)**: Combo schema validation — `system_message`, `tool_filter_regex`, `context_cache_protection` fields now pass Zod validation on save.
|
||||
- **fix(#487)**: KIRO MITM card cleanup — removed ZWS_README, generified `AntigravityToolCard` to use dynamic tool metadata.
|
||||
|
||||
### 🧪 Tests
|
||||
|
||||
- Added Anthropic-format tools filter unit tests (PR #397) — 8 regression tests for `tool.name` without `.function` wrapper
|
||||
- Test suite: **821 tests, 0 failures** (up from 813)
|
||||
|
||||
### 📋 Issues Closed (8)
|
||||
|
||||
- **#506** — Windows machineId `head` not recognized (fixed)
|
||||
- **#493** — Custom provider model naming (fixed)
|
||||
- **#490** — Streaming context cache (fixed)
|
||||
- **#452** — Per-API-key request limits (implemented)
|
||||
- **#466** — Windows login failure (same root cause as #506)
|
||||
- **#504** — MITM inactive (expected behavior)
|
||||
- **#462** — Gemini CLI PSA (resolved)
|
||||
- **#434** — Electron app crash (duplicate of #402)
|
||||
|
||||
## [2.8.9] — 2026-03-20
|
||||
|
||||
> Sprint: Merge community PRs, fix KIRO MITM card, dependency updates.
|
||||
|
||||
### Merged PRs
|
||||
|
||||
- **PR #498** (@Sajid11194): Fix Windows machine ID crash (`undefined\REG.exe`). Replaces `node-machine-id` with native OS registry queries. **Closes #486.**
|
||||
- **PR #497** (@zhangqiang8vip): Fix dev-mode HMR resource leaks — 485 leaked DB connections → 1, memory 2.4GB → 195MB. `globalThis` singletons, Edge Runtime warning fix, Windows test stability. (+1168/-338 across 22 files)
|
||||
- **PRs #499-503** (Dependabot): GitHub Actions updates — `docker/build-push-action@7`, `actions/checkout@6`, `peter-evans/dockerhub-description@5`, `docker/setup-qemu-action@4`, `docker/login-action@4`.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **#505** — KIRO MITM card now displays tool-specific instructions (`api.anthropic.com`) instead of Antigravity-specific text.
|
||||
- **#504** — Responded with UX clarification (MITM "Inactive" is expected behavior when proxy is not running).
|
||||
|
||||
---
|
||||
|
||||
## [2.8.8] — 2026-03-20
|
||||
|
||||
> Sprint: Fix OAuth batch test crash, add "Test All" button to individual provider pages.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **OAuth batch test crash** (ERR_CONNECTION_REFUSED): Replaced sequential for-loop with 5-connection concurrency limit + 30s per-connection timeout via `Promise.race()` + `Promise.allSettled()`. Prevents server crash when testing large OAuth provider groups (~30+ connections).
|
||||
|
||||
### Features
|
||||
|
||||
- **"Test All" button on provider pages**: Individual provider pages (e.g., `/providers/codex`) now show a "Test All" button in the Connections header when there are 2+ connections. Uses `POST /api/providers/test-batch` with `{mode: "provider", providerId}`. Results displayed in a modal with pass/fail summary and per-connection diagnosis.
|
||||
|
||||
---
|
||||
|
||||
## [2.8.7] — 2026-03-20
|
||||
|
||||
> Sprint: Merge PR #495 (Bottleneck 429 drop), fix #496 (custom embedding providers), triage features.
|
||||
|
||||
@@ -1105,17 +1105,17 @@ OmniRoute v2.0 is built as an operational platform, not just a relay proxy.
|
||||
|
||||
### 🎵 Multi-Modal APIs
|
||||
|
||||
| Feature | What It Does |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| 🖼️ **Image Generation** | `/v1/images/generations` with cloud and local backends |
|
||||
| 📐 **Embeddings** | `/v1/embeddings` for search and RAG pipelines |
|
||||
| 🎤 **Audio Transcription** | `/v1/audio/transcriptions` (Whisper and additional providers) |
|
||||
| 🔊 **Text-to-Speech** | `/v1/audio/speech` (multiple engines/providers) |
|
||||
| 🎬 **Video Generation** | `/v1/videos/generations` (ComfyUI + SD WebUI workflows) |
|
||||
| 🎵 **Music Generation** | `/v1/music/generations` (ComfyUI workflows) |
|
||||
| 🛡️ **Moderations** | `/v1/moderations` safety checks |
|
||||
| 🔀 **Reranking** | `/v1/rerank` for relevance scoring |
|
||||
| 🔍 **Web Search** 🆕 | `/v1/search` — 5 providers (Serper, Brave, Perplexity, Exa, Tavily), 6,500+ free/month, auto-failover, cache |
|
||||
| Feature | What It Does |
|
||||
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **Image Generation** | `/v1/images/generations` with cloud and local backends |
|
||||
| 📐 **Embeddings** | `/v1/embeddings` for search and RAG pipelines |
|
||||
| 🎤 **Audio Transcription** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **Text-to-Speech** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) with correct error messages |
|
||||
| 🎬 **Video Generation** | `/v1/videos/generations` (ComfyUI + SD WebUI workflows) |
|
||||
| 🎵 **Music Generation** | `/v1/music/generations` (ComfyUI workflows) |
|
||||
| 🛡️ **Moderations** | `/v1/moderations` safety checks |
|
||||
| 🔀 **Reranking** | `/v1/rerank` for relevance scoring |
|
||||
| 🔍 **Web Search** 🆕 | `/v1/search` — 5 providers (Serper, Brave, Perplexity, Exa, Tavily), 6,500+ free/month, auto-failover, cache |
|
||||
|
||||
### 🛡️ Resilience, Security & Governance
|
||||
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
# ZWS_README_V4 — 启动性能优化:HMR 泄漏修复与 Turbopack 迁移
|
||||
|
||||
## 一、如何发现问题
|
||||
|
||||
### 现象
|
||||
|
||||
- `npm run dev` 后,首次打开浏览器白屏等待 **5-22 秒**不等。
|
||||
- 运行一段时间后 Node 进程内存飙升至 **2.4 GB**,触发 Next.js 内存阈值保护强制重启。
|
||||
- 重启后 `Ready in 82.6s`(正常冷启动仅 3.4s),之后每个页面首次编译需 **7-28 秒**。
|
||||
- 日志中大量重复输出,单次会话内:
|
||||
- `[DB] SQLite database ready` 出现 **485 次**
|
||||
- `[HealthCheck] Starting proactive token health-check` 出现 **586 次**
|
||||
- `[CREDENTIALS] No external credentials file found` 出现 **432 次**
|
||||
|
||||
### 排查过程
|
||||
|
||||
1. **Terminal 日志分析**:统计关键日志出现次数,发现 DB 连接和 HealthCheck 定时器被反复创建。
|
||||
2. **代码审计**:追踪到所有受影响模块使用 `let initialized = false` 作为单例守卫——这在 Next.js dev 模式的 Webpack HMR 下会被重置。
|
||||
3. **对比**:`apiBridgeServer.ts` 使用了 `globalThis.__omnirouteApiBridgeStarted`,在日志中无重复初始化,验证了 `globalThis` 方案的有效性。
|
||||
4. **内存快照**:通过 `Get-Process node` 观察到两个 node 进程分别占用 1.7GB 和 1.0GB。
|
||||
5. **编译时间分析**:日志中 `compile:` 字段显示 Webpack 编译每个路由需 2-26 秒,对比 Turbopack 应在 0.5-3 秒。
|
||||
|
||||
---
|
||||
|
||||
## 二、根因分析
|
||||
|
||||
### 根因 1(P0):模块级单例在 HMR 中丢失
|
||||
|
||||
Next.js dev 模式下,Webpack HMR 会重新执行被修改(或依赖链变化)的模块。模块级 `let` 变量在每次重新执行时被重置为初始值。
|
||||
|
||||
```typescript
|
||||
// 修复前 — 每次 HMR 重新执行时 _db 重置为 null
|
||||
let _db: SqliteDatabase | null = null;
|
||||
|
||||
export function getDbInstance() {
|
||||
if (_db) return _db; // HMR 后这里永远 false
|
||||
// ... 重新打开一个新的 DB 连接(旧连接泄漏)
|
||||
}
|
||||
```
|
||||
|
||||
**受影响的模块与泄漏类型:**
|
||||
|
||||
| 模块 | 泄漏资源 | 累计次数 | 后果 |
|
||||
| ----------------------- | ---------------------- | -------- | ----------------------- |
|
||||
| `db/core.ts` | SQLite 连接 | 485 | 文件句柄泄漏 + 内存占用 |
|
||||
| `tokenHealthCheck.ts` | `setInterval` 定时器 | 586 | CPU 空转 + DB 查询风暴 |
|
||||
| `localHealthCheck.ts` | `setTimeout` 定时器链 | ~400 | 重复 HTTP 请求 + CPU |
|
||||
| `consoleInterceptor.ts` | console 方法包装 | ~400 | 日志 double-write |
|
||||
| `gracefulShutdown.ts` | SIGTERM/SIGINT handler | ~400 | 信号处理器堆叠 |
|
||||
|
||||
**级联效应**:泄漏的资源持续消耗内存和 CPU → 触发 Next.js 内存阈值保护 → 进程重启 → Webpack 从零重建模块图 → **Ready in 82.6s**。
|
||||
|
||||
### 根因 2(P0):强制使用 Webpack 而非 Turbopack
|
||||
|
||||
`scripts/run-next.mjs` 中硬编码了 `--webpack` 标志:
|
||||
|
||||
```javascript
|
||||
if (mode === "dev") {
|
||||
args.splice(2, 0, "--webpack");
|
||||
}
|
||||
```
|
||||
|
||||
Next.js 16 默认使用 Turbopack(Rust 编写的增量打包器),dev 编译速度是 Webpack 的 5-10 倍。强制回退到 Webpack 导致:
|
||||
|
||||
| 指标 | Webpack | Turbopack(预期) |
|
||||
| ----------------------- | ------- | ----------------- |
|
||||
| 首页编译 | 3.7s | ~0.5s |
|
||||
| Provider 详情页首次编译 | 22s | ~2-3s |
|
||||
| API route 首次编译 | 2-7s | ~0.3-1s |
|
||||
| 内存重启后 Ready | 82.6s | 不会触发 |
|
||||
|
||||
### 根因 3(P1):`node:crypto` 被拉入客户端 bundle
|
||||
|
||||
`src/lib/db/proxies.ts` 使用了 `import { randomUUID } from "node:crypto"`。通过 `localDb.ts` 的 re-export 链,这个 Node.js 原生模块被间接拉入客户端组件的 bundle,导致 Webpack 报错:
|
||||
|
||||
```
|
||||
UnhandledSchemeError: Reading from "node:crypto" is not handled by plugins
|
||||
Import trace: node:crypto → ./src/lib/db/proxies.ts → ./src/lib/localDb.ts → page.tsx
|
||||
```
|
||||
|
||||
Webpack 无法处理 `node:` URI scheme 前缀。`crypto`(不带 `node:` 前缀)已在 `next.config.mjs` 的 `serverExternalPackages` 中声明为服务端外部包。
|
||||
|
||||
### 根因 4(P1):Edge Runtime 编译警告刷屏
|
||||
|
||||
Next.js 16 会同时为 **Node.js** 和 **Edge** 两种运行时编译 `instrumentation.ts`。虽然 `register()` 函数内有 `process.env.NEXT_RUNTIME === "nodejs"` 的运行时守卫,但 Turbopack 在打包 Edge 版本时仍会**静态追踪**所有动态 `import()` 的依赖链:
|
||||
|
||||
```
|
||||
instrumentation.ts
|
||||
→ import("@/lib/db/secrets")
|
||||
→ @/lib/db/core.ts → fs, path, better-sqlite3
|
||||
→ @/lib/dataPaths.ts → path, os
|
||||
→ @/lib/db/migrationRunner.ts → fs, path, url
|
||||
```
|
||||
|
||||
对每个 Node.js 原生模块,Turbopack 都输出一条 "not supported in Edge Runtime" 警告。每次有新请求触发热编译时,这组 **10+ 条警告重复刷一遍**,严重污染终端输出,干扰开发调试。
|
||||
|
||||
### 根因 5(P2):启动 import 完全串行
|
||||
|
||||
`instrumentation.ts` 中 9 个 `await import()` 完全串行执行,每个都可能触发 Webpack 编译其依赖树:
|
||||
|
||||
```typescript
|
||||
await ensureSecrets(); // 串行 1
|
||||
const { initConsoleInterceptor } = await import(...); // 串行 2
|
||||
const { initGracefulShutdown } = await import(...); // 串行 3
|
||||
const { initApiBridgeServer } = await import(...); // 串行 4
|
||||
const { startBackgroundRefresh } = await import(...); // 串行 5
|
||||
const { getSettings } = await import(...); // 串行 6
|
||||
const { setCustomAliases } = await import(...); // 串行 7
|
||||
const { setDefaultFastServiceTierEnabled } = await import(...); // 串行 8
|
||||
const { initAuditLog, cleanupExpiredLogs } = await import(...); // 串行 9
|
||||
```
|
||||
|
||||
其中 4-6 互不依赖,7-8 互不依赖,完全可以并行。
|
||||
|
||||
---
|
||||
|
||||
## 三、修复方案
|
||||
|
||||
### 修复 1:globalThis 单例守卫(core.ts, tokenHealthCheck.ts, localHealthCheck.ts, consoleInterceptor.ts, gracefulShutdown.ts)
|
||||
|
||||
**原理**:`globalThis` 对象在 Node.js 进程生命周期内全局唯一,不受 Webpack 模块重新执行的影响。
|
||||
|
||||
```typescript
|
||||
// 修复后 — globalThis 在 HMR 后依然保留
|
||||
declare global {
|
||||
var __omnirouteDb: import("better-sqlite3").Database | undefined;
|
||||
}
|
||||
|
||||
function getDb() {
|
||||
return globalThis.__omnirouteDb ?? null;
|
||||
}
|
||||
function setDb(db) {
|
||||
/* ... */
|
||||
}
|
||||
|
||||
export function getDbInstance() {
|
||||
const existing = getDb();
|
||||
if (existing) return existing; // HMR 后命中缓存
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**每个模块的具体改动:**
|
||||
|
||||
| 模块 | globalThis key | 守卫内容 |
|
||||
| ----------------------- | ----------------------------------- | ----------------------------------------------------------- |
|
||||
| `db/core.ts` | `__omnirouteDb` | SQLite 连接实例 |
|
||||
| `tokenHealthCheck.ts` | `__omnirouteTokenHC` | `{ initialized, interval }` |
|
||||
| `localHealthCheck.ts` | `__omnirouteLocalHC` | `{ initialized, sweepTimer, healthCache, sweepInProgress }` |
|
||||
| `consoleInterceptor.ts` | `__omnirouteConsoleInterceptorInit` | `boolean` |
|
||||
| `gracefulShutdown.ts` | `__omnirouteShutdownInit` | `boolean` |
|
||||
|
||||
**优点**:
|
||||
|
||||
- 零依赖,无需额外库。
|
||||
- 与 `apiBridgeServer.ts` 已有模式一致。
|
||||
- 对生产环境零影响(非 HMR 场景下行为完全相同)。
|
||||
|
||||
**缺点/注意**:
|
||||
|
||||
- `globalThis` 键名需全局唯一,使用 `__omniroute` 前缀避免冲突。
|
||||
- 需要 `declare global` 类型声明以保持 TypeScript 类型安全。
|
||||
- 生产构建中 `globalThis` 存储略冗余(但仅是一个对象引用,几乎零开销)。
|
||||
|
||||
### 修复 2:支持通过环境变量切换 Turbopack(run-next.mjs)
|
||||
|
||||
```javascript
|
||||
// 修复后 — 默认仍用 webpack(保持原有行为),设置环境变量可启用 Turbopack
|
||||
if (mode === "dev" && process.env.OMNIROUTE_USE_TURBOPACK !== "1") {
|
||||
args.splice(2, 0, "--webpack");
|
||||
}
|
||||
```
|
||||
|
||||
**默认行为不变**:dev 模式仍使用 Webpack,与修复前完全一致。设置 `OMNIROUTE_USE_TURBOPACK=1` 可切换到 Turbopack 以获得更快的 dev 编译速度。
|
||||
|
||||
**优点**:
|
||||
|
||||
- 零风险:不改变任何人的现有体验。
|
||||
- 需要时设置 `OMNIROUTE_USE_TURBOPACK=1` 即可获得 5-10 倍编译加速。
|
||||
- `next.config.mjs` 中已有 `turbopack.resolveAlias` 配置,说明项目已在准备 Turbopack 迁移。
|
||||
|
||||
**缺点/注意**:
|
||||
|
||||
- Turbopack 对某些 Webpack 特定配置(如自定义 externals 函数)的支持方式不同,启用前需测试兼容性。
|
||||
- 默认走 Webpack 意味着不主动启用 Turbopack 的用户无法享受编译加速。
|
||||
|
||||
### 修复 3:`node:crypto` → `crypto`(proxies.ts, errorResponse.ts)
|
||||
|
||||
```typescript
|
||||
// 修复前
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
// 修复后
|
||||
import { randomUUID } from "crypto";
|
||||
```
|
||||
|
||||
**优点**:
|
||||
|
||||
- `crypto`(无 `node:` 前缀)已在 `next.config.mjs` 的 `serverExternalPackages` 列表中,Webpack/Turbopack 会正确将其标记为外部包。
|
||||
- 消除 `UnhandledSchemeError` 构建失败。
|
||||
- Node.js 中 `crypto` 和 `node:crypto` 解析到同一模块。
|
||||
|
||||
**缺点**:
|
||||
|
||||
- 无。`crypto` 是 Node.js 内建模块,两种写法功能完全等价。
|
||||
|
||||
### 修复 4:分离 Edge/Node.js Instrumentation(instrumentation.ts → instrumentation-node.ts)
|
||||
|
||||
**问题**:`instrumentation.ts` 中所有 Node.js 逻辑(`ensureSecrets`、DB 初始化、审计日志等)虽然只在 `NEXT_RUNTIME === "nodejs"` 时执行,但 Turbopack 编译 Edge 版本时仍静态追踪其 import 链,对每个 `fs`/`path`/`os`/`better-sqlite3` 等原生模块输出警告。
|
||||
|
||||
**方案**:将所有 Node.js 专属逻辑提取到 `src/instrumentation-node.ts`,主文件通过**计算的 import 路径**引入,阻止 Turbopack 静态解析:
|
||||
|
||||
```typescript
|
||||
// src/instrumentation.ts — 精简后仅 ~20 行
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
// 拼接路径阻止 Turbopack 在 Edge 编译时静态解析模块依赖
|
||||
const nodeMod = "./instrumentation-" + "node";
|
||||
const { registerNodejs } = await import(nodeMod);
|
||||
await registerNodejs();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/instrumentation-node.ts — 包含全部 Node.js 启动逻辑
|
||||
export async function registerNodejs(): Promise<void> {
|
||||
await ensureSecrets();
|
||||
// initConsoleInterceptor, initGracefulShutdown, initApiBridgeServer, ...
|
||||
// (原 instrumentation.ts 的完整 Node.js 逻辑)
|
||||
}
|
||||
```
|
||||
|
||||
**关键技术**:`"./instrumentation-" + "node"` 是运行时拼接的字符串,Turbopack 无法在编译期确定其值,因此**不会追踪**该 import 的依赖树。Node.js 运行时则正常解析该路径并执行。
|
||||
|
||||
**优点**:
|
||||
|
||||
- Edge 编译时完全跳过 Node.js 模块追踪,**10+ 条重复警告全部消除**。
|
||||
- Node.js 运行时行为与修复前完全一致。
|
||||
- 启动时间从 **13.9s → 1.25s**(Turbopack 不再在 Edge 编译中处理 Node.js 模块图)。
|
||||
|
||||
**缺点/注意**:
|
||||
|
||||
- 新增一个文件 `instrumentation-node.ts`,需同步维护。
|
||||
- 计算 import 路径是有意为之的 bundler 逃逸技巧,需加注释说明原因防止后续重构时被"优化"回静态字符串。
|
||||
|
||||
### 修复 5:并行化 instrumentation.ts 中的启动 import
|
||||
|
||||
```typescript
|
||||
// 修复后 — 4 个独立模块并行导入
|
||||
const [
|
||||
{ initGracefulShutdown },
|
||||
{ initApiBridgeServer },
|
||||
{ startBackgroundRefresh },
|
||||
{ getSettings },
|
||||
] = await Promise.all([
|
||||
import("@/lib/gracefulShutdown"),
|
||||
import("@/lib/apiBridgeServer"),
|
||||
import("@/domain/quotaCache"),
|
||||
import("@/lib/db/settings"),
|
||||
]);
|
||||
|
||||
// 2 个 open-sse 模块也并行导入
|
||||
const [{ setCustomAliases }, { setDefaultFastServiceTierEnabled }] = await Promise.all([
|
||||
import("@omniroute/open-sse/services/modelDeprecation.ts"),
|
||||
import("@omniroute/open-sse/executors/codex.ts"),
|
||||
]);
|
||||
```
|
||||
|
||||
**优点**:
|
||||
|
||||
- `consoleInterceptor` 仍保持第一个(必须在任何日志前初始化)。
|
||||
- 后续 4 个无依赖模块并行加载,节省 3 次串行等待。
|
||||
- open-sse 的 2 个模块也并行加载。
|
||||
|
||||
**缺点**:
|
||||
|
||||
- 并行 import 的错误堆栈略复杂(Promise.all 中某一个失败会 reject 整个组)。
|
||||
- 这里的 compliance 模块仍保持独立 try/catch 串行,因为它有自己的错误处理逻辑。
|
||||
|
||||
---
|
||||
|
||||
## 四、预期效果
|
||||
|
||||
| 指标 | 修复前 | 修复后(预期) |
|
||||
| ----------------------------- | ------------------------- | ------------------------ |
|
||||
| DB 连接创建次数 | 485 次/会话 | 1 次 |
|
||||
| HealthCheck 定时器 | 586 个泄漏 | 1 个 |
|
||||
| 信号处理器注册 | ~400 次重复 | 1 次 |
|
||||
| Console 拦截层数 | ~400 层嵌套 | 1 层 |
|
||||
| 内存使用峰值 | 2.4 GB → OOM 重启 | 预期 < 500 MB |
|
||||
| 冷启动 Ready | 3.4s | ~3s(略快) |
|
||||
| 内存重启 Ready | 82.6s | 不再触发内存重启 |
|
||||
| Login 页首次编译 | 3.7s | ~0.5s (需启用 Turbopack) |
|
||||
| Provider 详情页首次编译 | 22s | ~2-3s (需启用 Turbopack) |
|
||||
| `node:crypto` 构建错误 | 反复出现 | 消除 |
|
||||
| Edge Runtime 编译警告 | 每次热编译刷出 10+ 条 | **0 条** |
|
||||
| instrumentation 启动耗时 | 13.9s(含 Edge 模块追踪) | **1.25s** |
|
||||
| instrumentation import 并行度 | 9 次串行 import | 3 批并行 import |
|
||||
|
||||
---
|
||||
|
||||
## 五、涉及文件清单
|
||||
|
||||
| 区域 | 文件 | 改动类型 |
|
||||
| ------------------- | ------------------------------- | ------------------------------------------------------------------ |
|
||||
| DB 单例 | `src/lib/db/core.ts` | `let _db` → `globalThis.__omnirouteDb` |
|
||||
| Token 健康检查 | `src/lib/tokenHealthCheck.ts` | `let initialized` → `globalThis.__omnirouteTokenHC` |
|
||||
| 本地节点健康检查 | `src/lib/localHealthCheck.ts` | `let initialized` → `globalThis.__omnirouteLocalHC` |
|
||||
| Console 拦截 | `src/lib/consoleInterceptor.ts` | `let initialized` → `globalThis.__omnirouteConsoleInterceptorInit` |
|
||||
| 优雅关停 | `src/lib/gracefulShutdown.ts` | 新增 `globalThis.__omnirouteShutdownInit` 守卫 |
|
||||
| Dev 启动脚本 | `scripts/run-next.mjs` | 新增 `OMNIROUTE_USE_TURBOPACK=1` 开关 |
|
||||
| Proxy 注册表 | `src/lib/db/proxies.ts` | `node:crypto` → `crypto` |
|
||||
| API 错误响应 | `src/lib/api/errorResponse.ts` | `node:crypto` → `crypto` |
|
||||
| 启动钩子(主入口) | `src/instrumentation.ts` | 精简为 ~20 行,计算 import 路径阻止 Edge 追踪 |
|
||||
| 启动钩子(Node.js) | `src/instrumentation-node.ts` | 新文件,承载全部 Node.js 启动逻辑 + `Promise.all` 并行 |
|
||||
|
||||
---
|
||||
|
||||
## 六、回退方案
|
||||
|
||||
- **启用 Turbopack**:设置 `OMNIROUTE_USE_TURBOPACK=1` 环境变量;不设置则默认使用 Webpack(原有行为不变)。
|
||||
- **globalThis 方案异常**:所有 globalThis key 都以 `__omniroute` 为前缀,可通过 `delete globalThis.__omnirouteDb` 等方式手动重置。
|
||||
- **Edge 警告回退**:若 `instrumentation-node.ts` 拆分导致问题,可将其内容合并回 `instrumentation.ts`,恢复为直接 `import()` 调用(警告会重新出现但不影响功能)。
|
||||
- **生产环境**:以上修复对生产构建无负面影响——生产环境不存在 HMR,globalThis 单例仅在首次调用时初始化一次。计算 import 路径在 `next build` 时由 Node.js 正常解析,不影响打包产物。
|
||||
|
||||
---
|
||||
|
||||
## 七、单元测试与备份恢复(pre-commit 验证通过)
|
||||
|
||||
为保证提交前必须通过验证(不再使用 `--no-verify`),对以下失败用例与生产逻辑做了修复与加固。
|
||||
|
||||
### 问题与根因
|
||||
|
||||
| 失败项 | 根因 |
|
||||
| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| bootstrap-env 4 个用例 | Windows 上 DATA_DIR 解析用 `APPDATA`/`homedir()`,测试只设了 `HOME`,脚本读不到测试用的 `.env`。 |
|
||||
| domain-persistence costRules 2 个用例 | `core` 在首次 import 时缓存 `DATA_DIR`;测试每测一个 tmpDir 并在 afterEach 删目录,导致后续 describe 使用的 DB 路径已被删,读写得到 0。 |
|
||||
| fixes-p1 restoreDbBackup | 测试在 DB 仍打开时写 stale 侧文件;`restoreDbBackup` 内 pre-restore 备份未 await 就关库,Windows 上句柄未及时释放,unlink 报 EBUSY。 |
|
||||
| fixes-p1 resetStorage 及后续用例 | 上一测留下 DB 打开,下一测 `resetStorage()` 删目录时文件仍被占用,EBUSY。 |
|
||||
|
||||
### 修复 6:bootstrap-env 测试(tests/unit/bootstrap-env.test.mjs)
|
||||
|
||||
在每个用例的 `withTempEnv` 回调开头增加 `process.env.DATA_DIR = dataDir`,使脚本在任意平台(含 Windows)都使用测试临时目录,而不是依赖 `HOME`/`APPDATA`。
|
||||
|
||||
### 修复 7:domain-persistence 测试(tests/unit/domain-persistence.test.mjs)
|
||||
|
||||
- **单例 tmpDir**:全文件共用一个 `fileTmpDir`,在模块加载时创建并设置 `process.env.DATA_DIR`,与 `core` 首次加载时缓存的路径一致。
|
||||
- **每测清 DB 不清目录**:`beforeEach` 中 `resetDbInstance()` 后删除 `storage.sqlite` 及其 `-wal`/`-shm`/`-journal`,保证每测干净 DB,不在 afterEach 删目录,避免路径失效。
|
||||
- **收尾**:`after()` 中恢复 `DATA_DIR` 并删除 `fileTmpDir`。
|
||||
- **costRules 断言**:改为小容差精确校验(`assertAlmostEqual`),继续验证 `4.5` / `4.0` 这类业务关键值,避免把真实累计错误放过去。
|
||||
|
||||
### 修复 8:fixes-p1 测试(tests/unit/fixes-p1.test.mjs)
|
||||
|
||||
- **restoreDbBackup 用例**:在写入 stale 侧文件前调用 `core.resetDbInstance()`,避免 DB 仍打开时写 `-wal`/`-shm` 触发 Windows 锁错误。
|
||||
- **Windows 跳过**:该用例在 Windows 上仍使用 `test(..., { skip: isWindows })`。原因不是业务逻辑不支持 Windows,而是 better-sqlite3 关闭后底层句柄释放存在时序抖动,这条真实 sidecar 集成测试容易退化成不稳定的文件锁测试;Linux/macOS 上照常运行。
|
||||
- **核心兜底测试**:新增平台无关的 `unlinkFileWithRetry` 单测,直接模拟 `EBUSY` / `EPERM` 后重试并最终成功,确保 Windows 相关的重试删除逻辑被稳定覆盖,而不是完全依赖 flaky 的真实文件锁时序。
|
||||
- **resetStorage**:改为 async,对 `rmSync(TEST_DATA_DIR)` 做最多 10 次、间隔 100ms 的 EBUSY/EPERM 重试,避免下一测因上一测句柄未释放而失败。
|
||||
|
||||
### 修复 9:备份恢复逻辑(src/lib/db/backup.ts)
|
||||
|
||||
- **pre-restore 备份改为同步等待**:在 `restoreDbBackup` 内用内联逻辑做 pre-restore 备份并 `await` 完成,再调用 `resetDbInstance()`,避免异步 backup 未结束就关库导致后续 unlink 失败。
|
||||
- **节流语义保持一致**:pre-restore 备份成功后补回 `_lastBackupAt = Date.now()`,避免恢复后紧接着又触发一轮额外自动备份。
|
||||
- **关库后短延迟**:`resetDbInstance()` 后 `await new Promise(r => setTimeout(r, 500))`,再执行 unlink,给 Windows 等平台释放句柄留时间。
|
||||
- **unlink 重试**:将主库及 `-wal`/`-shm`/`-journal` 的删除提取为 `unlinkFileWithRetry`,统一做最多 10 次、间隔 100ms 的 EBUSY/EPERM 重试,提高恢复流程在锁释放较慢环境下的成功率,也便于单测直接覆盖重试逻辑。
|
||||
|
||||
### 涉及文件(本节)
|
||||
|
||||
| 区域 | 文件 | 改动类型 |
|
||||
| -------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| 单元测试 | `tests/unit/bootstrap-env.test.mjs` | 各用例内设置 `process.env.DATA_DIR = dataDir` |
|
||||
| 单元测试 | `tests/unit/domain-persistence.test.mjs` | 单例 tmpDir、beforeEach 清 DB 文件、after 删目录;costRules 改为小容差精确断言 |
|
||||
| 单元测试 | `tests/unit/fixes-p1.test.mjs` | restoreDbBackup 前 resetDbInstance、Windows skip 说明、resetStorage 重试、`unlinkFileWithRetry` 核心单测 |
|
||||
| 备份恢复 | `src/lib/db/backup.ts` | pre-restore 内联并 await、恢复 `_lastBackupAt` 节流语义、关库后 500ms 延迟、抽取 `unlinkFileWithRetry` 重试删除 |
|
||||
@@ -0,0 +1,332 @@
|
||||
# ZWS_README_V5 — 按协议配置模型兼容性 + 前端性能优化
|
||||
|
||||
V4 内容(HMR 泄漏修复、Edge 警告消除、测试稳定性)已完成;V5 在 V4 基础上实现**按协议维度配置模型兼容性**,新增前端查找性能优化与类型安全改进。
|
||||
|
||||
---
|
||||
|
||||
## 一、如何发现问题
|
||||
|
||||
### 现象
|
||||
|
||||
- 同一模型被 **OpenAI Chat Completions**、**OpenAI Responses API**、**Anthropic Messages** 三种客户端请求形态调用时,V2 的兼容性开关(工具 ID 9 位、不保留 developer 角色)是**全局生效**的——无法为不同协议设置不同的兼容策略。
|
||||
- 例如:用户希望 OpenAI Responses API 请求时不保留 developer 角色(MiniMax 422 修复),但 OpenAI Chat Completions 请求时保留。V2 下只能二选一。
|
||||
- 前端兼容性弹层未标明当前配置对应哪种协议,容易误导。
|
||||
- 前端组件中 `Array.find()` 在每次渲染时对 customModels 和 modelCompatOverrides 做 O(n) 线性扫描,模型数量多时存在不必要的性能开销。
|
||||
- `ModelCompatPatch` 类型定义与运行时逻辑不一致:`preserveOpenAIDeveloperRole` 字段需要支持 `null`(表示取消设置/恢复默认),但类型仅允许 `boolean`。
|
||||
|
||||
### 排查过程
|
||||
|
||||
1. **需求分析**:梳理 `detectFormat(body)` 返回的三种协议键(`openai`、`openai-responses`、`claude`),确认每种协议对 developer 角色和 tool call ID 的需求不同。
|
||||
2. **数据模型设计**:在现有 `normalizeToolCallId` / `preserveOpenAIDeveloperRole` 顶层字段基础上,设计 `compatByProtocol` 嵌套结构,按协议键细分。
|
||||
3. **构建问题**:客户端 `"use client"` 组件直接从 `@/lib/localDb` 引入常量时,间接拉入了 `node:crypto`(经由 `db/proxies.ts`),触发 Webpack `UnhandledSchemeError`。需将常量拆到 `shared/` 层。
|
||||
4. **前端性能**:通过 React DevTools 和代码审计发现 `effectiveNormalizeForProtocol` 等函数每次调用都对数组做 `find()`,在渲染列表时存在 O(n²) 的隐患。
|
||||
|
||||
---
|
||||
|
||||
## 二、根因分析
|
||||
|
||||
### 根因 1(P0):兼容选项无协议维度
|
||||
|
||||
V2 的 `normalizeToolCallId` / `preserveOpenAIDeveloperRole` 存储在模型级别的顶层字段,无法区分请求来源协议。`chatCore.ts` 中的 getter 函数只接收 `(providerId, modelId)` 两个参数,不感知当前请求的 `sourceFormat`。
|
||||
|
||||
**影响**:跨协议场景下用户只能设置一个全局值,无法精确控制。
|
||||
|
||||
### 根因 2(P1):客户端构建拉入 Node.js 模块
|
||||
|
||||
`page.tsx`("use client")→ `@/lib/localDb` → `db/proxies.ts` → `import { randomUUID } from "node:crypto"`
|
||||
|
||||
Webpack 无法处理 `node:` URI scheme,报 `UnhandledSchemeError`。虽然 V4 已将 `node:crypto` → `crypto` 修复了 `proxies.ts`,但 `localDb.ts` 的 barrel export 链仍然存在风险——客户端组件不应引入任何可能传递到 Node.js 模块的路径。
|
||||
|
||||
### 根因 3(P2):前端查找性能
|
||||
|
||||
`effectiveNormalizeForProtocol`、`effectivePreserveForProtocol`、`anyNormalizeCompatBadge`、`anyNoPreserveCompatBadge` 四个函数每次调用都使用 `Array.find()` 在 `customModels` 和 `modelCompatOverrides` 数组中查找目标模型。在模型列表渲染时,每个模型行会调用多次这些函数,导致 O(n × m) 的查找开销(n = 模型数,m = 每行调用次数)。
|
||||
|
||||
### 根因 4(P2):类型定义与运行时不一致
|
||||
|
||||
```typescript
|
||||
// V3 暂存区版本(有问题)
|
||||
export type ModelCompatPatch = Partial<
|
||||
Pick<
|
||||
ModelCompatOverride,
|
||||
"normalizeToolCallId" | "preserveOpenAIDeveloperRole" | "compatByProtocol"
|
||||
>
|
||||
>;
|
||||
```
|
||||
|
||||
`ModelCompatOverride.preserveOpenAIDeveloperRole` 类型为 `boolean | undefined`,但 `mergeModelCompatOverride()` 内部有 `=== null` 判断(用于取消设置/恢复默认),类型层面无法覆盖。
|
||||
|
||||
---
|
||||
|
||||
## 三、修复方案
|
||||
|
||||
### 修复 1:`compatByProtocol` 存储与读取(models.ts)
|
||||
|
||||
**新增数据结构**:
|
||||
|
||||
```typescript
|
||||
type CompatByProtocolMap = Partial<Record<ModelCompatProtocolKey, ModelCompatPerProtocol>>;
|
||||
|
||||
export type ModelCompatOverride = {
|
||||
id: string;
|
||||
normalizeToolCallId?: boolean;
|
||||
preserveOpenAIDeveloperRole?: boolean;
|
||||
compatByProtocol?: CompatByProtocolMap; // 新增
|
||||
};
|
||||
```
|
||||
|
||||
**读取优先级链**(适用于 `getModelNormalizeToolCallId` 和 `getModelPreserveOpenAIDeveloperRole`):
|
||||
|
||||
```
|
||||
compatByProtocol[sourceFormat].field → 顶层 field → 默认值
|
||||
```
|
||||
|
||||
1. 若 `sourceFormat` 属于已知协议键(`openai` / `openai-responses` / `claude`),且 `compatByProtocol[sourceFormat]` 中存在目标字段,使用该值。
|
||||
2. 否则回退到顶层字段。
|
||||
3. 顶层字段也不存在时使用默认值(normalizeToolCallId=false,preserveOpenAIDeveloperRole=undefined)。
|
||||
|
||||
**深度合并逻辑** `deepMergeCompatByProtocol()`:
|
||||
|
||||
- 对每个协议键,逐字段合并而非覆盖。
|
||||
- `normalizeToolCallId=false` 时删除该字段(不存储 false,减少冗余)。
|
||||
- 合并后若整个协议条目为空对象,删除该协议条目。
|
||||
- 协议键通过 `isCompatProtocolKey()` 白名单校验,拒绝未知键。
|
||||
|
||||
**Getter 签名扩展**(向后兼容,第三参数可选):
|
||||
|
||||
```typescript
|
||||
export function getModelNormalizeToolCallId(
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
sourceFormat?: string | null
|
||||
): boolean;
|
||||
|
||||
export function getModelPreserveOpenAIDeveloperRole(
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
sourceFormat?: string | null
|
||||
): boolean | undefined;
|
||||
```
|
||||
|
||||
**优点**:
|
||||
|
||||
- 完全向后兼容:无 `sourceFormat` 参数时行为与 V2 完全一致。
|
||||
- 协议键白名单校验防止存储污染。
|
||||
- 深度合并保留未变更协议的配置。
|
||||
|
||||
**缺点/注意**:
|
||||
|
||||
- JSON 存储体积略增(每个模型最多增加 3 个协议条目)。
|
||||
- 新增 ~80 行 TypeScript 代码。
|
||||
|
||||
### 修复 2:请求管线传入 sourceFormat(chatCore.ts)
|
||||
|
||||
```typescript
|
||||
const normalizeToolCallId = getModelNormalizeToolCallId(
|
||||
provider || "",
|
||||
model || "",
|
||||
sourceFormat // 新增第三参
|
||||
);
|
||||
const preserveDeveloperRole = getModelPreserveOpenAIDeveloperRole(
|
||||
provider || "",
|
||||
model || "",
|
||||
sourceFormat // 新增第三参
|
||||
);
|
||||
```
|
||||
|
||||
`sourceFormat` 由已有的 `detectFormat(body)` 返回,无需新增检测逻辑。
|
||||
|
||||
**优点**:
|
||||
|
||||
- 改动仅 2 行,精准传参。
|
||||
- 不影响其他 handler(embeddings、imageGeneration 等不涉及 developer 角色和 tool call ID)。
|
||||
|
||||
### 修复 3:API 路由支持 compatByProtocol(route.ts)
|
||||
|
||||
**PUT 请求体扩展**:
|
||||
|
||||
- 解构 `compatByProtocol` 并传入 `updateCustomModel()`。
|
||||
- `compatOnly` 判断扩展:仅含 `provider` + `modelId` + 兼容字段时,走 `mergeModelCompatOverride()` 路径。
|
||||
- 使用 `ModelCompatPatch` 类型替代行内类型定义,统一类型来源。
|
||||
|
||||
**Zod 校验 schema**:
|
||||
|
||||
```typescript
|
||||
const modelCompatPerProtocolSchema = z.object({
|
||||
normalizeToolCallId: z.boolean().optional(),
|
||||
preserveOpenAIDeveloperRole: z.boolean().optional(),
|
||||
}).strict(); // strict: 拒绝额外字段
|
||||
|
||||
compatByProtocol: z
|
||||
.record(z.enum(["openai", "openai-responses", "claude"]), modelCompatPerProtocolSchema)
|
||||
.optional(),
|
||||
```
|
||||
|
||||
**优点**:
|
||||
|
||||
- `.strict()` 防止客户端注入额外字段。
|
||||
- `z.enum()` 限定协议键,与后端白名单一致。
|
||||
- 仅传 `compatByProtocol` 即可更新,前端无需拼装完整模型对象。
|
||||
|
||||
### 修复 4:客户端安全常量拆分(modelCompat.ts)
|
||||
|
||||
**新增** `src/shared/constants/modelCompat.ts`:
|
||||
|
||||
```typescript
|
||||
export const MODEL_COMPAT_PROTOCOL_KEYS = ["openai", "openai-responses", "claude"] as const;
|
||||
export type ModelCompatProtocolKey = (typeof MODEL_COMPAT_PROTOCOL_KEYS)[number];
|
||||
```
|
||||
|
||||
- 不依赖 Node.js / DB 代码,客户端组件可安全引入。
|
||||
- `models.ts` 从此模块引入并再导出。
|
||||
- `localDb.ts` 新增 `ModelCompatPatch` 类型导出(供 route.ts 使用),不导出协议常量。
|
||||
- `page.tsx` 改为从 `@/shared/constants/modelCompat` 引入。
|
||||
|
||||
**优点**:
|
||||
|
||||
- 彻底切断客户端 → localDb → db → proxies → node:crypto 的依赖链。
|
||||
- 协议键定义单一来源(Single Source of Truth)。
|
||||
|
||||
### 修复 5:前端协议选择器与按协议解析(page.tsx)
|
||||
|
||||
**ModelCompatPopover 重构**:
|
||||
|
||||
- 新增协议下拉选择器(`<select>`),可选 OpenAI Chat / OpenAI Responses / Anthropic Messages。
|
||||
- 两个开关(工具 ID 9 位、不保留 developer)**针对选中协议**生效。
|
||||
- 选择 Claude 协议时隐藏 developer 角色开关(developer 仅对 OpenAI 系有意义)。
|
||||
- 保存时以 `{ compatByProtocol: { [protocol]: payload } }` 形式提交,后端按协议合并。
|
||||
- 深色模式适配:下拉框使用 `bg-white dark:bg-zinc-800`、`text-zinc-900 dark:text-zinc-100`。
|
||||
|
||||
**Props 接口重构**:
|
||||
|
||||
旧接口(4 个独立值/回调):
|
||||
|
||||
```typescript
|
||||
(normalizeToolCallId, preserveDeveloperRole, onNormalizeChange, onPreserveChange);
|
||||
```
|
||||
|
||||
新接口(3 个函数式 props):
|
||||
|
||||
```typescript
|
||||
effectiveModelNormalize: (protocol: string) => boolean
|
||||
effectiveModelPreserveDeveloper: (protocol: string) => boolean
|
||||
onCompatPatch: (protocol: string, payload: {...}) => void
|
||||
```
|
||||
|
||||
所有消费方(`ModelRow`、`PassthroughModelRow`、`CustomModelsSection`、`CompatibleModelsSection`)已同步更新。
|
||||
|
||||
**角标显示逻辑**:
|
||||
|
||||
- `anyNormalizeCompatBadge()`:任意协议或顶层存在 `normalizeToolCallId=true` 即显示「ID×9」角标。
|
||||
- `anyNoPreserveCompatBadge()`:任意协议或顶层存在 `preserveOpenAIDeveloperRole=false` 即显示「不保留」角标。
|
||||
|
||||
**CustomModelsSection 增强**:
|
||||
|
||||
- 新增 `modelCompatOverrides` 状态,从 API 响应中获取。
|
||||
- 新增 `saveCustomCompat()` 函数,支持仅传 `compatByProtocol` 的独立保存。
|
||||
|
||||
### 修复 6:前端 Map 查找性能优化(page.tsx)
|
||||
|
||||
**问题**:`effectiveNormalizeForProtocol` 等函数对 `customModels` 和 `modelCompatOverrides` 用 `Array.find()` 做 O(n) 查找,在列表渲染时每个模型行多次调用。
|
||||
|
||||
**方案**:使用 `useMemo` + `Map` 将数组预建为 O(1) 查找表。
|
||||
|
||||
```typescript
|
||||
type CompatModelMap = Map<string, CompatModelRow>;
|
||||
|
||||
function buildCompatMap(rows: CompatModelRow[]): CompatModelMap {
|
||||
const m = new Map<string, CompatModelRow>();
|
||||
for (const r of rows) if (r.id) m.set(r.id, r);
|
||||
return m;
|
||||
}
|
||||
|
||||
// 在组件内
|
||||
const customMap = useMemo(() => buildCompatMap(modelMeta.customModels), [modelMeta.customModels]);
|
||||
const overrideMap = useMemo(
|
||||
() => buildCompatMap(modelMeta.modelCompatOverrides),
|
||||
[modelMeta.modelCompatOverrides]
|
||||
);
|
||||
```
|
||||
|
||||
所有查找函数签名从 `(modelId, protocol, customModels[], overrides[])` 改为 `(modelId, protocol, customMap, overrideMap)`,内部使用 `Map.get()` 替代 `Array.find()`。
|
||||
|
||||
**优点**:
|
||||
|
||||
- 查找从 O(n) 降为 O(1)。
|
||||
- `useMemo` 依赖项正确,仅在数据变化时重建 Map。
|
||||
- `CustomModelsSection` 内部也独立构建 Map,不依赖父组件。
|
||||
|
||||
### 修复 7:ModelCompatPatch 类型修正(models.ts)
|
||||
|
||||
```typescript
|
||||
// 修复后 — 显式允许 null
|
||||
export type ModelCompatPatch = {
|
||||
normalizeToolCallId?: boolean;
|
||||
preserveOpenAIDeveloperRole?: boolean | null; // null = 取消设置/恢复默认
|
||||
compatByProtocol?: CompatByProtocolMap;
|
||||
};
|
||||
```
|
||||
|
||||
与 `mergeModelCompatOverride()` 内的 `=== null` 判断逻辑一致,类型安全。
|
||||
|
||||
### 修复 8:CompatByProtocolMap 类型收紧(page.tsx)
|
||||
|
||||
客户端 `CompatByProtocolMap` 从 `Record<string, ...>` 改为 `Record<ModelCompatProtocolKey, ...>`,增强类型安全,防止传入未知协议键。
|
||||
|
||||
### 修复 9:i18n 文案新增
|
||||
|
||||
| 键名 | 中文 | 英文 |
|
||||
| ------------------------------- | --------------------------------------------- | -------------------------------------------------------------- |
|
||||
| `compatProtocolLabel` | 客户端请求协议 | Client request protocol |
|
||||
| `compatProtocolHint` | 以下选项在 OmniRoute 识别到该请求形态时生效。 | These options apply when OmniRoute detects this request shape. |
|
||||
| `compatProtocolOpenAI` | OpenAI Chat Completions | OpenAI Chat Completions |
|
||||
| `compatProtocolOpenAIResponses` | OpenAI Responses API | OpenAI Responses API |
|
||||
| `compatProtocolClaude` | Anthropic Messages | Anthropic Messages |
|
||||
|
||||
---
|
||||
|
||||
## 四、使用方式
|
||||
|
||||
1. 点击模型行的 **「兼容性」** 按钮。
|
||||
2. 在弹层内先选择 **「客户端请求协议」**(OpenAI Chat / OpenAI Responses / Anthropic Messages)。
|
||||
3. 勾选该协议下的「工具 ID 9 位」或「不保留 developer 角色」。
|
||||
4. 保存后,仅在该协议形态的请求下生效。
|
||||
5. 未配置某协议时,该协议下行为回退到顶层兼容字段(若存在),再回退到默认值(保留 developer、不规范化 tool id)。
|
||||
6. 角标「ID×9」「不保留」在任意协议存在对应配置时显示。
|
||||
|
||||
---
|
||||
|
||||
## 五、预期效果
|
||||
|
||||
| 指标 | 修复前 | 修复后 |
|
||||
| ------------------------- | --------------------- | ------------------------------------------ |
|
||||
| 兼容性配置维度 | 全局(模型级) | 按协议(OpenAI Chat / Responses / Claude) |
|
||||
| developer 角色精确控制 | 不支持 | 支持(如:仅 Responses API 不保留) |
|
||||
| 前端兼容性查找性能 | O(n) Array.find | O(1) Map.get(useMemo 缓存) |
|
||||
| ModelCompatPatch 类型安全 | null 值无类型覆盖 | 显式 `boolean \| null` |
|
||||
| 客户端构建风险 | 可能引入 Node.js 模块 | 已隔离(shared/constants 层) |
|
||||
| API 验证 | 无 compatByProtocol | Zod strict schema 校验 |
|
||||
| 深色模式 | 协议选择器不可读 | bg/text 适配 dark 主题 |
|
||||
|
||||
---
|
||||
|
||||
## 六、涉及文件清单
|
||||
|
||||
| 区域 | 文件 | 改动类型 |
|
||||
| ---------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| 协议常量 | `src/shared/constants/modelCompat.ts` | **新建**,客户端安全的协议键与类型 |
|
||||
| 存储与读写 | `src/lib/db/models.ts` | `compatByProtocol` 数据结构、深度合并、getter 第三参 `sourceFormat`、`ModelCompatPatch` 类型修正 |
|
||||
| 再导出层 | `src/lib/localDb.ts` | 新增 `ModelCompatPatch` 类型导出 |
|
||||
| API 路由 | `src/app/api/provider-models/route.ts` | PUT 支持 `compatByProtocol`,使用 `ModelCompatPatch` 类型 |
|
||||
| 输入校验 | `src/shared/validation/schemas.ts` | `modelCompatPerProtocolSchema`(strict)+ `compatByProtocol` 记录校验 |
|
||||
| 请求管线 | `open-sse/handlers/chatCore.ts` | `getModelNormalizeToolCallId` / `getModelPreserveOpenAIDeveloperRole` 传入 `sourceFormat` |
|
||||
| 前端 UI | `src/app/(dashboard)/dashboard/providers/[id]/page.tsx` | 协议选择器、按协议解析/保存、角标逻辑、Map 性能优化、类型收紧 |
|
||||
| i18n | `src/i18n/messages/en.json`,`src/i18n/messages/zh-CN.json` | 5 条新文案 |
|
||||
|
||||
---
|
||||
|
||||
## 七、回退方案
|
||||
|
||||
- **禁用按协议配置**:删除 `compatByProtocol` 字段后,getter 自动回退到顶层字段,行为与 V2 一致。
|
||||
- **前端 Map 优化回退**:将 `Map.get()` 改回 `Array.find()` 即可,纯性能优化无功能耦合。
|
||||
- **客户端常量回退**:将 `MODEL_COMPAT_PROTOCOL_KEYS` 定义移回 `models.ts` 并从 `localDb.ts` 导出(需同时确保 `node:crypto` 问题不再存在)。
|
||||
- **生产环境**:以上修复对生产构建无负面影响。`compatByProtocol` 为可选字段,未配置时默认行为不变。API Zod 校验确保不会接受畸形数据。
|
||||
@@ -932,8 +932,8 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
| ميزة | ماذا يفعل || -------------------------- | ------------------------------------------------------------- |
|
||||
| 🖼️ **إنشاء الصور** | `/v1/images/generations` مع الواجهات الخلفية السحابية والمحلية |
|
||||
| 📐 **المضامين** | `/v1/embeddings` للبحث وخطوط أنابيب RAG |
|
||||
| 🎤 **نسخ صوتي** | `/v1/audio/transcriptions` (مقدمو خدمات الهمس والإضافيون) |
|
||||
| 🔊 **تحويل النص إلى كلام** | `/v1/audio/speech` (محركات/موفرو متعددون) |
|
||||
| 🎤 **نسخ صوتي** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **تحويل النص إلى كلام** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🎬 **توليد الفيديو** | `/v1/videos/generations` (سير عمل ComfyUI + SD WebUI) |
|
||||
| 🎵 **جيل الموسيقى** | `/v1/music/generations` (سير عمل ComfyUI) |
|
||||
| 🛡️ **اعتدالات** | فحوصات السلامة `/v1/moderations` |
|
||||
|
||||
@@ -933,8 +933,8 @@ OmniRoute v2.0 е създаден като операционна платфо
|
||||
| Характеристика | Какво прави || -------------------------- | ------------------------------------------------------------ |
|
||||
| 🖼️ **Генериране на изображения** | `/v1/images/generations` с облак и локален бекенд |
|
||||
| 📐 **Вграждания** | `/v1/embeddings` за търсене и RAG тръбопроводи |
|
||||
| 🎤 **Аудио транскрипция** | `/v1/audio/transcriptions` (Whisper и допълнителни доставчици) |
|
||||
| 🔊 **Текст към говор** | `/v1/audio/speech` (множество машини/доставчици) |
|
||||
| 🎤 **Аудио транскрипция** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **Текст към говор** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🎬 **Видео генериране** | `/v1/videos/generations` (работни процеси ComfyUI + SD WebUI) |
|
||||
| 🎵 **Музикално поколение** | `/v1/music/generations` (работни процеси на ComfyUI) |
|
||||
| 🛡️ **Модерации** | `/v1/moderations` проверки за безопасност |
|
||||
|
||||
@@ -934,8 +934,8 @@ OmniRoute v2.0 er bygget som en operationel platform, ikke kun en relæ-proxy.
|
||||
| Funktion | Hvad det gør || -------------------------- | -------------------------------------------------------------------- |
|
||||
| 🖼️ **Billedgenerering** | `/v1/images/generations` med cloud og lokale backends |
|
||||
| 📐 **Indlejringer** | `/v1/embeddings` til søgning og RAG-rørledninger |
|
||||
| 🎤 **Lydtransskription** | `/v1/audio/transcriptions` (Whisper og yderligere udbydere) |
|
||||
| 🔊 **Tekst-til-tale** | `/v1/audio/speech` (flere motorer/udbydere) |
|
||||
| 🎤 **Lydtransskription** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **Tekst-til-tale** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🎬 **Videogenerering** | `/v1/videos/generations` (ComfyUI + SD WebUI-arbejdsgange) |
|
||||
| 🎵 **Music Generation** | `/v1/music/generations` (ComfyUI-arbejdsgange) |
|
||||
| 🛡️ **Moderationer** | `/v1/moderations` sikkerhedstjek |
|
||||
|
||||
@@ -939,8 +939,8 @@ OmniRoute v2.0 ist als Betriebsplattform konzipiert und nicht nur als Relay-Prox
|
||||
| Funktion | Was es tut || -------------------------- | ------------------------------------------------------------- |
|
||||
| 🖼️ **Bilderzeugung** | `/v1/images/generations` mit Cloud- und lokalen Backends |
|
||||
| 📐 **Einbettungen** | `/v1/embeddings` für Such- und RAG-Pipelines |
|
||||
| 🎤 **Audio-Transkription** | `/v1/audio/transcriptions` (Whisper und zusätzliche Anbieter) |
|
||||
| 🔊 **Text-to-Speech** | `/v1/audio/speech` (mehrere Engines/Anbieter) |
|
||||
| 🎤 **Audio-Transkription** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **Text-to-Speech** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🎬 **Videogenerierung** | `/v1/videos/generations` (ComfyUI + SD WebUI-Workflows) |
|
||||
| 🎵 **Musikgeneration** | `/v1/music/generations` (ComfyUI-Workflows) |
|
||||
| 🛡️ **Moderationen** | `/v1/moderations` Sicherheitsprüfungen |
|
||||
|
||||
@@ -877,14 +877,14 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
|
||||
### 🎵 APIs Multi-Modal
|
||||
|
||||
| Característica | Qué Hace |
|
||||
| ----------------------------- | ------------------------------------------------------ |
|
||||
| 🖼️ **Generación de Imágenes** | `/v1/images/generations` — 4 proveedores, 9+ modelos |
|
||||
| 📐 **Embeddings** | `/v1/embeddings` — 6 proveedores, 9+ modelos |
|
||||
| 🎤 **Transcripción de Audio** | `/v1/audio/transcriptions` — Compatible con Whisper |
|
||||
| 🔊 **Texto a Voz** | `/v1/audio/speech` — Síntesis de audio multi-proveedor |
|
||||
| 🛡️ **Moderaciones** | `/v1/moderations` — Verificaciones de seguridad |
|
||||
| 🔀 **Reranking** | `/v1/rerank` — Reranking de relevancia de documentos |
|
||||
| Característica | Qué Hace |
|
||||
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **Generación de Imágenes** | `/v1/images/generations` — 4 proveedores, 9+ modelos |
|
||||
| 📐 **Embeddings** | `/v1/embeddings` — 6 proveedores, 9+ modelos |
|
||||
| 🎤 **Transcripción de Audio** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **Texto a Voz** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🛡️ **Moderaciones** | `/v1/moderations` — Verificaciones de seguridad |
|
||||
| 🔀 **Reranking** | `/v1/rerank` — Reranking de relevancia de documentos |
|
||||
|
||||
### 🛡️ Resiliencia y Seguridad
|
||||
|
||||
|
||||
@@ -874,14 +874,14 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
|
||||
### 🎵 Multimodaaliset sovellusliittymät
|
||||
|
||||
| Ominaisuus | Mitä se tekee |
|
||||
| ------------------------- | --------------------------------------------------------- |
|
||||
| 🖼️ **Kuvan luominen** | `/v1/images/generations` — 4 toimittajaa, 9+ mallia |
|
||||
| 📐 **Upotukset** | `/v1/embeddings` — 6 toimittajaa, 9+ mallia |
|
||||
| 🎤 **Äänitranskriptio** | `/v1/audio/transcriptions` — Kuiskausyhteensopiva |
|
||||
| 🔊 **Tekstistä puheeksi** | `/v1/audio/speech` — Usean palveluntarjoajan äänisynteesi |
|
||||
| 🛡️ **Moderaatiot** | `/v1/moderations` — Sisällön turvallisuustarkistukset |
|
||||
| 🔀 **Uudelleenjärjestys** | `/v1/rerank` — Asiakirjan osuvuuden uudelleensijoitus |
|
||||
| Ominaisuus | Mitä se tekee |
|
||||
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **Kuvan luominen** | `/v1/images/generations` — 4 toimittajaa, 9+ mallia |
|
||||
| 📐 **Upotukset** | `/v1/embeddings` — 6 toimittajaa, 9+ mallia |
|
||||
| 🎤 **Äänitranskriptio** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **Tekstistä puheeksi** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🛡️ **Moderaatiot** | `/v1/moderations` — Sisällön turvallisuustarkistukset |
|
||||
| 🔀 **Uudelleenjärjestys** | `/v1/rerank` — Asiakirjan osuvuuden uudelleensijoitus |
|
||||
|
||||
### 🛡️ Joustavuus ja turvallisuus
|
||||
|
||||
|
||||
@@ -875,14 +875,14 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
|
||||
### 🎵 APIs multi-modales
|
||||
|
||||
| Fonctionnalité | Ce qu'elle fait |
|
||||
| -------------------------- | ------------------------------------------------------- |
|
||||
| 🖼️ **Génération d'images** | `/v1/images/generations` — 4 fournisseurs, 9+ modèles |
|
||||
| 📐 **Embeddings** | `/v1/embeddings` — 6 fournisseurs, 9+ modèles |
|
||||
| 🎤 **Transcription audio** | `/v1/audio/transcriptions` — compatible Whisper |
|
||||
| 🔊 **Texte vers parole** | `/v1/audio/speech` — synthèse audio multi-fournisseur |
|
||||
| 🛡️ **Modérations** | `/v1/moderations` — vérifications de sécurité |
|
||||
| 🔀 **Reranking** | `/v1/rerank` — reclassement de pertinence des documents |
|
||||
| Fonctionnalité | Ce qu'elle fait |
|
||||
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **Génération d'images** | `/v1/images/generations` — 4 fournisseurs, 9+ modèles |
|
||||
| 📐 **Embeddings** | `/v1/embeddings` — 6 fournisseurs, 9+ modèles |
|
||||
| 🎤 **Transcription audio** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **Texte vers parole** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🛡️ **Modérations** | `/v1/moderations` — vérifications de sécurité |
|
||||
| 🔀 **Reranking** | `/v1/rerank` — reclassement de pertinence des documents |
|
||||
|
||||
### 🛡️ Résilience & Sécurité
|
||||
|
||||
|
||||
@@ -873,14 +873,14 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
|
||||
### 🎵 ממשקי API רב-מודאליים
|
||||
|
||||
| תכונה | מה זה עושה |
|
||||
| ------------------- | --------------------------------------------- |
|
||||
| 🖼️ **יצירת תמונות** | `/v1/images/generations` — 4 ספקים, 9+ דגמים |
|
||||
| 📐 **הטבעות** | `/v1/embeddings` — 6 ספקים, 9+ דגמים |
|
||||
| 🎤 **תמלול אודיו** | `/v1/audio/transcriptions` — תואם לחישה |
|
||||
| 🔊 **טקסט לדיבור** | `/v1/audio/speech` — סינתזת אודיו מרובה ספקים |
|
||||
| 🛡️ **מנחים** | `/v1/moderations` — בדיקות בטיחות תוכן |
|
||||
| 🔀 **דירוג מחדש** | `/v1/rerank` — דירוג מחדש של רלוונטיות המסמך |
|
||||
| תכונה | מה זה עושה |
|
||||
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **יצירת תמונות** | `/v1/images/generations` — 4 ספקים, 9+ דגמים |
|
||||
| 📐 **הטבעות** | `/v1/embeddings` — 6 ספקים, 9+ דגמים |
|
||||
| 🎤 **תמלול אודיו** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **טקסט לדיבור** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🛡️ **מנחים** | `/v1/moderations` — בדיקות בטיחות תוכן |
|
||||
| 🔀 **דירוג מחדש** | `/v1/rerank` — דירוג מחדש של רלוונטיות המסמך |
|
||||
|
||||
### 🛡️ חוסן וביטחון
|
||||
|
||||
|
||||
@@ -874,14 +874,14 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
|
||||
### 🎵 Multimodális API-k
|
||||
|
||||
| Funkció | Mit csinál |
|
||||
| ---------------------- | ------------------------------------------------------------ |
|
||||
| 🖼️ **Képgenerálás** | `/v1/images/generations` — 4 szolgáltató, 9+ modell |
|
||||
| 📐 **Beágyazás** | `/v1/embeddings` — 6 szolgáltató, 9+ modell |
|
||||
| 🎤 **Audio átírás** | `/v1/audio/transcriptions` — Suttogás-kompatibilis |
|
||||
| 🔊 **Szövegfelolvasó** | `/v1/audio/speech` — Hangszintézis több szolgáltatónál |
|
||||
| 🛡️ **Moderálás** | `/v1/moderations` — Tartalombiztonsági ellenőrzések |
|
||||
| 🔀 **Átsorolás** | `/v1/rerank` — A dokumentumok relevancia szerinti átsorolása |
|
||||
| Funkció | Mit csinál |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **Képgenerálás** | `/v1/images/generations` — 4 szolgáltató, 9+ modell |
|
||||
| 📐 **Beágyazás** | `/v1/embeddings` — 6 szolgáltató, 9+ modell |
|
||||
| 🎤 **Audio átírás** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **Szövegfelolvasó** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🛡️ **Moderálás** | `/v1/moderations` — Tartalombiztonsági ellenőrzések |
|
||||
| 🔀 **Átsorolás** | `/v1/rerank` — A dokumentumok relevancia szerinti átsorolása |
|
||||
|
||||
### 🛡️ Rugalmasság és biztonság
|
||||
|
||||
|
||||
@@ -874,14 +874,14 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
|
||||
### 🎵 API Multi-Modal
|
||||
|
||||
| Fitur | Apa Fungsinya |
|
||||
| -------------------------- | ------------------------------------------------------ |
|
||||
| 🖼️ **Pembuatan Gambar** | `/v1/images/generations` — 4 penyedia, 9+ model |
|
||||
| 📐 **Sematan** | `/v1/embeddings` — 6 penyedia, 9+ model |
|
||||
| 🎤 **Transkripsi Audio** | `/v1/audio/transcriptions` — Kompatibel dengan bisikan |
|
||||
| 🔊 **Teks-ke-Ucapan** | `/v1/audio/speech` — Sintesis audio multi-penyedia |
|
||||
| 🛡️ **Moderasi** | `/v1/moderations` — Pemeriksaan keamanan konten |
|
||||
| 🔀 **Pemeringkatan Ulang** | `/v1/rerank` — Pemeringkatan ulang relevansi dokumen |
|
||||
| Fitur | Apa Fungsinya |
|
||||
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **Pembuatan Gambar** | `/v1/images/generations` — 4 penyedia, 9+ model |
|
||||
| 📐 **Sematan** | `/v1/embeddings` — 6 penyedia, 9+ model |
|
||||
| 🎤 **Transkripsi Audio** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **Teks-ke-Ucapan** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🛡️ **Moderasi** | `/v1/moderations` — Pemeriksaan keamanan konten |
|
||||
| 🔀 **Pemeringkatan Ulang** | `/v1/rerank` — Pemeringkatan ulang relevansi dokumen |
|
||||
|
||||
### 🛡️ Ketahanan & Keamanan
|
||||
|
||||
|
||||
@@ -770,14 +770,14 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
|
||||
### 🎵 मल्टी-मॉडल एपीआई
|
||||
|
||||
| फ़ीचर | यह क्या करता है |
|
||||
| ---------------------------- | ------------------------------------------------- |
|
||||
| 🖼️ **छवि निर्माण** | `/v1/images/generations` - 4 प्रदाता, 9+ मॉडल |
|
||||
| 📐 **एंबेडिंग** | `/v1/embeddings` — 6 प्रदाता, 9+ मॉडल |
|
||||
| 🎤 **ऑडियो ट्रांस्क्रिप्शन** | `/v1/audio/transcriptions` - कानाफूसी-संगत |
|
||||
| 🔊 **टेक्स्ट-टू-स्पीच** | `/v1/audio/speech` - बहु-प्रदाता ऑडियो संश्लेषण |
|
||||
| 🛡️ **संयम** | `/v1/moderations` — सामग्री सुरक्षा जांच |
|
||||
| 🔀 **पुनर्रैंकिंग** | `/v1/rerank` — दस्तावेज़ प्रासंगिकता पुनर्रैंकिंग |
|
||||
| फ़ीचर | यह क्या करता है |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **छवि निर्माण** | `/v1/images/generations` - 4 प्रदाता, 9+ मॉडल |
|
||||
| 📐 **एंबेडिंग** | `/v1/embeddings` — 6 प्रदाता, 9+ मॉडल |
|
||||
| 🎤 **ऑडियो ट्रांस्क्रिप्शन** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **टेक्स्ट-टू-स्पीच** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🛡️ **संयम** | `/v1/moderations` — सामग्री सुरक्षा जांच |
|
||||
| 🔀 **पुनर्रैंकिंग** | `/v1/rerank` — दस्तावेज़ प्रासंगिकता पुनर्रैंकिंग |
|
||||
|
||||
### 🛡️ लचीलापन और सुरक्षा
|
||||
|
||||
|
||||
@@ -874,14 +874,14 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
|
||||
### 🎵 API Multi-modali
|
||||
|
||||
| Funzionalità | Cosa Fa |
|
||||
| --------------------------- | ---------------------------------------------------- |
|
||||
| 🖼️ **Generazione immagini** | `/v1/images/generations` — 4 provider, 9+ modelli |
|
||||
| 📐 **Embeddings** | `/v1/embeddings` — 6 provider, 9+ modelli |
|
||||
| 🎤 **Trascrizione audio** | `/v1/audio/transcriptions` — Compatibile Whisper |
|
||||
| 🔊 **Testo a voce** | `/v1/audio/speech` — Sintesi audio multi-provider |
|
||||
| 🛡️ **Moderazioni** | `/v1/moderations` — Controlli di sicurezza |
|
||||
| 🔀 **Reranking** | `/v1/rerank` — Riclassificazione rilevanza documenti |
|
||||
| Funzionalità | Cosa Fa |
|
||||
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **Generazione immagini** | `/v1/images/generations` — 4 provider, 9+ modelli |
|
||||
| 📐 **Embeddings** | `/v1/embeddings` — 6 provider, 9+ modelli |
|
||||
| 🎤 **Trascrizione audio** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **Testo a voce** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🛡️ **Moderazioni** | `/v1/moderations` — Controlli di sicurezza |
|
||||
| 🔀 **Reranking** | `/v1/rerank` — Riclassificazione rilevanza documenti |
|
||||
|
||||
### 🛡️ Resilienza & Sicurezza
|
||||
|
||||
|
||||
@@ -874,14 +874,14 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
|
||||
### 🎵 マルチモーダル API
|
||||
|
||||
| 特集 | 何をするのか |
|
||||
| ----------------------- | --------------------------------------------------------------- |
|
||||
| 🖼️ **画像生成** | `/v1/images/generations` — 4 つのプロバイダー、9 つ以上のモデル |
|
||||
| 📐 **埋め込み** | `/v1/embeddings` — 6 つのプロバイダー、9 つ以上のモデル |
|
||||
| 🎤 **音声文字起こし** | `/v1/audio/transcriptions` — ウィスパー互換 |
|
||||
| 🔊 **テキスト読み上げ** | `/v1/audio/speech` — マルチプロバイダーのオーディオ合成 |
|
||||
| 🛡️ **モデレーション** | `/v1/moderations` — コンテンツの安全性チェック |
|
||||
| 🔀 **再ランキング** | `/v1/rerank` — ドキュメントの関連性の再ランキング |
|
||||
| 特集 | 何をするのか |
|
||||
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **画像生成** | `/v1/images/generations` — 4 つのプロバイダー、9 つ以上のモデル |
|
||||
| 📐 **埋め込み** | `/v1/embeddings` — 6 つのプロバイダー、9 つ以上のモデル |
|
||||
| 🎤 **音声文字起こし** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **テキスト読み上げ** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🛡️ **モデレーション** | `/v1/moderations` — コンテンツの安全性チェック |
|
||||
| 🔀 **再ランキング** | `/v1/rerank` — ドキュメントの関連性の再ランキング |
|
||||
|
||||
### 🛡️ 復元力とセキュリティ
|
||||
|
||||
|
||||
@@ -873,14 +873,14 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
|
||||
### 🎵 다중 모드 API
|
||||
|
||||
| 기능 | 그것이 하는 일 |
|
||||
| ----------------------- | ------------------------------------------------------ |
|
||||
| 🖼️ **이미지 생성** | `/v1/images/generations` — 4개 공급자, 9개 이상의 모델 |
|
||||
| 📐 **임베딩** | `/v1/embeddings` — 6개 공급자, 9개 이상의 모델 |
|
||||
| 🎤 **오디오 전사** | `/v1/audio/transcriptions` — 속삭임 호환 |
|
||||
| 🔊 **텍스트 음성 변환** | `/v1/audio/speech` — 다중 제공자 오디오 합성 |
|
||||
| 🛡️ **조정** | `/v1/moderations` — 콘텐츠 안전 확인 |
|
||||
| 🔀 **재순위** | `/v1/rerank` — 문서 관련성 재순위 |
|
||||
| 기능 | 그것이 하는 일 |
|
||||
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **이미지 생성** | `/v1/images/generations` — 4개 공급자, 9개 이상의 모델 |
|
||||
| 📐 **임베딩** | `/v1/embeddings` — 6개 공급자, 9개 이상의 모델 |
|
||||
| 🎤 **오디오 전사** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **텍스트 음성 변환** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🛡️ **조정** | `/v1/moderations` — 콘텐츠 안전 확인 |
|
||||
| 🔀 **재순위** | `/v1/rerank` — 문서 관련성 재순위 |
|
||||
|
||||
### 🛡️ 복원력 및 보안
|
||||
|
||||
|
||||
@@ -873,14 +873,14 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
|
||||
### 🎵 API Berbilang Modal
|
||||
|
||||
| Ciri | Apa yang Dilakukan |
|
||||
| ------------------------ | ------------------------------------------------------ |
|
||||
| 🖼️ **Penjanaan Imej** | `/v1/images/generations` — 4 pembekal, 9+ model |
|
||||
| 📐 **Pembenaman** | `/v1/embeddings` — 6 pembekal, 9+ model |
|
||||
| 🎤 **Transkripsi Audio** | `/v1/audio/transcriptions` — Serasi dengan bisikan |
|
||||
| 🔊 **Teks-ke-Ucapan** | `/v1/audio/speech` — Sintesis audio berbilang pembekal |
|
||||
| 🛡️ **Kesederhanaan** | `/v1/moderations` — Pemeriksaan keselamatan kandungan |
|
||||
| 🔀 **Penyusunan semula** | `/v1/rerank` — Penarafan semula perkaitan dokumen |
|
||||
| Ciri | Apa yang Dilakukan |
|
||||
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **Penjanaan Imej** | `/v1/images/generations` — 4 pembekal, 9+ model |
|
||||
| 📐 **Pembenaman** | `/v1/embeddings` — 6 pembekal, 9+ model |
|
||||
| 🎤 **Transkripsi Audio** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **Teks-ke-Ucapan** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🛡️ **Kesederhanaan** | `/v1/moderations` — Pemeriksaan keselamatan kandungan |
|
||||
| 🔀 **Penyusunan semula** | `/v1/rerank` — Penarafan semula perkaitan dokumen |
|
||||
|
||||
### 🛡️ Ketahanan & Keselamatan
|
||||
|
||||
|
||||
@@ -873,14 +873,14 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
|
||||
### 🎵 Multimodale API's
|
||||
|
||||
| Kenmerk | Wat het doet |
|
||||
| ------------------------ | --------------------------------------------------------- |
|
||||
| 🖼️ **Beeldgeneratie** | `/v1/images/generations` — 4 providers, 9+ modellen |
|
||||
| 📐 **Insluitingen** | `/v1/embeddings` — 6 providers, 9+ modellen |
|
||||
| 🎤 **Audiotranscriptie** | `/v1/audio/transcriptions` — Whisper-compatibel |
|
||||
| 🔊 **Tekst-naar-spraak** | `/v1/audio/speech` — Audiosynthese van meerdere providers |
|
||||
| 🛡️ **Moderaties** | `/v1/moderations` — Veiligheidscontroles van inhoud |
|
||||
| 🔀 **Herschikking** | `/v1/rerank` — Herschikking van documentrelevantie |
|
||||
| Kenmerk | Wat het doet |
|
||||
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **Beeldgeneratie** | `/v1/images/generations` — 4 providers, 9+ modellen |
|
||||
| 📐 **Insluitingen** | `/v1/embeddings` — 6 providers, 9+ modellen |
|
||||
| 🎤 **Audiotranscriptie** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **Tekst-naar-spraak** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🛡️ **Moderaties** | `/v1/moderations` — Veiligheidscontroles van inhoud |
|
||||
| 🔀 **Herschikking** | `/v1/rerank` — Herschikking van documentrelevantie |
|
||||
|
||||
### 🛡️ Veerkracht en veiligheid
|
||||
|
||||
|
||||
@@ -873,14 +873,14 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
|
||||
### 🎵 Multi-Modal APIer
|
||||
|
||||
| Funksjon | Hva det gjør |
|
||||
| ----------------------- | ------------------------------------------------------ |
|
||||
| 🖼️ **Bildegenerering** | `/v1/images/generations` — 4 leverandører, 9+ modeller |
|
||||
| 📐 **Innbygging** | `/v1/embeddings` — 6 leverandører, 9+ modeller |
|
||||
| 🎤 **Lydtranskripsjon** | `/v1/audio/transcriptions` — Whisper-kompatibel |
|
||||
| 🔊 **Tekst-til-tale** | `/v1/audio/speech` — Multi-leverandør lydsyntese |
|
||||
| 🛡️ **Moderasjoner** | `/v1/moderations` — Innholdssikkerhetssjekker |
|
||||
| 🔀 **Omrangering** | `/v1/rerank` — Rerangering av dokumentrelevans |
|
||||
| Funksjon | Hva det gjør |
|
||||
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **Bildegenerering** | `/v1/images/generations` — 4 leverandører, 9+ modeller |
|
||||
| 📐 **Innbygging** | `/v1/embeddings` — 6 leverandører, 9+ modeller |
|
||||
| 🎤 **Lydtranskripsjon** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **Tekst-til-tale** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🛡️ **Moderasjoner** | `/v1/moderations` — Innholdssikkerhetssjekker |
|
||||
| 🔀 **Omrangering** | `/v1/rerank` — Rerangering av dokumentrelevans |
|
||||
|
||||
### 🛡️ Spenst og sikkerhet
|
||||
|
||||
|
||||
@@ -873,14 +873,14 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
|
||||
### 🎵 Mga Multi-Modal na API
|
||||
|
||||
| Tampok | Ano ang Ginagawa Nito |
|
||||
| -------------------------- | ------------------------------------------------------------ |
|
||||
| 🖼️ **Pagbuo ng Larawan** | `/v1/images/generations` — 4 na provider, 9+ na modelo |
|
||||
| 📐 **Mga Pag-embed** | `/v1/embeddings` — 6 na provider, 9+ na modelo |
|
||||
| 🎤 **Audio Transcription** | `/v1/audio/transcriptions` — Whisper-compatible |
|
||||
| 🔊 **Text-to-Speech** | `/v1/audio/speech` — Multi-provider audio synthesis |
|
||||
| 🛡️ **Mga Pag-moderate** | `/v1/moderations` — Mga pagsusuri sa kaligtasan ng nilalaman |
|
||||
| 🔀 **Reranking** | `/v1/rerank` — Muling pagraranggo ng kaugnayan ng dokumento |
|
||||
| Tampok | Ano ang Ginagawa Nito |
|
||||
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **Pagbuo ng Larawan** | `/v1/images/generations` — 4 na provider, 9+ na modelo |
|
||||
| 📐 **Mga Pag-embed** | `/v1/embeddings` — 6 na provider, 9+ na modelo |
|
||||
| 🎤 **Audio Transcription** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **Text-to-Speech** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🛡️ **Mga Pag-moderate** | `/v1/moderations` — Mga pagsusuri sa kaligtasan ng nilalaman |
|
||||
| 🔀 **Reranking** | `/v1/rerank` — Muling pagraranggo ng kaugnayan ng dokumento |
|
||||
|
||||
### 🛡️ Katatagan at Seguridad
|
||||
|
||||
|
||||
@@ -873,14 +873,14 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
|
||||
### 🎵 Wielomodalne interfejsy API
|
||||
|
||||
| Funkcja | Co to robi |
|
||||
| ----------------------------- | ------------------------------------------------------ |
|
||||
| 🖼️ **Generowanie obrazu** | `/v1/images/generations` — 4 dostawców, ponad 9 modeli |
|
||||
| 📐 **Osadzenia** | `/v1/embeddings` — 6 dostawców, ponad 9 modeli |
|
||||
| 🎤 **Transkrypcja audio** | `/v1/audio/transcriptions` — Kompatybilny z szeptem |
|
||||
| 🔊 **Zamiana tekstu na mowę** | `/v1/audio/speech` — Synteza dźwięku wielu dostawców |
|
||||
| 🛡️ **Moderacje** | `/v1/moderations` — Kontrola bezpieczeństwa treści |
|
||||
| 🔀 **Ponowna pozycja** | `/v1/rerank` — Zmiana rankingu trafności dokumentu |
|
||||
| Funkcja | Co to robi |
|
||||
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **Generowanie obrazu** | `/v1/images/generations` — 4 dostawców, ponad 9 modeli |
|
||||
| 📐 **Osadzenia** | `/v1/embeddings` — 6 dostawców, ponad 9 modeli |
|
||||
| 🎤 **Transkrypcja audio** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **Zamiana tekstu na mowę** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🛡️ **Moderacje** | `/v1/moderations` — Kontrola bezpieczeństwa treści |
|
||||
| 🔀 **Ponowna pozycja** | `/v1/rerank` — Zmiana rankingu trafności dokumentu |
|
||||
|
||||
### 🛡️ Odporność i bezpieczeństwo
|
||||
|
||||
|
||||
+10
-10
@@ -879,16 +879,16 @@ Por que isso é relevante:
|
||||
|
||||
### 🎵 APIs Multi-Modal
|
||||
|
||||
| Funcionalidade | O que Faz |
|
||||
| --------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **Geração de Imagem** | `/v1/images/generations` — 10 provedores, 20+ modelos (cloud + local) |
|
||||
| 📐 **Embeddings** | `/v1/embeddings` — 6 provedores, 9+ modelos |
|
||||
| 🎤 **Transcrição de Áudio** | `/v1/audio/transcriptions` — Whisper + Nvidia NIM, HuggingFace, Qwen3 |
|
||||
| 🔊 **Texto para Fala** | `/v1/audio/speech` — ElevenLabs, Nvidia NIM, HuggingFace, Coqui, Tortoise, Qwen3, Inworld, Cartesia, PlayHT |
|
||||
| 🎬 **Geração de Vídeo** | `/v1/videos/generations` — ComfyUI (AnimateDiff, SVD), SD WebUI |
|
||||
| 🎵 **Geração de Música** | `/v1/music/generations` — ComfyUI (Stable Audio Open, MusicGen) |
|
||||
| 🛡️ **Moderações** | `/v1/moderations` — Verificações de segurança |
|
||||
| 🔀 **Reranking** | `/v1/rerank` — Reranking de relevância de documentos |
|
||||
| Funcionalidade | O que Faz |
|
||||
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **Geração de Imagem** | `/v1/images/generations` — 10 provedores, 20+ modelos (cloud + local) |
|
||||
| 📐 **Embeddings** | `/v1/embeddings` — 6 provedores, 9+ modelos |
|
||||
| 🎤 **Transcrição de Áudio** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **Texto para Fala** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🎬 **Geração de Vídeo** | `/v1/videos/generations` — ComfyUI (AnimateDiff, SVD), SD WebUI |
|
||||
| 🎵 **Geração de Música** | `/v1/music/generations` — ComfyUI (Stable Audio Open, MusicGen) |
|
||||
| 🛡️ **Moderações** | `/v1/moderations` — Verificações de segurança |
|
||||
| 🔀 **Reranking** | `/v1/rerank` — Reranking de relevância de documentos |
|
||||
|
||||
### 🛡️ Resiliência e Segurança
|
||||
|
||||
|
||||
@@ -874,14 +874,14 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
|
||||
### 🎵 APIs multimodais
|
||||
|
||||
| Recurso | O que faz |
|
||||
| --------------------------------- | ----------------------------------------------------------- |
|
||||
| 🖼️ **Geração de imagens** | `/v1/images/generations` — 4 provedores, mais de 9 modelos |
|
||||
| 📐 **Incorporações** | `/v1/embeddings` — 6 provedores, mais de 9 modelos |
|
||||
| 🎤 **Transcrição de áudio** | `/v1/audio/transcriptions` — Compatível com sussurro |
|
||||
| 🔊 **Conversão de texto em fala** | `/v1/audio/speech` — Síntese de áudio multiprovedor |
|
||||
| 🛡️ **Moderações** | `/v1/moderations` — Verificações de segurança de conteúdo |
|
||||
| 🔀 **Reclassificação** | `/v1/rerank` — Reclassificação da relevância dos documentos |
|
||||
| Recurso | O que faz |
|
||||
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **Geração de imagens** | `/v1/images/generations` — 4 provedores, mais de 9 modelos |
|
||||
| 📐 **Incorporações** | `/v1/embeddings` — 6 provedores, mais de 9 modelos |
|
||||
| 🎤 **Transcrição de áudio** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **Conversão de texto em fala** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🛡️ **Moderações** | `/v1/moderations` — Verificações de segurança de conteúdo |
|
||||
| 🔀 **Reclassificação** | `/v1/rerank` — Reclassificação da relevância dos documentos |
|
||||
|
||||
### 🛡️ Resiliência e segurança
|
||||
|
||||
|
||||
@@ -875,14 +875,14 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
|
||||
### 🎵 API-uri multimodale
|
||||
|
||||
| Caracteristica | Ce face |
|
||||
| ------------------------- | ---------------------------------------------------------- |
|
||||
| 🖼️ **Generarea imaginii** | `/v1/images/generations` — 4 furnizori, peste 9 modele |
|
||||
| 📐 **Inglobări** | `/v1/embeddings` — 6 furnizori, peste 9 modele |
|
||||
| 🎤 **Transcriere audio** | `/v1/audio/transcriptions` — Compatibil cu Whisper |
|
||||
| 🔊 **Text-to-speech** | `/v1/audio/speech` — Sinteză audio cu mai mulți furnizori |
|
||||
| 🛡️ **Moderații** | `/v1/moderations` — Verificări de siguranță a conținutului |
|
||||
| 🔀 **Reclasificare** | `/v1/rerank` — Reclasificarea relevanței documentului |
|
||||
| Caracteristica | Ce face |
|
||||
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **Generarea imaginii** | `/v1/images/generations` — 4 furnizori, peste 9 modele |
|
||||
| 📐 **Inglobări** | `/v1/embeddings` — 6 furnizori, peste 9 modele |
|
||||
| 🎤 **Transcriere audio** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **Text-to-speech** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🛡️ **Moderații** | `/v1/moderations` — Verificări de siguranță a conținutului |
|
||||
| 🔀 **Reclasificare** | `/v1/rerank` — Reclasificarea relevanței documentului |
|
||||
|
||||
### 🛡️ Reziliență și securitate
|
||||
|
||||
|
||||
@@ -873,14 +873,14 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
|
||||
### 🎵 Мультимодальные API
|
||||
|
||||
| Функция | Что делает |
|
||||
| ---------------------------- | --------------------------------------------------- |
|
||||
| 🖼️ **Генерация изображений** | `/v1/images/generations` — 4 провайдера, 9+ моделей |
|
||||
| 📐 **Embeddings** | `/v1/embeddings` — 6 провайдеров, 9+ моделей |
|
||||
| 🎤 **Транскрипция аудио** | `/v1/audio/transcriptions` — Совместимо с Whisper |
|
||||
| 🔊 **Текст в речь** | `/v1/audio/speech` — Мульти-провайдерный синтез |
|
||||
| 🛡️ **Модерация** | `/v1/moderations` — Проверки безопасности контента |
|
||||
| 🔀 **Reranking** | `/v1/rerank` — Переранжирование релевантности |
|
||||
| Функция | Что делает |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **Генерация изображений** | `/v1/images/generations` — 4 провайдера, 9+ моделей |
|
||||
| 📐 **Embeddings** | `/v1/embeddings` — 6 провайдеров, 9+ моделей |
|
||||
| 🎤 **Транскрипция аудио** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **Текст в речь** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🛡️ **Модерация** | `/v1/moderations` — Проверки безопасности контента |
|
||||
| 🔀 **Reranking** | `/v1/rerank` — Переранжирование релевантности |
|
||||
|
||||
### 🛡️ Устойчивость и безопасность
|
||||
|
||||
|
||||
@@ -877,14 +877,14 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
|
||||
### 🎵 Multimodálne API
|
||||
|
||||
| Funkcia | Čo to robí |
|
||||
| --------------------------- | ---------------------------------------------------------------- |
|
||||
| 🖼️ **Generovanie obrázkov** | `/v1/images/generations` — 4 poskytovatelia, 9+ modelov |
|
||||
| 📐 **Vloženie** | `/v1/embeddings` — 6 poskytovateľov, 9+ modelov |
|
||||
| 🎤 **Prepis zvuku** | `/v1/audio/transcriptions` — Kompatibilné so šepotom |
|
||||
| 🔊 **Prevod textu na reč** | `/v1/audio/speech` — Zvuková syntéza od viacerých poskytovateľov |
|
||||
| 🛡️ **Moderovania** | `/v1/moderations` — Kontroly bezpečnosti obsahu |
|
||||
| 🔀 **Reranking** | `/v1/rerank` — Zmena poradia relevantnosti dokumentu |
|
||||
| Funkcia | Čo to robí |
|
||||
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **Generovanie obrázkov** | `/v1/images/generations` — 4 poskytovatelia, 9+ modelov |
|
||||
| 📐 **Vloženie** | `/v1/embeddings` — 6 poskytovateľov, 9+ modelov |
|
||||
| 🎤 **Prepis zvuku** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **Prevod textu na reč** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🛡️ **Moderovania** | `/v1/moderations` — Kontroly bezpečnosti obsahu |
|
||||
| 🔀 **Reranking** | `/v1/rerank` — Zmena poradia relevantnosti dokumentu |
|
||||
|
||||
### 🛡️ Odolnosť a bezpečnosť
|
||||
|
||||
|
||||
@@ -873,14 +873,14 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
|
||||
### 🎵 Multimodala API:er
|
||||
|
||||
| Funktion | Vad det gör |
|
||||
| ------------------------ | ------------------------------------------------------ |
|
||||
| 🖼️ **Bildgenerering** | `/v1/images/generations` — 4 leverantörer, 9+ modeller |
|
||||
| 📐 **Inbäddningar** | `/v1/embeddings` — 6 leverantörer, 9+ modeller |
|
||||
| 🎤 **Ljudtranskription** | `/v1/audio/transcriptions` — Whisper-kompatibel |
|
||||
| 🔊 **Text-till-tal** | `/v1/audio/speech` — Ljudsyntes med flera leverantörer |
|
||||
| 🛡️ **Moderationer** | `/v1/moderations` — Innehållssäkerhetskontroller |
|
||||
| 🔀 **Omrankning** | `/v1/rerank` — Omrankning av dokumentrelevans |
|
||||
| Funktion | Vad det gör |
|
||||
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **Bildgenerering** | `/v1/images/generations` — 4 leverantörer, 9+ modeller |
|
||||
| 📐 **Inbäddningar** | `/v1/embeddings` — 6 leverantörer, 9+ modeller |
|
||||
| 🎤 **Ljudtranskription** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **Text-till-tal** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🛡️ **Moderationer** | `/v1/moderations` — Innehållssäkerhetskontroller |
|
||||
| 🔀 **Omrankning** | `/v1/rerank` — Omrankning av dokumentrelevans |
|
||||
|
||||
### 🛡️ Motståndskraft och säkerhet
|
||||
|
||||
|
||||
@@ -874,14 +874,14 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
|
||||
### 🎵 Multi-Modal API
|
||||
|
||||
| คุณสมบัติ | มันทำอะไร |
|
||||
| ----------------------- | ------------------------------------------------------------- |
|
||||
| 🖼️ **การสร้างภาพ** | `/v1/images/generations` — ผู้ให้บริการ 4 ราย รุ่น 9+ |
|
||||
| 📐 **การฝัง** | `/v1/embeddings` — ผู้ให้บริการ 6 ราย รุ่น 9+ |
|
||||
| 🎶 **การถอดเสียง** | `/v1/audio/transcriptions` — รองรับการกระซิบ |
|
||||
| 🔊 **ข้อความเป็นคำพูด** | `/v1/audio/speech` — การสังเคราะห์เสียงจากผู้ให้บริการหลายราย |
|
||||
| 🛡️ **การกลั่นกรอง** | `/v1/moderations` — การตรวจสอบความปลอดภัยของเนื้อหา |
|
||||
| 🔀 **จัดอันดับ** | `/v1/rerank` — การจัดอันดับความเกี่ยวข้องของเอกสาร |
|
||||
| คุณสมบัติ | มันทำอะไร |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **การสร้างภาพ** | `/v1/images/generations` — ผู้ให้บริการ 4 ราย รุ่น 9+ |
|
||||
| 📐 **การฝัง** | `/v1/embeddings` — ผู้ให้บริการ 6 ราย รุ่น 9+ |
|
||||
| 🎶 **การถอดเสียง** | `/v1/audio/transcriptions` — รองรับการกระซิบ |
|
||||
| 🔊 **ข้อความเป็นคำพูด** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🛡️ **การกลั่นกรอง** | `/v1/moderations` — การตรวจสอบความปลอดภัยของเนื้อหา |
|
||||
| 🔀 **จัดอันดับ** | `/v1/rerank` — การจัดอันดับความเกี่ยวข้องของเอกสาร |
|
||||
|
||||
### 🛡️ ความยืดหยุ่นและความปลอดภัย
|
||||
|
||||
|
||||
@@ -878,14 +878,14 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
|
||||
### 🎵 Мультимодальні API
|
||||
|
||||
| Особливість | Що він робить |
|
||||
| ---------------------------------- | ----------------------------------------------------- |
|
||||
| 🖼️ **Створення зображень** | `/v1/images/generations` — 4 провайдери, 9+ моделей |
|
||||
| 📐 **Вбудовування** | `/v1/embeddings` — 6 провайдерів, 9+ моделей |
|
||||
| 🎤 **Транскрипція аудіо** | `/v1/audio/transcriptions` — сумісний із Whisper |
|
||||
| 🔊 **Створення тексту в мовлення** | `/v1/audio/speech` — Багатопровайдерний аудіосинтез |
|
||||
| 🛡️ **Модерації** | `/v1/moderations` — Перевірка безпеки вмісту |
|
||||
| 🔀 **Переранжування** | `/v1/rerank` — Переранжування релевантності документа |
|
||||
| Особливість | Що він робить |
|
||||
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **Створення зображень** | `/v1/images/generations` — 4 провайдери, 9+ моделей |
|
||||
| 📐 **Вбудовування** | `/v1/embeddings` — 6 провайдерів, 9+ моделей |
|
||||
| 🎤 **Транскрипція аудіо** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **Створення тексту в мовлення** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🛡️ **Модерації** | `/v1/moderations` — Перевірка безпеки вмісту |
|
||||
| 🔀 **Переранжування** | `/v1/rerank` — Переранжування релевантності документа |
|
||||
|
||||
### 🛡️ Стійкість і безпека
|
||||
|
||||
|
||||
@@ -874,14 +874,14 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
|
||||
### 🎵 API đa phương thức
|
||||
|
||||
| Tính năng | Nó làm gì |
|
||||
| ------------------------------------- | ------------------------------------------------------------ |
|
||||
| 🖼️ **Tạo hình ảnh** | `/v1/images/generations` — 4 nhà cung cấp, hơn 9 mô hình |
|
||||
| 📐 **Nhúng** | `/v1/embeddings` — 6 nhà cung cấp, hơn 9 mô hình |
|
||||
| 🎤 **Phiên âm âm thanh** | `/v1/audio/transcriptions` — Tương thích với lời thì thầm |
|
||||
| 🔊 **Chuyển văn bản thành giọng nói** | `/v1/audio/speech` — Tổng hợp âm thanh từ nhiều nhà cung cấp |
|
||||
| 🛡️ **Kiểm duyệt** | `/v1/moderations` — Kiểm tra an toàn nội dung |
|
||||
| 🔀 **Sắp xếp lại** | `/v1/rerank` — Sắp xếp lại mức độ liên quan của tài liệu |
|
||||
| Tính năng | Nó làm gì |
|
||||
| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **Tạo hình ảnh** | `/v1/images/generations` — 4 nhà cung cấp, hơn 9 mô hình |
|
||||
| 📐 **Nhúng** | `/v1/embeddings` — 6 nhà cung cấp, hơn 9 mô hình |
|
||||
| 🎤 **Phiên âm âm thanh** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **Chuyển văn bản thành giọng nói** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🛡️ **Kiểm duyệt** | `/v1/moderations` — Kiểm tra an toàn nội dung |
|
||||
| 🔀 **Sắp xếp lại** | `/v1/rerank` — Sắp xếp lại mức độ liên quan của tài liệu |
|
||||
|
||||
### 🛡️ Khả năng phục hồi và bảo mật
|
||||
|
||||
|
||||
@@ -873,14 +873,14 @@ npm run electron:build:linux # Linux (.AppImage)
|
||||
|
||||
### 🎵 多模态 API
|
||||
|
||||
| 功能 | 功能描述 |
|
||||
| ----------------- | ---------------------------------------------- |
|
||||
| 🖼️ **图像生成** | `/v1/images/generations` — 4 个提供商,9+ 模型 |
|
||||
| 📐 **Embeddings** | `/v1/embeddings` — 6 个提供商,9+ 模型 |
|
||||
| 🎤 **音频转录** | `/v1/audio/transcriptions` — Whisper 兼容 |
|
||||
| 🔊 **文字转语音** | `/v1/audio/speech` — 多提供商音频合成 |
|
||||
| 🛡️ **内容审核** | `/v1/moderations` — 内容安全检查 |
|
||||
| 🔀 **重排序** | `/v1/rerank` — 文档相关性重排序 |
|
||||
| 功能 | 功能描述 |
|
||||
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **图像生成** | `/v1/images/generations` — 4 个提供商,9+ 模型 |
|
||||
| 📐 **Embeddings** | `/v1/embeddings` — 6 个提供商,9+ 模型 |
|
||||
| 🎤 **音频转录** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support |
|
||||
| 🔊 **文字转语音** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) |
|
||||
| 🛡️ **内容审核** | `/v1/moderations` — 内容安全检查 |
|
||||
| 🔀 **重排序** | `/v1/rerank` — 文档相关性重排序 |
|
||||
|
||||
### 🛡️ 弹性与安全
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 2.8.7
|
||||
version: 2.9.2
|
||||
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,
|
||||
|
||||
@@ -1125,6 +1125,35 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
{ id: "claude-sonnet-4-5@20251101", name: "Claude Sonnet 4.5 (Vertex)" },
|
||||
],
|
||||
},
|
||||
|
||||
alibaba: {
|
||||
id: "alibaba",
|
||||
alias: "ali",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
// DashScope international OpenAI-compatible endpoint.
|
||||
// China users should set providerSpecificData.baseUrl to:
|
||||
// https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions
|
||||
baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions",
|
||||
modelsUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
{ id: "qwen-max", name: "Qwen Max" },
|
||||
{ id: "qwen-max-2025-01-25", name: "Qwen Max (2025-01-25)" },
|
||||
{ id: "qwen-plus", name: "Qwen Plus" },
|
||||
{ id: "qwen-plus-2025-07-14", name: "Qwen Plus (2025-07-14)" },
|
||||
{ id: "qwen-turbo", name: "Qwen Turbo" },
|
||||
{ id: "qwen-turbo-2025-11-01", name: "Qwen Turbo (2025-11-01)" },
|
||||
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
|
||||
{ id: "qwen3-coder-flash", name: "Qwen3 Coder Flash" },
|
||||
{ id: "qwq-plus", name: "QwQ Plus (Reasoning)" },
|
||||
{ id: "qwq-32b", name: "QwQ 32B" },
|
||||
{ id: "qwen3-32b", name: "Qwen3 32B" },
|
||||
{ id: "qwen3-235b-a22b", name: "Qwen3 235B A22B" },
|
||||
],
|
||||
passthroughModels: true,
|
||||
},
|
||||
};
|
||||
|
||||
// ── Generator Functions ───────────────────────────────────────────────────
|
||||
|
||||
@@ -2,6 +2,20 @@ import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts";
|
||||
import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts";
|
||||
import { getRotatingApiKey } from "../services/apiKeyRotator.ts";
|
||||
|
||||
/**
|
||||
* Sanitizes a custom API path to prevent path traversal attacks.
|
||||
* Valid paths must start with '/', contain no '..' segments,
|
||||
* no null bytes, and be reasonable in length.
|
||||
*/
|
||||
function sanitizePath(path: string): boolean {
|
||||
if (typeof path !== "string") return false;
|
||||
if (!path.startsWith("/")) return false;
|
||||
if (path.includes("\0")) return false; // null byte
|
||||
if (path.includes("..")) return false; // path traversal
|
||||
if (path.length > 512) return false; // sanity limit
|
||||
return true;
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export type ProviderConfig = {
|
||||
@@ -103,7 +117,9 @@ export class BaseExecutor {
|
||||
const psd = credentials?.providerSpecificData;
|
||||
const baseUrl = typeof psd?.baseUrl === "string" ? psd.baseUrl : "https://api.openai.com/v1";
|
||||
const normalized = baseUrl.replace(/\/$/, "");
|
||||
const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null;
|
||||
// Sanitize custom path: must start with '/', no path traversal, no null bytes
|
||||
const rawPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null;
|
||||
const customPath = rawPath && sanitizePath(rawPath) ? rawPath : null;
|
||||
if (customPath) return `${normalized}${customPath}`;
|
||||
const path = this.provider.includes("responses") ? "/responses" : "/chat/completions";
|
||||
return `${normalized}${path}`;
|
||||
|
||||
@@ -80,18 +80,14 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
/**
|
||||
* For compatible providers, ensure the model name sent upstream
|
||||
* is the clean model name without internal routing prefixes.
|
||||
* e.g. "openapi-chat-anti/claude-opus-4-6-thinking" → "claude-opus-4-6-thinking"
|
||||
* For compatible providers, the model name is already clean by the time
|
||||
* it reaches the executor (chatCore sets body.model = modelInfo.model,
|
||||
* which is the parsed model ID without internal routing prefixes).
|
||||
*
|
||||
* Models may legitimately contain "/" as part of their ID (e.g. "zai-org/GLM-5-FP8",
|
||||
* "org/model-name") — we must NOT strip path segments. (Fix #493)
|
||||
*/
|
||||
transformRequest(model, body, stream, credentials) {
|
||||
if (
|
||||
this.provider?.startsWith?.("openai-compatible-") ||
|
||||
this.provider?.startsWith?.("anthropic-compatible-")
|
||||
) {
|
||||
const cleanModel = model.includes("/") ? model.split("/").slice(1).join("/") : model;
|
||||
return { ...body, model: cleanModel };
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,13 +28,17 @@ function upstreamErrorResponse(res, errText) {
|
||||
let errorMessage: string;
|
||||
try {
|
||||
const parsed = JSON.parse(errText);
|
||||
errorMessage =
|
||||
// Extract a human-readable message from various error response shapes.
|
||||
// Guard against `parsed.error` being an object (e.g. ElevenLabs returns
|
||||
// { error: { message: "...", status_code: 401 } } or { detail: { ... } })
|
||||
const raw =
|
||||
parsed?.err_msg ||
|
||||
parsed?.error?.message ||
|
||||
parsed?.error ||
|
||||
(typeof parsed?.error === "string" ? parsed.error : null) ||
|
||||
parsed?.message ||
|
||||
parsed?.detail ||
|
||||
errText;
|
||||
(typeof parsed?.detail === "string" ? parsed.detail : parsed?.detail?.message) ||
|
||||
null;
|
||||
errorMessage = raw ? String(raw) : errText || `Upstream error (${res.status})`;
|
||||
} catch {
|
||||
errorMessage = errText || `Upstream error (${res.status})`;
|
||||
}
|
||||
|
||||
@@ -34,13 +34,15 @@ function upstreamErrorResponse(res, errText) {
|
||||
let errorMessage: string;
|
||||
try {
|
||||
const parsed = JSON.parse(errText);
|
||||
errorMessage =
|
||||
// Guard against `parsed.error` or `parsed.detail` being objects
|
||||
const raw =
|
||||
parsed?.err_msg ||
|
||||
parsed?.error?.message ||
|
||||
parsed?.error ||
|
||||
(typeof parsed?.error === "string" ? parsed.error : null) ||
|
||||
parsed?.message ||
|
||||
parsed?.detail ||
|
||||
errText;
|
||||
(typeof parsed?.detail === "string" ? parsed.detail : parsed?.detail?.message) ||
|
||||
null;
|
||||
errorMessage = raw ? String(raw) : errText || `Upstream error (${res.status})`;
|
||||
} catch {
|
||||
errorMessage = errText || `Upstream error (${res.status})`;
|
||||
}
|
||||
@@ -65,13 +67,67 @@ function getUploadedFileName(file: Blob & { name?: unknown }): string {
|
||||
return typeof file.name === "string" && file.name.length > 0 ? file.name : "audio.wav";
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer a suitable Content-Type for Deepgram from the browser-provided MIME
|
||||
* type and the original filename. Deepgram accepts `audio/*` and many raw
|
||||
* formats, but `video/*` causes it to silently fail with "no speech detected".
|
||||
*
|
||||
* Strategy:
|
||||
* 1. If the browser says `audio/*`, keep it as-is.
|
||||
* 2. If it's `video/*` (e.g. `.mp4`), remap to the audio equivalent so
|
||||
* Deepgram extracts the audio track. `.mp4` → `audio/mp4`, etc.
|
||||
* 3. Fall back to `application/octet-stream` which tells Deepgram to
|
||||
* auto-detect from the raw bytes (most reliable for unknown formats).
|
||||
*/
|
||||
function resolveAudioContentType(file: Blob & { name?: unknown }): string {
|
||||
const browserType = (file.type || "").toLowerCase();
|
||||
const fileName = typeof file.name === "string" ? file.name.toLowerCase() : "";
|
||||
|
||||
// 1) Browser already says it's audio — trust it
|
||||
if (browserType.startsWith("audio/")) return browserType;
|
||||
|
||||
// 2) Derive from file extension (covers video/* and empty MIME)
|
||||
const ext = fileName.includes(".") ? fileName.split(".").pop() : "";
|
||||
const EXT_TO_MIME: Record<string, string> = {
|
||||
mp3: "audio/mpeg",
|
||||
mp4: "audio/mp4",
|
||||
m4a: "audio/mp4",
|
||||
wav: "audio/wav",
|
||||
ogg: "audio/ogg",
|
||||
flac: "audio/flac",
|
||||
webm: "audio/webm",
|
||||
aac: "audio/aac",
|
||||
wma: "audio/x-ms-wma",
|
||||
opus: "audio/opus",
|
||||
};
|
||||
if (ext && EXT_TO_MIME[ext]) return EXT_TO_MIME[ext];
|
||||
|
||||
// 3) Fallback — let Deepgram auto-detect from raw bytes
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Deepgram transcription (raw binary audio, model via query param)
|
||||
*/
|
||||
async function handleDeepgramTranscription(providerConfig, file, modelId, token) {
|
||||
async function handleDeepgramTranscription(
|
||||
providerConfig,
|
||||
file,
|
||||
modelId,
|
||||
token,
|
||||
formData?: FormData
|
||||
) {
|
||||
const url = new URL(providerConfig.baseUrl);
|
||||
url.searchParams.set("model", modelId);
|
||||
url.searchParams.set("smart_format", "true");
|
||||
url.searchParams.set("punctuate", "true");
|
||||
|
||||
// Language: if caller specified one, use it; otherwise let Deepgram auto-detect
|
||||
const langParam = formData?.get("language");
|
||||
if (typeof langParam === "string" && langParam.trim()) {
|
||||
url.searchParams.set("language", langParam.trim());
|
||||
} else {
|
||||
url.searchParams.set("detect_language", "true");
|
||||
}
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
@@ -79,7 +135,7 @@ async function handleDeepgramTranscription(providerConfig, file, modelId, token)
|
||||
method: "POST",
|
||||
headers: {
|
||||
...buildAuthHeaders(providerConfig, token),
|
||||
"Content-Type": file.type || "audio/wav",
|
||||
"Content-Type": resolveAudioContentType(file),
|
||||
},
|
||||
body: arrayBuffer,
|
||||
});
|
||||
@@ -212,7 +268,7 @@ async function handleHuggingFaceTranscription(providerConfig, file, modelId, tok
|
||||
method: "POST",
|
||||
headers: {
|
||||
...buildAuthHeaders(providerConfig, token),
|
||||
"Content-Type": file.type || "audio/wav",
|
||||
"Content-Type": resolveAudioContentType(file),
|
||||
},
|
||||
body: arrayBuffer,
|
||||
});
|
||||
@@ -283,7 +339,7 @@ export async function handleAudioTranscription({
|
||||
|
||||
// Route to provider-specific handler
|
||||
if (providerConfig.format === "deepgram") {
|
||||
return handleDeepgramTranscription(providerConfig, file, modelId, token);
|
||||
return handleDeepgramTranscription(providerConfig, file, modelId, token, formData);
|
||||
}
|
||||
|
||||
if (providerConfig.format === "assemblyai") {
|
||||
|
||||
@@ -317,10 +317,15 @@ export async function handleChatCore({
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeToolCallId = getModelNormalizeToolCallId(provider || "", model || "");
|
||||
const normalizeToolCallId = getModelNormalizeToolCallId(
|
||||
provider || "",
|
||||
model || "",
|
||||
sourceFormat
|
||||
);
|
||||
const preserveDeveloperRole = getModelPreserveOpenAIDeveloperRole(
|
||||
provider || "",
|
||||
model || ""
|
||||
model || "",
|
||||
sourceFormat
|
||||
);
|
||||
translatedBody = translateRequest(
|
||||
sourceFormat,
|
||||
|
||||
@@ -447,8 +447,10 @@ export async function handleComboChat({
|
||||
const handleSingleModelWrapped = combo.context_cache_protection
|
||||
? async (b, modelStr) => {
|
||||
const res = await handleSingleModel(b, modelStr);
|
||||
// Inject tag only on success and only for non-streaming non-binary responses
|
||||
if (res.ok && !b.stream) {
|
||||
if (!res.ok) return res;
|
||||
|
||||
// Non-streaming: inject tag into JSON response (existing logic)
|
||||
if (!b.stream) {
|
||||
try {
|
||||
const json = await res.clone().json();
|
||||
const msgs = Array.isArray(json?.messages) ? json.messages : [];
|
||||
@@ -460,10 +462,71 @@ export async function handleComboChat({
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* non-JSON or stream — skip tagging */
|
||||
/* non-JSON — skip tagging */
|
||||
}
|
||||
return res;
|
||||
}
|
||||
return res;
|
||||
|
||||
// Streaming (Fix #490 + #511): prepend omniModel tag into the first
|
||||
// non-empty content chunk so it arrives BEFORE finish_reason:stop.
|
||||
// SDKs close the connection on finish_reason, so anything sent after
|
||||
// that marker is silently dropped.
|
||||
if (!res.body) return res;
|
||||
const tagContent = `\n<omniModel>${modelStr}</omniModel>\n`;
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
let tagInjected = false;
|
||||
|
||||
const transform = new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
if (tagInjected) {
|
||||
// Already injected — passthrough
|
||||
controller.enqueue(chunk);
|
||||
return;
|
||||
}
|
||||
|
||||
const text = decoder.decode(chunk, { stream: true });
|
||||
|
||||
// Look for the first SSE data line with non-empty content
|
||||
// Pattern: "content":"<non-empty>" — we inject tag at the start
|
||||
const contentMatch = text.match(/"content":"([^"]+)/);
|
||||
if (contentMatch) {
|
||||
// Inject tag at the beginning of the first content value
|
||||
const injected = text.replace(
|
||||
/"content":"([^"]+)/,
|
||||
`"content":"${tagContent.replace(/"/g, '\\"')}$1`
|
||||
);
|
||||
tagInjected = true;
|
||||
controller.enqueue(encoder.encode(injected));
|
||||
return;
|
||||
}
|
||||
|
||||
// No content yet — passthrough
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
flush(controller) {
|
||||
// If stream ends without ever finding content (edge case),
|
||||
// inject tag as a standalone chunk before the stream closes
|
||||
if (!tagInjected) {
|
||||
const tagChunk = `data: ${JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
delta: { content: tagContent },
|
||||
index: 0,
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
})}\n\n`;
|
||||
controller.enqueue(encoder.encode(tagChunk));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const transformedStream = res.body.pipeThrough(transform);
|
||||
return new Response(transformedStream, {
|
||||
status: res.status,
|
||||
headers: res.headers,
|
||||
});
|
||||
}
|
||||
: handleSingleModel;
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -80,9 +80,17 @@ function supportsSystemRole(provider: string, model: string): boolean {
|
||||
* OpenAI Responses API sends `developer`; MiniMax and most OpenAI-compatible gateways
|
||||
* only accept system/user/assistant/tool and return "role param error" otherwise.
|
||||
*
|
||||
* Logic:
|
||||
* - When targetFormat !== "openai": always convert developer → system (Claude, Gemini, etc.).
|
||||
* - When targetFormat === "openai": convert only when preserveDeveloperRole === false.
|
||||
* This covers OpenAI-compatible providers (MiniMax, etc.) that use targetFormat "openai"
|
||||
* but do not accept the developer role; the per-model preserveDeveloperRole flag is set
|
||||
* via the dashboard "Compatibility" toggle ("Do not preserve developer role").
|
||||
* - When targetFormat === "openai" && preserveDeveloperRole !== false: keep developer (e.g. official OpenAI).
|
||||
*
|
||||
* @param messages - Array of messages
|
||||
* @param targetFormat - The target format (e.g., "openai", "claude", "gemini")
|
||||
* @param preserveDeveloperRole - For targetFormat openai: undefined/true = keep developer (legacy default); false = map to system (MiniMax etc.)
|
||||
* @param preserveDeveloperRole - For targetFormat openai: undefined/true = keep developer (legacy default); false = map to system (MiniMax and other OpenAI-compatible gateways that reject developer)
|
||||
*/
|
||||
export function normalizeDeveloperRole(
|
||||
messages: NormalizedMessage[] | unknown,
|
||||
@@ -170,8 +178,14 @@ export function normalizeSystemRole(
|
||||
/**
|
||||
* Full role normalization pipeline.
|
||||
* Call this before sending the request to the provider.
|
||||
* Applies developer→system (when needed) then system→user for providers/models that do not support system role.
|
||||
*
|
||||
* @param preserveDeveloperRole - See {@link normalizeDeveloperRole}
|
||||
* @param messages - Array of messages to normalize (or non-array, returned as-is)
|
||||
* @param provider - Provider id for capability lookup (e.g. system role support)
|
||||
* @param model - Model id for capability lookup
|
||||
* @param targetFormat - Target request format (e.g. "openai", "claude", "gemini"); see {@link normalizeDeveloperRole}
|
||||
* @param preserveDeveloperRole - Optional; see {@link normalizeDeveloperRole}. When false, developer role is mapped to system.
|
||||
* @returns Normalized messages array, or the original value if messages is not an array
|
||||
*/
|
||||
export function normalizeRoles(
|
||||
messages: NormalizedMessage[] | unknown,
|
||||
|
||||
@@ -96,7 +96,9 @@ export function translateRequest(
|
||||
// Fix missing tool responses (insert empty tool_result if needed)
|
||||
fixMissingToolResponses(result);
|
||||
|
||||
// Normalize roles: developer→system unless preserved, system→user for incompatible models
|
||||
// Normalize roles: developer→system unless preserved, system→user for incompatible models.
|
||||
// This handles (1) sourceFormat openai with messages containing developer → non-openai target
|
||||
// or preserveDeveloperRole=false, and (2) all other paths where result.messages already exists.
|
||||
if (result.messages && Array.isArray(result.messages)) {
|
||||
result.messages = normalizeRoles(
|
||||
result.messages,
|
||||
@@ -151,8 +153,10 @@ export function translateRequest(
|
||||
result = normalizeOpenAIResponsesRequest(result);
|
||||
}
|
||||
|
||||
// After OPENAI_RESPONSES → OPENAI, messages are built from input; first normalizeRoles was a no-op.
|
||||
// Run role pipeline again so developer→system respects preserveDeveloperRole (no hardcoding in translator).
|
||||
// Second role normalization: only for OPENAI_RESPONSES. Here messages are built from input
|
||||
// after the translation step, so the first normalizeRoles (above) did not see them. For
|
||||
// sourceFormat openai with messages already on the body, the first block handles developer
|
||||
// → system (non-openai target or preserveDeveloperRole=false); no second pass needed.
|
||||
if (
|
||||
sourceFormat === FORMATS.OPENAI_RESPONSES &&
|
||||
result.messages &&
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.8.7",
|
||||
"version": "2.9.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "2.8.7",
|
||||
"version": "2.9.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.8.7",
|
||||
"version": "2.9.2",
|
||||
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -16,7 +16,8 @@ const { dashboardPort } = runtimePorts;
|
||||
const env = bootstrapEnv();
|
||||
|
||||
const args = ["./node_modules/next/dist/bin/next", mode, "--port", String(dashboardPort)];
|
||||
if (mode === "dev") {
|
||||
// Default: use webpack (stable). Set OMNIROUTE_USE_TURBOPACK=1 to use Turbopack (faster dev).
|
||||
if (mode === "dev" && process.env.OMNIROUTE_USE_TURBOPACK !== "1") {
|
||||
args.splice(2, 0, "--webpack");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Search Analytics Tab
|
||||
*
|
||||
* Shows search request stats from call_logs (request_type = 'search'),
|
||||
* provider breakdown, cache hit rate, and cost summary.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface SearchStats {
|
||||
total: number;
|
||||
today: number;
|
||||
cached: number;
|
||||
errors: number;
|
||||
totalCostUsd: number;
|
||||
byProvider: Record<string, { count: number; costUsd: number }>;
|
||||
last24h: Array<{ hour: string; count: number }>;
|
||||
cacheHitRate: number;
|
||||
avgDurationMs: number;
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
}: {
|
||||
icon: string;
|
||||
label: string;
|
||||
value: string | number;
|
||||
sub?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="card p-4 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2 text-text-muted text-sm">
|
||||
<span className="material-symbols-outlined text-[18px]">{icon}</span>
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-text">{value}</div>
|
||||
{sub && <div className="text-xs text-text-muted">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderBar({
|
||||
provider,
|
||||
count,
|
||||
total,
|
||||
costUsd,
|
||||
}: {
|
||||
provider: string;
|
||||
count: number;
|
||||
total: number;
|
||||
costUsd: number;
|
||||
}) {
|
||||
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium text-text">{provider}</span>
|
||||
<span className="text-text-muted">
|
||||
{count} queries · ${costUsd.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-text-muted text-right">{pct}%</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SearchAnalyticsTab() {
|
||||
const [stats, setStats] = useState<SearchStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/v1/search/analytics")
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
setStats(d);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
setError(e.message);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16 text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin mr-2">progress_activity</span>
|
||||
Loading search analytics…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !stats) {
|
||||
return (
|
||||
<div className="card p-6 text-center text-text-muted">
|
||||
<span className="material-symbols-outlined text-[32px] mb-2 block">search_off</span>
|
||||
{error || "No search data available yet."}
|
||||
<p className="text-xs mt-2">
|
||||
Search requests will appear here after the first search via /v1/search.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const providers = Object.entries(stats.byProvider).sort(([, a], [, b]) => b.count - a.count);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
icon="manage_search"
|
||||
label="Total Searches"
|
||||
value={stats.total.toLocaleString()}
|
||||
sub={`${stats.today} today`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="cached"
|
||||
label="Cache Hit Rate"
|
||||
value={`${stats.cacheHitRate}%`}
|
||||
sub={`${stats.cached} cached requests`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="attach_money"
|
||||
label="Total Cost"
|
||||
value={`$${stats.totalCostUsd.toFixed(4)}`}
|
||||
sub="search API costs"
|
||||
/>
|
||||
<StatCard
|
||||
icon="timer"
|
||||
label="Avg Response"
|
||||
value={`${stats.avgDurationMs}ms`}
|
||||
sub={stats.errors > 0 ? `${stats.errors} errors` : "No errors"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Provider Breakdown */}
|
||||
{providers.length > 0 && (
|
||||
<div className="card p-5">
|
||||
<h3 className="font-semibold text-text mb-4 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-primary text-[20px]">hub</span>
|
||||
Provider Breakdown
|
||||
</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
{providers.map(([prov, data]) => (
|
||||
<ProviderBar
|
||||
key={prov}
|
||||
provider={prov}
|
||||
count={data.count}
|
||||
total={stats.total}
|
||||
costUsd={data.costUsd}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{stats.total === 0 && (
|
||||
<div className="card p-8 text-center text-text-muted">
|
||||
<span className="material-symbols-outlined text-[48px] mb-3 block text-primary opacity-50">
|
||||
travel_explore
|
||||
</span>
|
||||
<p className="font-medium text-text">No searches yet</p>
|
||||
<p className="text-sm mt-1">
|
||||
Use <code className="bg-bg-muted px-1 rounded">POST /v1/search</code> to start routing
|
||||
web searches.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Free tier note */}
|
||||
<div className="text-xs text-text-muted border border-border rounded-lg p-3 flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-[16px] text-green-500 mt-0.5">
|
||||
check_circle
|
||||
</span>
|
||||
<span>
|
||||
<strong>Free tier available:</strong> Serper (2,500/mo), Brave (2,000/mo), Exa (1,000/mo),
|
||||
Tavily (1,000/mo) — total 6,500+ free searches/month with automatic failover.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,15 +3,17 @@
|
||||
import { useState, Suspense } from "react";
|
||||
import { UsageAnalytics, CardSkeleton, SegmentedControl } from "@/shared/components";
|
||||
import EvalsTab from "../usage/components/EvalsTab";
|
||||
import SearchAnalyticsTab from "./SearchAnalyticsTab";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const t = useTranslations("analytics");
|
||||
|
||||
const tabDescriptions = {
|
||||
const tabDescriptions: Record<string, string> = {
|
||||
overview: t("overviewDescription"),
|
||||
evals: t("evalsDescription"),
|
||||
search: "Search request analytics — provider breakdown, cache hit rate, and cost tracking.",
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -29,6 +31,7 @@ export default function AnalyticsPage() {
|
||||
options={[
|
||||
{ value: "overview", label: t("overview") },
|
||||
{ value: "evals", label: t("evals") },
|
||||
{ value: "search", label: "Search" },
|
||||
]}
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
@@ -40,6 +43,7 @@ export default function AnalyticsPage() {
|
||||
</Suspense>
|
||||
)}
|
||||
{activeTab === "evals" && <EvalsTab />}
|
||||
{activeTab === "search" && <SearchAnalyticsTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -355,25 +355,33 @@ export default function AntigravityToolCard({
|
||||
)}
|
||||
|
||||
{/* When stopped: how it works */}
|
||||
{!isRunning && (
|
||||
<div className="flex flex-col gap-1.5 px-1">
|
||||
<p className="text-xs text-text-muted">
|
||||
<span className="font-medium text-text-main">{t("howItWorks")}</span>{" "}
|
||||
{t("antigravityHowWorksDesc")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-0.5 text-[11px] text-text-muted">
|
||||
<span>{t("antigravityStep1")}</span>
|
||||
<span>
|
||||
{t("antigravityStep2Prefix")}{" "}
|
||||
<code className="text-[10px] bg-surface px-1 rounded">
|
||||
daily-cloudcode-pa.googleapis.com
|
||||
</code>{" "}
|
||||
{t("antigravityStep2Suffix")}
|
||||
</span>
|
||||
<span>{t("antigravityStep3")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isRunning &&
|
||||
(() => {
|
||||
// Dynamic MITM instructions per tool (#505)
|
||||
const mitmDomains: Record<string, string> = {
|
||||
antigravity: "daily-cloudcode-pa.googleapis.com",
|
||||
kiro: "api.anthropic.com",
|
||||
};
|
||||
const toolName = tool.name || tool.id;
|
||||
const domain = mitmDomains[tool.id] || mitmDomains.antigravity;
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 px-1">
|
||||
<p className="text-xs text-text-muted">
|
||||
<span className="font-medium text-text-main">{t("howItWorks")}</span>{" "}
|
||||
{t("mitmHowWorksDesc", { toolName })}
|
||||
</p>
|
||||
<div className="flex flex-col gap-0.5 text-[11px] text-text-muted">
|
||||
<span>{t("mitmStep1")}</span>
|
||||
<span>
|
||||
{t("mitmStep2Prefix")}{" "}
|
||||
<code className="text-[10px] bg-surface px-1 rounded">{domain}</code>{" "}
|
||||
{t("mitmStep2Suffix")}
|
||||
</span>
|
||||
<span>{t("mitmStep3", { toolName })}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,13 @@ import {
|
||||
updateCustomModel,
|
||||
getModelCompatOverrides,
|
||||
mergeModelCompatOverride,
|
||||
type ModelCompatPatch,
|
||||
} from "@/lib/localDb";
|
||||
import {
|
||||
AI_PROVIDERS,
|
||||
isOpenAICompatibleProvider,
|
||||
isAnthropicCompatibleProvider,
|
||||
} from "@/shared/constants/providers";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { providerModelMutationSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
@@ -32,9 +38,9 @@ export async function GET(request) {
|
||||
const modelCompatOverrides = provider ? getModelCompatOverrides(provider) : [];
|
||||
|
||||
return Response.json({ models, modelCompatOverrides });
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: { message: error.message, type: "server_error" } },
|
||||
{ error: { message: "Failed to fetch provider models", type: "server_error" } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
@@ -124,6 +130,7 @@ export async function PUT(request) {
|
||||
supportedEndpoints,
|
||||
normalizeToolCallId,
|
||||
preserveOpenAIDeveloperRole,
|
||||
compatByProtocol,
|
||||
} = validation.data;
|
||||
|
||||
const raw = rawBody as Record<string, unknown>;
|
||||
@@ -134,6 +141,9 @@ export async function PUT(request) {
|
||||
if ("normalizeToolCallId" in raw) updates.normalizeToolCallId = normalizeToolCallId;
|
||||
if ("preserveOpenAIDeveloperRole" in raw)
|
||||
updates.preserveOpenAIDeveloperRole = preserveOpenAIDeveloperRole;
|
||||
if ("compatByProtocol" in raw && compatByProtocol !== undefined) {
|
||||
updates.compatByProtocol = compatByProtocol;
|
||||
}
|
||||
|
||||
const model = await updateCustomModel(provider, modelId, updates);
|
||||
|
||||
@@ -142,24 +152,48 @@ export async function PUT(request) {
|
||||
const compatOnly =
|
||||
rawKeys.length > 0 &&
|
||||
rawKeys.every((k) =>
|
||||
["provider", "modelId", "normalizeToolCallId", "preserveOpenAIDeveloperRole"].includes(k)
|
||||
[
|
||||
"provider",
|
||||
"modelId",
|
||||
"normalizeToolCallId",
|
||||
"preserveOpenAIDeveloperRole",
|
||||
"compatByProtocol",
|
||||
].includes(k)
|
||||
) &&
|
||||
("normalizeToolCallId" in raw || "preserveOpenAIDeveloperRole" in raw);
|
||||
("normalizeToolCallId" in raw ||
|
||||
"preserveOpenAIDeveloperRole" in raw ||
|
||||
"compatByProtocol" in raw);
|
||||
if (compatOnly) {
|
||||
const patch: {
|
||||
normalizeToolCallId?: boolean;
|
||||
preserveOpenAIDeveloperRole?: boolean;
|
||||
} = {};
|
||||
const knownProvider =
|
||||
!!provider &&
|
||||
(Object.prototype.hasOwnProperty.call(
|
||||
AI_PROVIDERS as Record<string, unknown>,
|
||||
provider
|
||||
) ||
|
||||
isOpenAICompatibleProvider(provider) ||
|
||||
isAnthropicCompatibleProvider(provider));
|
||||
if (!knownProvider) {
|
||||
return Response.json(
|
||||
{ error: { message: "Unknown provider", type: "validation_error" } },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const patch: ModelCompatPatch = {};
|
||||
if ("normalizeToolCallId" in raw && typeof normalizeToolCallId === "boolean") {
|
||||
patch.normalizeToolCallId = normalizeToolCallId;
|
||||
}
|
||||
if (
|
||||
"preserveOpenAIDeveloperRole" in raw &&
|
||||
typeof preserveOpenAIDeveloperRole === "boolean"
|
||||
) {
|
||||
patch.preserveOpenAIDeveloperRole = preserveOpenAIDeveloperRole;
|
||||
if ("preserveOpenAIDeveloperRole" in raw) {
|
||||
patch.preserveOpenAIDeveloperRole =
|
||||
preserveOpenAIDeveloperRole === null || typeof preserveOpenAIDeveloperRole === "boolean"
|
||||
? preserveOpenAIDeveloperRole
|
||||
: undefined;
|
||||
}
|
||||
if ("compatByProtocol" in raw && compatByProtocol && typeof compatByProtocol === "object") {
|
||||
patch.compatByProtocol = compatByProtocol;
|
||||
}
|
||||
if (Object.keys(patch).length > 0) {
|
||||
mergeModelCompatOverride(provider, modelId, patch);
|
||||
}
|
||||
mergeModelCompatOverride(provider, modelId, patch);
|
||||
return Response.json({
|
||||
ok: true,
|
||||
modelCompatOverrides: getModelCompatOverrides(provider),
|
||||
|
||||
@@ -90,13 +90,23 @@ export async function POST(request) {
|
||||
});
|
||||
}
|
||||
|
||||
// Test each connection sequentially via direct function call (no HTTP self-call)
|
||||
const results = [];
|
||||
// Test each connection with timeout and concurrency limits (prevents server crash on large groups)
|
||||
const PER_CONNECTION_TIMEOUT = 30_000; // 30s per connection
|
||||
const CONCURRENCY = 5; // max parallel tests
|
||||
|
||||
for (const conn of connectionsToTest) {
|
||||
const testOne = async (conn) => {
|
||||
try {
|
||||
const data = await testSingleConnection(conn.id);
|
||||
results.push({
|
||||
const result = await Promise.race([
|
||||
testSingleConnection(conn.id),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error("Connection test timed out after 30s")),
|
||||
PER_CONNECTION_TIMEOUT
|
||||
)
|
||||
),
|
||||
]);
|
||||
const data = result as any;
|
||||
return {
|
||||
provider: conn.provider,
|
||||
connectionId: conn.id,
|
||||
connectionName: conn.name || conn.email || conn.provider,
|
||||
@@ -107,9 +117,9 @@ export async function POST(request) {
|
||||
diagnosis: data.diagnosis || null,
|
||||
statusCode: data.statusCode || null,
|
||||
testedAt: data.testedAt || new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
} catch (error) {
|
||||
results.push({
|
||||
return {
|
||||
provider: conn.provider,
|
||||
connectionId: conn.id,
|
||||
connectionName: conn.name || conn.email || conn.provider,
|
||||
@@ -120,7 +130,37 @@ export async function POST(request) {
|
||||
diagnosis: { type: "network_error", source: "local", code: null, message: error.message },
|
||||
statusCode: null,
|
||||
testedAt: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Execute with concurrency limit
|
||||
const results = [];
|
||||
for (let i = 0; i < connectionsToTest.length; i += CONCURRENCY) {
|
||||
const batch = connectionsToTest.slice(i, i + CONCURRENCY);
|
||||
const batchResults = await Promise.allSettled(batch.map(testOne));
|
||||
for (const r of batchResults) {
|
||||
results.push(
|
||||
r.status === "fulfilled"
|
||||
? r.value
|
||||
: {
|
||||
provider: "unknown",
|
||||
connectionId: "unknown",
|
||||
connectionName: "unknown",
|
||||
authType: "unknown",
|
||||
valid: false,
|
||||
latencyMs: 0,
|
||||
error: r.reason?.message || "Test failed",
|
||||
diagnosis: {
|
||||
type: "network_error",
|
||||
source: "local",
|
||||
code: null,
|
||||
message: r.reason?.message || "Test failed",
|
||||
},
|
||||
statusCode: null,
|
||||
testedAt: new Date().toISOString(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* GET /api/v1/search/analytics
|
||||
*
|
||||
* Returns search request statistics from call_logs (request_type = 'search').
|
||||
* Includes provider breakdown, cache hit rate, cost summary, and error count.
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { getDbInstance } from "@/lib/db/core";
|
||||
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const policy = await enforceApiKeyPolicy(req, "analytics");
|
||||
if (policy.rejection) return policy.rejection;
|
||||
|
||||
try {
|
||||
const db = getDbInstance();
|
||||
|
||||
// Total search requests
|
||||
const totalRow = db
|
||||
.prepare(`SELECT COUNT(*) as cnt FROM call_logs WHERE request_type = 'search'`)
|
||||
.get() as { cnt: number };
|
||||
const total = totalRow?.cnt ?? 0;
|
||||
|
||||
// Today's searches (UTC date)
|
||||
const todayStart = new Date();
|
||||
todayStart.setUTCHours(0, 0, 0, 0);
|
||||
const todayRow = db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as cnt FROM call_logs WHERE request_type = 'search' AND timestamp >= ?`
|
||||
)
|
||||
.get(todayStart.toISOString()) as { cnt: number };
|
||||
const today = todayRow?.cnt ?? 0;
|
||||
|
||||
// Errors
|
||||
const errRow = db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as cnt FROM call_logs WHERE request_type = 'search' AND (status >= 400 OR error IS NOT NULL)`
|
||||
)
|
||||
.get() as { cnt: number };
|
||||
const errors = errRow?.cnt ?? 0;
|
||||
|
||||
// Avg duration
|
||||
const durRow = db
|
||||
.prepare(
|
||||
`SELECT AVG(duration) as avg FROM call_logs WHERE request_type = 'search' AND duration > 0`
|
||||
)
|
||||
.get() as { avg: number | null };
|
||||
const avgDurationMs = Math.round(durRow?.avg ?? 0);
|
||||
|
||||
// Per-provider breakdown (provider column stores search provider id)
|
||||
const provRows = db
|
||||
.prepare(
|
||||
`SELECT provider, COUNT(*) as cnt
|
||||
FROM call_logs WHERE request_type = 'search'
|
||||
GROUP BY provider ORDER BY cnt DESC`
|
||||
)
|
||||
.all() as Array<{ provider: string; cnt: number }>;
|
||||
|
||||
// Cost per search provider (matching searchRegistry.ts rates)
|
||||
const COST_PER_QUERY: Record<string, number> = {
|
||||
"serper-search": 0.001,
|
||||
"brave-search": 0.003,
|
||||
"perplexity-search": 0.005,
|
||||
"exa-search": 0.01,
|
||||
"tavily-search": 0.004,
|
||||
};
|
||||
|
||||
const byProvider: Record<string, { count: number; costUsd: number }> = {};
|
||||
let totalCostUsd = 0;
|
||||
for (const row of provRows) {
|
||||
const cost = (COST_PER_QUERY[row.provider] ?? 0.001) * row.cnt;
|
||||
byProvider[row.provider] = { count: row.cnt, costUsd: cost };
|
||||
totalCostUsd += cost;
|
||||
}
|
||||
|
||||
// Cached: very fast responses (< 5ms) indicate cache hits
|
||||
const cachedRow = db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as cnt FROM call_logs
|
||||
WHERE request_type = 'search' AND duration > 0 AND duration < 5`
|
||||
)
|
||||
.get() as { cnt: number };
|
||||
const cached = cachedRow?.cnt ?? 0;
|
||||
const cacheHitRate = total > 0 ? Math.round((cached / total) * 100) : 0;
|
||||
|
||||
return NextResponse.json({
|
||||
total,
|
||||
today,
|
||||
cached,
|
||||
errors,
|
||||
totalCostUsd,
|
||||
byProvider,
|
||||
cacheHitRate,
|
||||
avgDurationMs,
|
||||
last24h: [],
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error("[/api/v1/search/analytics]", msg);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -437,6 +437,11 @@
|
||||
"antigravityStep2Prefix": "2. Add",
|
||||
"antigravityStep2Suffix": "to your hosts file as 127.0.0.1.",
|
||||
"antigravityStep3": "3. Open Antigravity and requests will be proxied.",
|
||||
"mitmHowWorksDesc": "{toolName} sends requests to its provider endpoint. MITM intercepts and redirects them to OmniRoute.",
|
||||
"mitmStep1": "1. Start MITM to route requests through OmniRoute.",
|
||||
"mitmStep2Prefix": "2. Add",
|
||||
"mitmStep2Suffix": "to your hosts file as 127.0.0.1.",
|
||||
"mitmStep3": "3. Open {toolName} and requests will be proxied.",
|
||||
"sudoPasswordRequiredTitle": "Sudo Password Required",
|
||||
"sudoPasswordHint": "Administrator password is required to modify hosts file and system proxy settings.",
|
||||
"enterSudoPassword": "Enter sudo password",
|
||||
@@ -1432,6 +1437,11 @@
|
||||
"compatDeveloperShort": "Developer role",
|
||||
"compatDoNotPreserveDeveloper": "Do not preserve developer role",
|
||||
"compatBadgeNoPreserve": "No preserve",
|
||||
"compatProtocolLabel": "Client request protocol",
|
||||
"compatProtocolHint": "These options apply when OmniRoute detects this request shape (OpenAI Chat, Responses API, or Anthropic Messages).",
|
||||
"compatProtocolOpenAI": "OpenAI Chat Completions",
|
||||
"compatProtocolOpenAIResponses": "OpenAI Responses API",
|
||||
"compatProtocolClaude": "Anthropic Messages",
|
||||
"modelId": "Model ID",
|
||||
"customModelPlaceholder": "e.g. gpt-4.5-turbo",
|
||||
"loading": "Loading...",
|
||||
|
||||
@@ -1432,6 +1432,11 @@
|
||||
"compatDeveloperShort": "Developer 角色",
|
||||
"compatDoNotPreserveDeveloper": "不保留 developer 角色",
|
||||
"compatBadgeNoPreserve": "不保留",
|
||||
"compatProtocolLabel": "客户端请求协议",
|
||||
"compatProtocolHint": "以下选项在 OmniRoute 识别到该请求形态(OpenAI Chat、Responses API 或 Anthropic Messages)时生效。",
|
||||
"compatProtocolOpenAI": "OpenAI Chat Completions",
|
||||
"compatProtocolOpenAIResponses": "OpenAI Responses API",
|
||||
"compatProtocolClaude": "Anthropic Messages",
|
||||
"modelId": "模型 ID",
|
||||
"customModelPlaceholder": "例如:gpt-4.5-turbo",
|
||||
"loading": "正在加载...",
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Node.js-only instrumentation logic.
|
||||
*
|
||||
* Separated from instrumentation.ts so that Turbopack's Edge bundler
|
||||
* does not trace into Node.js-only modules (fs, path, os, better-sqlite3, etc.)
|
||||
* and emit spurious "not supported in Edge Runtime" warnings.
|
||||
*/
|
||||
|
||||
function getRandomBytes(byteLength: number): Uint8Array {
|
||||
const bytes = new Uint8Array(byteLength);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function toBase64(bytes: Uint8Array): string {
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
}
|
||||
|
||||
function toHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function ensureSecrets(): Promise<void> {
|
||||
let getPersistedSecret = (_key: string): string | null => null;
|
||||
let persistSecret = (_key: string, _value: string): void => {};
|
||||
|
||||
try {
|
||||
({ getPersistedSecret, persistSecret } = await import("@/lib/db/secrets"));
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn(
|
||||
"[STARTUP] Secret persistence unavailable; falling back to process-local secrets:",
|
||||
msg
|
||||
);
|
||||
}
|
||||
|
||||
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.trim() === "") {
|
||||
const persisted = getPersistedSecret("jwtSecret");
|
||||
if (persisted) {
|
||||
process.env.JWT_SECRET = persisted;
|
||||
console.log("[STARTUP] JWT_SECRET restored from persistent store");
|
||||
} else {
|
||||
const generated = toBase64(getRandomBytes(48));
|
||||
process.env.JWT_SECRET = generated;
|
||||
persistSecret("jwtSecret", generated);
|
||||
console.log("[STARTUP] JWT_SECRET auto-generated and persisted (random 64-char secret)");
|
||||
}
|
||||
}
|
||||
|
||||
if (!process.env.API_KEY_SECRET || process.env.API_KEY_SECRET.trim() === "") {
|
||||
const persisted = getPersistedSecret("apiKeySecret");
|
||||
if (persisted) {
|
||||
process.env.API_KEY_SECRET = persisted;
|
||||
} else {
|
||||
const generated = toHex(getRandomBytes(32));
|
||||
process.env.API_KEY_SECRET = generated;
|
||||
persistSecret("apiKeySecret", generated);
|
||||
console.log(
|
||||
"[STARTUP] API_KEY_SECRET auto-generated and persisted (random 64-char hex secret)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerNodejs(): Promise<void> {
|
||||
await ensureSecrets();
|
||||
|
||||
const { initConsoleInterceptor } = await import("@/lib/consoleInterceptor");
|
||||
initConsoleInterceptor();
|
||||
|
||||
const [
|
||||
{ initGracefulShutdown },
|
||||
{ initApiBridgeServer },
|
||||
{ startBackgroundRefresh },
|
||||
{ getSettings },
|
||||
] = await Promise.all([
|
||||
import("@/lib/gracefulShutdown"),
|
||||
import("@/lib/apiBridgeServer"),
|
||||
import("@/domain/quotaCache"),
|
||||
import("@/lib/db/settings"),
|
||||
]);
|
||||
|
||||
initGracefulShutdown();
|
||||
initApiBridgeServer();
|
||||
startBackgroundRefresh();
|
||||
console.log("[STARTUP] Quota cache background refresh started");
|
||||
|
||||
try {
|
||||
const [{ setCustomAliases }, { setDefaultFastServiceTierEnabled }] = await Promise.all([
|
||||
import("@omniroute/open-sse/services/modelDeprecation.ts"),
|
||||
import("@omniroute/open-sse/executors/codex.ts"),
|
||||
]);
|
||||
const settings = await getSettings();
|
||||
|
||||
if (settings.modelAliases) {
|
||||
const aliases =
|
||||
typeof settings.modelAliases === "string"
|
||||
? JSON.parse(settings.modelAliases)
|
||||
: settings.modelAliases;
|
||||
if (aliases && typeof aliases === "object") {
|
||||
setCustomAliases(aliases);
|
||||
console.log(
|
||||
`[STARTUP] Restored ${Object.keys(aliases).length} custom model alias(es) from settings`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const persisted =
|
||||
typeof settings.codexServiceTier === "string"
|
||||
? JSON.parse(settings.codexServiceTier)
|
||||
: settings.codexServiceTier;
|
||||
|
||||
if (typeof persisted?.enabled === "boolean") {
|
||||
setDefaultFastServiceTierEnabled(persisted.enabled);
|
||||
console.log(
|
||||
`[STARTUP] Restored Codex fast service tier: ${persisted.enabled ? "on" : "off"}`
|
||||
);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn("[STARTUP] Could not restore runtime settings:", msg);
|
||||
}
|
||||
|
||||
try {
|
||||
const { initAuditLog, cleanupExpiredLogs } = await import("@/lib/compliance/index");
|
||||
initAuditLog();
|
||||
console.log("[COMPLIANCE] Audit log table initialized");
|
||||
|
||||
const cleanup = cleanupExpiredLogs();
|
||||
if (cleanup.deletedUsage || cleanup.deletedCallLogs || cleanup.deletedAuditLogs) {
|
||||
console.log("[COMPLIANCE] Expired log cleanup:", cleanup);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn("[COMPLIANCE] Could not initialize audit log:", msg);
|
||||
}
|
||||
}
|
||||
+8
-130
@@ -2,141 +2,19 @@
|
||||
* Next.js Instrumentation Hook
|
||||
*
|
||||
* Called once when the server starts (both dev and production).
|
||||
* Used to initialize graceful shutdown handlers, console log capture,
|
||||
* and compliance features (audit log table, expired log cleanup).
|
||||
* All Node.js-specific logic lives in ./instrumentation-node.ts to prevent
|
||||
* Turbopack's Edge bundler from tracing into native modules (fs, path, os, etc.)
|
||||
*
|
||||
* @see https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
|
||||
*/
|
||||
|
||||
function getRandomBytes(byteLength: number): Uint8Array {
|
||||
const bytes = new Uint8Array(byteLength);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function toBase64(bytes: Uint8Array): string {
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
}
|
||||
|
||||
function toHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function ensureSecrets(): Promise<void> {
|
||||
let getPersistedSecret = (_key: string): string | null => null;
|
||||
let persistSecret = (_key: string, _value: string): void => {};
|
||||
|
||||
try {
|
||||
({ getPersistedSecret, persistSecret } = await import("@/lib/db/secrets"));
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn(
|
||||
"[STARTUP] Secret persistence unavailable; falling back to process-local secrets:",
|
||||
msg
|
||||
);
|
||||
}
|
||||
|
||||
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.trim() === "") {
|
||||
const persisted = getPersistedSecret("jwtSecret");
|
||||
if (persisted) {
|
||||
process.env.JWT_SECRET = persisted;
|
||||
console.log("[STARTUP] JWT_SECRET restored from persistent store");
|
||||
} else {
|
||||
const generated = toBase64(getRandomBytes(48));
|
||||
process.env.JWT_SECRET = generated;
|
||||
persistSecret("jwtSecret", generated);
|
||||
console.log("[STARTUP] JWT_SECRET auto-generated and persisted (random 64-char secret)");
|
||||
}
|
||||
}
|
||||
|
||||
if (!process.env.API_KEY_SECRET || process.env.API_KEY_SECRET.trim() === "") {
|
||||
const persisted = getPersistedSecret("apiKeySecret");
|
||||
if (persisted) {
|
||||
process.env.API_KEY_SECRET = persisted;
|
||||
} else {
|
||||
const generated = toHex(getRandomBytes(32));
|
||||
process.env.API_KEY_SECRET = generated;
|
||||
persistSecret("apiKeySecret", generated);
|
||||
console.log(
|
||||
"[STARTUP] API_KEY_SECRET auto-generated and persisted (random 64-char hex secret)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function register() {
|
||||
// Only run on the server (not during build or in Edge runtime)
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
await ensureSecrets();
|
||||
// Console log file capture (must be first — before any logging occurs)
|
||||
const { initConsoleInterceptor } = await import("@/lib/consoleInterceptor");
|
||||
initConsoleInterceptor();
|
||||
|
||||
const { initGracefulShutdown } = await import("@/lib/gracefulShutdown");
|
||||
initGracefulShutdown();
|
||||
|
||||
const { initApiBridgeServer } = await import("@/lib/apiBridgeServer");
|
||||
initApiBridgeServer();
|
||||
|
||||
// Quota cache: start background refresh for quota-aware account selection
|
||||
// Dynamic import required — quotaCache depends on better-sqlite3 (Node-only),
|
||||
// and instrumentation.ts is bundled for all runtimes including Edge.
|
||||
const { startBackgroundRefresh } = await import("@/domain/quotaCache");
|
||||
startBackgroundRefresh();
|
||||
console.log("[STARTUP] Quota cache background refresh started");
|
||||
|
||||
// Model aliases: restore persisted custom aliases into in-memory state (#316)
|
||||
// Custom aliases are saved to settings.modelAliases on PUT /api/settings/model-aliases
|
||||
// but the in-memory _customAliases resets to {} on every restart — load them here.
|
||||
try {
|
||||
const { getSettings } = await import("@/lib/db/settings");
|
||||
const { setCustomAliases } = await import("@omniroute/open-sse/services/modelDeprecation.ts");
|
||||
const { setDefaultFastServiceTierEnabled } =
|
||||
await import("@omniroute/open-sse/executors/codex.ts");
|
||||
const settings = await getSettings();
|
||||
|
||||
if (settings.modelAliases) {
|
||||
const aliases =
|
||||
typeof settings.modelAliases === "string"
|
||||
? JSON.parse(settings.modelAliases)
|
||||
: settings.modelAliases;
|
||||
if (aliases && typeof aliases === "object") {
|
||||
setCustomAliases(aliases);
|
||||
console.log(
|
||||
`[STARTUP] Restored ${Object.keys(aliases).length} custom model alias(es) from settings`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const persisted =
|
||||
typeof settings.codexServiceTier === "string"
|
||||
? JSON.parse(settings.codexServiceTier)
|
||||
: settings.codexServiceTier;
|
||||
|
||||
if (typeof persisted?.enabled === "boolean") {
|
||||
setDefaultFastServiceTierEnabled(persisted.enabled);
|
||||
console.log(
|
||||
`[STARTUP] Restored Codex fast service tier: ${persisted.enabled ? "on" : "off"}`
|
||||
);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn("[STARTUP] Could not restore runtime settings:", msg);
|
||||
}
|
||||
|
||||
// Compliance: Initialize audit_log table + cleanup expired logs
|
||||
try {
|
||||
const { initAuditLog, cleanupExpiredLogs } = await import("@/lib/compliance/index");
|
||||
initAuditLog();
|
||||
console.log("[COMPLIANCE] Audit log table initialized");
|
||||
|
||||
const cleanup = cleanupExpiredLogs();
|
||||
if (cleanup.deletedUsage || cleanup.deletedCallLogs || cleanup.deletedAuditLogs) {
|
||||
console.log("[COMPLIANCE] Expired log cleanup:", cleanup);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn("[COMPLIANCE] Could not initialize audit log:", msg);
|
||||
}
|
||||
// Computed path prevents Turbopack from statically resolving the import
|
||||
// for the Edge instrumentation bundle, avoiding spurious warnings about
|
||||
// Node.js modules not being available in the Edge Runtime.
|
||||
const nodeMod = "./instrumentation-" + "node";
|
||||
const { registerNodejs } = await import(nodeMod);
|
||||
await registerNodejs();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export type ApiErrorType = "invalid_request" | "not_found" | "conflict" | "server_error";
|
||||
|
||||
|
||||
@@ -16,7 +16,9 @@ import { dirname, resolve } from "path";
|
||||
const logToFile = process.env.LOG_TO_FILE !== "false";
|
||||
const logFilePath = resolve(process.env.LOG_FILE_PATH || "logs/application/app.log");
|
||||
|
||||
let initialized = false;
|
||||
declare global {
|
||||
var __omnirouteConsoleInterceptorInit: boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map console method names to log levels.
|
||||
@@ -92,7 +94,7 @@ function writeEntry(level: string, args: unknown[]) {
|
||||
* Safe to call multiple times — only initializes once.
|
||||
*/
|
||||
export function initConsoleInterceptor(): void {
|
||||
if (!logToFile || initialized) return;
|
||||
if (!logToFile || globalThis.__omnirouteConsoleInterceptorInit) return;
|
||||
|
||||
try {
|
||||
ensureDir();
|
||||
@@ -101,7 +103,7 @@ export function initConsoleInterceptor(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
globalThis.__omnirouteConsoleInterceptorInit = true;
|
||||
|
||||
// Save original methods
|
||||
const originalMethods = {
|
||||
|
||||
+35
-2
@@ -38,6 +38,8 @@ interface ApiKeyMetadata {
|
||||
autoResolve: boolean;
|
||||
isActive: boolean;
|
||||
accessSchedule: AccessSchedule | null;
|
||||
maxRequestsPerDay: number | null;
|
||||
maxRequestsPerMinute: number | null;
|
||||
}
|
||||
|
||||
interface ApiKeyRow extends JsonRecord {
|
||||
@@ -187,6 +189,14 @@ function ensureApiKeysColumns(db: ApiKeysDbLike) {
|
||||
db.exec("ALTER TABLE api_keys ADD COLUMN access_schedule TEXT");
|
||||
console.log("[DB] Added api_keys.access_schedule column");
|
||||
}
|
||||
if (!columnNames.has("max_requests_per_day")) {
|
||||
db.exec("ALTER TABLE api_keys ADD COLUMN max_requests_per_day INTEGER");
|
||||
console.log("[DB] Added api_keys.max_requests_per_day column");
|
||||
}
|
||||
if (!columnNames.has("max_requests_per_minute")) {
|
||||
db.exec("ALTER TABLE api_keys ADD COLUMN max_requests_per_minute INTEGER");
|
||||
console.log("[DB] Added api_keys.max_requests_per_minute column");
|
||||
}
|
||||
_schemaChecked = true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -212,7 +222,7 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements {
|
||||
_stmtGetKeyById = db.prepare<ApiKeyRow>("SELECT * FROM api_keys WHERE id = ?");
|
||||
_stmtValidateKey = db.prepare<JsonRecord>("SELECT 1 FROM api_keys WHERE key = ?");
|
||||
_stmtGetKeyMetadata = db.prepare<ApiKeyRow>(
|
||||
"SELECT id, name, machine_id, allowed_models, allowed_connections, no_log, auto_resolve, is_active, access_schedule FROM api_keys WHERE key = ?"
|
||||
"SELECT id, name, machine_id, allowed_models, allowed_connections, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute FROM api_keys WHERE key = ?"
|
||||
);
|
||||
_stmtInsertKey = db.prepare(
|
||||
"INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
@@ -406,6 +416,8 @@ export async function updateApiKeyPermissions(
|
||||
autoResolve?: boolean;
|
||||
isActive?: boolean;
|
||||
accessSchedule?: AccessSchedule | null;
|
||||
maxRequestsPerDay?: number | null;
|
||||
maxRequestsPerMinute?: number | null;
|
||||
}
|
||||
) {
|
||||
const db = getDbInstance() as ApiKeysDbLike;
|
||||
@@ -422,6 +434,8 @@ export async function updateApiKeyPermissions(
|
||||
autoResolve: update.autoResolve,
|
||||
isActive: update.isActive,
|
||||
accessSchedule: update.accessSchedule,
|
||||
maxRequestsPerDay: update.maxRequestsPerDay,
|
||||
maxRequestsPerMinute: update.maxRequestsPerMinute,
|
||||
};
|
||||
|
||||
if (
|
||||
@@ -431,7 +445,9 @@ export async function updateApiKeyPermissions(
|
||||
normalized.noLog === undefined &&
|
||||
normalized.autoResolve === undefined &&
|
||||
normalized.isActive === undefined &&
|
||||
normalized.accessSchedule === undefined
|
||||
normalized.accessSchedule === undefined &&
|
||||
normalized.maxRequestsPerDay === undefined &&
|
||||
normalized.maxRequestsPerMinute === undefined
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -446,6 +462,8 @@ export async function updateApiKeyPermissions(
|
||||
autoResolve?: number;
|
||||
isActive?: number;
|
||||
accessSchedule?: string | null;
|
||||
maxRequestsPerDay?: number | null;
|
||||
maxRequestsPerMinute?: number | null;
|
||||
} = { id };
|
||||
|
||||
if (normalized.name !== undefined) {
|
||||
@@ -486,6 +504,16 @@ export async function updateApiKeyPermissions(
|
||||
normalized.accessSchedule !== null ? JSON.stringify(normalized.accessSchedule) : null;
|
||||
}
|
||||
|
||||
if (normalized.maxRequestsPerDay !== undefined) {
|
||||
updates.push("max_requests_per_day = @maxRequestsPerDay");
|
||||
params.maxRequestsPerDay = normalized.maxRequestsPerDay;
|
||||
}
|
||||
|
||||
if (normalized.maxRequestsPerMinute !== undefined) {
|
||||
updates.push("max_requests_per_minute = @maxRequestsPerMinute");
|
||||
params.maxRequestsPerMinute = normalized.maxRequestsPerMinute;
|
||||
}
|
||||
|
||||
const result = db.prepare(`UPDATE api_keys SET ${updates.join(", ")} WHERE id = @id`).run(params);
|
||||
|
||||
if (result.changes === 0) return false;
|
||||
@@ -574,6 +602,9 @@ export async function getApiKeyMetadata(
|
||||
const machineIdRaw = record.machine_id ?? record.machineId;
|
||||
const metadataMachineId = typeof machineIdRaw === "string" ? machineIdRaw : null;
|
||||
|
||||
const rawMaxRPD = record.max_requests_per_day ?? record.maxRequestsPerDay;
|
||||
const rawMaxRPM = record.max_requests_per_minute ?? record.maxRequestsPerMinute;
|
||||
|
||||
const metadata: ApiKeyMetadata = {
|
||||
id: metadataId,
|
||||
name: metadataName,
|
||||
@@ -586,6 +617,8 @@ export async function getApiKeyMetadata(
|
||||
autoResolve: parseAutoResolve(record.auto_resolve ?? record.autoResolve),
|
||||
isActive: parseIsActive(record.is_active ?? record.isActive),
|
||||
accessSchedule: parseAccessSchedule(record.access_schedule ?? record.accessSchedule),
|
||||
maxRequestsPerDay: typeof rawMaxRPD === "number" && rawMaxRPD > 0 ? rawMaxRPD : null,
|
||||
maxRequestsPerMinute: typeof rawMaxRPM === "number" && rawMaxRPM > 0 ? rawMaxRPM : null,
|
||||
};
|
||||
|
||||
if (!metadata.id) {
|
||||
|
||||
+49
-5
@@ -24,6 +24,35 @@ let _lastBackupAt = 0;
|
||||
const BACKUP_THROTTLE_MS = 60 * 60 * 1000; // 60 minutes
|
||||
const MAX_DB_BACKUPS = 20;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function unlinkFileWithRetry(
|
||||
filePath: string,
|
||||
options?: { maxAttempts?: number; retryableCodes?: string[]; baseDelayMs?: number }
|
||||
) {
|
||||
const maxAttempts = Math.max(1, options?.maxAttempts ?? 10);
|
||||
const retryableCodes = new Set(options?.retryableCodes ?? ["EBUSY", "EPERM"]);
|
||||
const baseDelayMs = Math.max(0, options?.baseDelayMs ?? 100);
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
return;
|
||||
} catch (err: unknown) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err ? (err as NodeJS.ErrnoException).code : "";
|
||||
if (code === "ENOENT") return;
|
||||
if (retryableCodes.has(String(code)) && attempt < maxAttempts - 1) {
|
||||
await sleep(baseDelayMs * (attempt + 1));
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────── Backup ────────────────
|
||||
|
||||
export function backupDbFile(reason = "auto") {
|
||||
@@ -197,9 +226,22 @@ export async function restoreDbBackup(backupId: string) {
|
||||
throw new Error(`Backup file is corrupt: ${message}`);
|
||||
}
|
||||
|
||||
// Force pre-restore backup (bypass throttle)
|
||||
// Force pre-restore backup (bypass throttle) and await so the DB is not closed while backup runs
|
||||
_lastBackupAt = 0;
|
||||
backupDbFile("pre-restore");
|
||||
const backupDirForPre = DB_BACKUPS_DIR || path.join(DATA_DIR, "db_backups");
|
||||
if (SQLITE_FILE && fs.existsSync(SQLITE_FILE)) {
|
||||
const stat = fs.statSync(SQLITE_FILE);
|
||||
if (stat.size >= 4096) {
|
||||
if (!fs.existsSync(backupDirForPre)) fs.mkdirSync(backupDirForPre, { recursive: true });
|
||||
const preBackupPath = path.join(
|
||||
backupDirForPre,
|
||||
`db_${new Date().toISOString().replace(/[:.]/g, "-")}_pre-restore.sqlite`
|
||||
);
|
||||
const dbForBackup = getDbInstance();
|
||||
await dbForBackup.backup(preBackupPath);
|
||||
_lastBackupAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
// Close and reset current connection
|
||||
resetDbInstance();
|
||||
@@ -212,7 +254,11 @@ export async function restoreDbBackup(backupId: string) {
|
||||
throw new Error("SQLITE_FILE is unavailable in local backup restore");
|
||||
}
|
||||
|
||||
// On Windows, the file handle may be released asynchronously after close; give it a moment.
|
||||
await sleep(500);
|
||||
|
||||
// Remove main file and WAL sidecars to avoid stale frame replay after restore.
|
||||
// Retry unlink on EBUSY/EPERM (Windows may hold the handle briefly).
|
||||
const sqliteFilesToReplace = [
|
||||
sqliteFile,
|
||||
`${sqliteFile}-wal`,
|
||||
@@ -221,9 +267,7 @@ export async function restoreDbBackup(backupId: string) {
|
||||
];
|
||||
for (const filePath of sqliteFilesToReplace) {
|
||||
if (!filePath) continue;
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
await unlinkFileWithRetry(filePath);
|
||||
}
|
||||
|
||||
// Copy backup over current DB
|
||||
|
||||
+26
-12
@@ -302,8 +302,24 @@ export function cleanNulls(obj: unknown): JsonRecord {
|
||||
}
|
||||
|
||||
// ──────────────── Singleton DB Instance ────────────────
|
||||
// Use globalThis to survive Next.js dev HMR module re-evaluation.
|
||||
// Module-level `let` resets on every webpack recompile, causing connection leaks.
|
||||
|
||||
let _db: SqliteDatabase | null = null;
|
||||
declare global {
|
||||
var __omnirouteDb: import("better-sqlite3").Database | undefined;
|
||||
}
|
||||
|
||||
function getDb(): SqliteDatabase | null {
|
||||
return globalThis.__omnirouteDb ?? null;
|
||||
}
|
||||
|
||||
function setDb(db: SqliteDatabase | null): void {
|
||||
if (db) {
|
||||
globalThis.__omnirouteDb = db;
|
||||
} else {
|
||||
delete globalThis.__omnirouteDb;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureProviderConnectionsColumns(db: SqliteDatabase) {
|
||||
try {
|
||||
@@ -361,7 +377,8 @@ function ensureUsageHistoryColumns(db: SqliteDatabase) {
|
||||
}
|
||||
|
||||
export function getDbInstance(): SqliteDatabase {
|
||||
if (_db) return _db;
|
||||
const existing = getDb();
|
||||
if (existing) return existing;
|
||||
|
||||
if (isCloud || isBuildPhase) {
|
||||
if (isBuildPhase) {
|
||||
@@ -371,7 +388,7 @@ export function getDbInstance(): SqliteDatabase {
|
||||
memoryDb.pragma("journal_mode = WAL");
|
||||
memoryDb.exec(SCHEMA_SQL);
|
||||
ensureUsageHistoryColumns(memoryDb);
|
||||
_db = memoryDb;
|
||||
setDb(memoryDb);
|
||||
return memoryDb;
|
||||
}
|
||||
|
||||
@@ -382,6 +399,7 @@ export function getDbInstance(): SqliteDatabase {
|
||||
const jsonDbFile = JSON_DB_FILE;
|
||||
|
||||
// Detect and handle old schema format — preserve data when possible (#146)
|
||||
// Uses a single probe connection that becomes the real connection when possible.
|
||||
if (fs.existsSync(sqliteFile)) {
|
||||
try {
|
||||
const probe = new Database(sqliteFile, { readonly: true });
|
||||
@@ -390,7 +408,6 @@ export function getDbInstance(): SqliteDatabase {
|
||||
.get();
|
||||
|
||||
if (hasOldSchema) {
|
||||
// Check if the DB has actual data we should preserve
|
||||
let hasData = false;
|
||||
try {
|
||||
const count = probe.prepare("SELECT COUNT(*) as c FROM provider_connections").get() as
|
||||
@@ -403,15 +420,12 @@ export function getDbInstance(): SqliteDatabase {
|
||||
probe.close();
|
||||
|
||||
if (hasData) {
|
||||
// Data exists — preserve it! Just drop the old migration tracking table
|
||||
// and let our new migration system (CREATE TABLE IF NOT EXISTS) take over
|
||||
console.log(
|
||||
`[DB] Old schema_migrations table found but data exists — preserving data (#146)`
|
||||
);
|
||||
const fixDb = new Database(sqliteFile);
|
||||
try {
|
||||
fixDb.exec("DROP TABLE IF EXISTS schema_migrations");
|
||||
// Clean up WAL/SHM files that might be stale
|
||||
fixDb.pragma("wal_checkpoint(TRUNCATE)");
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
@@ -420,7 +434,6 @@ export function getDbInstance(): SqliteDatabase {
|
||||
fixDb.close();
|
||||
}
|
||||
} else {
|
||||
// No data — safe to rename and start fresh
|
||||
const oldPath = sqliteFile + ".old-schema";
|
||||
console.log(
|
||||
`[DB] Old incompatible schema detected (empty) — renaming to ${path.basename(oldPath)}`
|
||||
@@ -481,7 +494,7 @@ export function getDbInstance(): SqliteDatabase {
|
||||
);
|
||||
versionStmt.run();
|
||||
|
||||
_db = db;
|
||||
setDb(db);
|
||||
console.log(`[DB] SQLite database ready: ${sqliteFile}`);
|
||||
return db;
|
||||
}
|
||||
@@ -490,9 +503,10 @@ export function getDbInstance(): SqliteDatabase {
|
||||
* Reset the singleton (used by restore).
|
||||
*/
|
||||
export function resetDbInstance() {
|
||||
if (_db) {
|
||||
_db.close();
|
||||
_db = null;
|
||||
const db = getDb();
|
||||
if (db) {
|
||||
db.close();
|
||||
setDb(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+147
-10
@@ -4,16 +4,60 @@
|
||||
|
||||
import { getDbInstance } from "./core";
|
||||
import { backupDbFile } from "./backup";
|
||||
import {
|
||||
MODEL_COMPAT_PROTOCOL_KEYS,
|
||||
type ModelCompatProtocolKey,
|
||||
} from "@/shared/constants/modelCompat";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
/** Built-in / alias models: tool-call + developer-role flags without a full custom row */
|
||||
const MODEL_COMPAT_NAMESPACE = "modelCompatOverrides";
|
||||
|
||||
export { MODEL_COMPAT_PROTOCOL_KEYS, type ModelCompatProtocolKey };
|
||||
|
||||
export type ModelCompatPerProtocol = {
|
||||
normalizeToolCallId?: boolean;
|
||||
preserveOpenAIDeveloperRole?: boolean;
|
||||
};
|
||||
|
||||
type CompatByProtocolMap = Partial<Record<ModelCompatProtocolKey, ModelCompatPerProtocol>>;
|
||||
|
||||
function isCompatProtocolKey(p: string): p is ModelCompatProtocolKey {
|
||||
return (MODEL_COMPAT_PROTOCOL_KEYS as readonly string[]).includes(p);
|
||||
}
|
||||
|
||||
function deepMergeCompatByProtocol(
|
||||
prev: CompatByProtocolMap | undefined,
|
||||
patch: Partial<Record<ModelCompatProtocolKey, Partial<ModelCompatPerProtocol>>>
|
||||
): CompatByProtocolMap {
|
||||
const out: CompatByProtocolMap = { ...(prev || {}) };
|
||||
for (const key of Object.keys(patch) as ModelCompatProtocolKey[]) {
|
||||
if (!isCompatProtocolKey(key)) continue;
|
||||
const deltas = patch[key];
|
||||
if (!deltas || typeof deltas !== "object") continue;
|
||||
const hasDelta =
|
||||
Object.prototype.hasOwnProperty.call(deltas, "normalizeToolCallId") ||
|
||||
Object.prototype.hasOwnProperty.call(deltas, "preserveOpenAIDeveloperRole");
|
||||
if (!hasDelta) continue;
|
||||
const cur: ModelCompatPerProtocol = { ...(out[key] || {}) };
|
||||
if ("normalizeToolCallId" in deltas) {
|
||||
cur.normalizeToolCallId = Boolean(deltas.normalizeToolCallId);
|
||||
}
|
||||
if ("preserveOpenAIDeveloperRole" in deltas) {
|
||||
cur.preserveOpenAIDeveloperRole = Boolean(deltas.preserveOpenAIDeveloperRole);
|
||||
}
|
||||
if (Object.keys(cur).length === 0) delete out[key];
|
||||
else out[key] = cur;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type ModelCompatOverride = {
|
||||
id: string;
|
||||
normalizeToolCallId?: boolean;
|
||||
preserveOpenAIDeveloperRole?: boolean;
|
||||
compatByProtocol?: CompatByProtocolMap;
|
||||
};
|
||||
|
||||
function readCompatList(providerId: string): ModelCompatOverride[] {
|
||||
@@ -52,10 +96,24 @@ export function getModelCompatOverrides(providerId: string): ModelCompatOverride
|
||||
return readCompatList(providerId);
|
||||
}
|
||||
|
||||
export type ModelCompatPatch = {
|
||||
normalizeToolCallId?: boolean;
|
||||
preserveOpenAIDeveloperRole?: boolean | null;
|
||||
compatByProtocol?: CompatByProtocolMap;
|
||||
};
|
||||
|
||||
function compatByProtocolHasEntries(map: CompatByProtocolMap | undefined): boolean {
|
||||
if (!map || typeof map !== "object") return false;
|
||||
return Object.keys(map).some((k) => {
|
||||
const v = map[k as ModelCompatProtocolKey];
|
||||
return v && typeof v === "object" && Object.keys(v).length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function mergeModelCompatOverride(
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
patch: Partial<Pick<ModelCompatOverride, "normalizeToolCallId" | "preserveOpenAIDeveloperRole">>
|
||||
patch: ModelCompatPatch
|
||||
) {
|
||||
const list = readCompatList(providerId);
|
||||
const idx = list.findIndex((e) => e.id === modelId);
|
||||
@@ -66,11 +124,24 @@ export function mergeModelCompatOverride(
|
||||
else delete next.normalizeToolCallId;
|
||||
}
|
||||
if ("preserveOpenAIDeveloperRole" in patch) {
|
||||
next.preserveOpenAIDeveloperRole = Boolean(patch.preserveOpenAIDeveloperRole);
|
||||
if (patch.preserveOpenAIDeveloperRole === null) {
|
||||
delete next.preserveOpenAIDeveloperRole; // unset: revert to default (undefined at read time)
|
||||
} else {
|
||||
next.preserveOpenAIDeveloperRole = Boolean(patch.preserveOpenAIDeveloperRole);
|
||||
}
|
||||
}
|
||||
if (patch.compatByProtocol && Object.keys(patch.compatByProtocol).length > 0) {
|
||||
const merged = deepMergeCompatByProtocol(next.compatByProtocol, patch.compatByProtocol);
|
||||
if (compatByProtocolHasEntries(merged)) next.compatByProtocol = merged;
|
||||
else delete next.compatByProtocol;
|
||||
}
|
||||
const filtered = list.filter((e) => e.id !== modelId);
|
||||
const hasPreserveFlag = Object.prototype.hasOwnProperty.call(next, "preserveOpenAIDeveloperRole");
|
||||
if (next.normalizeToolCallId || hasPreserveFlag) {
|
||||
if (
|
||||
next.normalizeToolCallId ||
|
||||
hasPreserveFlag ||
|
||||
compatByProtocolHasEntries(next.compatByProtocol)
|
||||
) {
|
||||
filtered.push(next);
|
||||
}
|
||||
writeCompatList(providerId, filtered);
|
||||
@@ -274,7 +345,24 @@ export async function updateCustomModel(
|
||||
if (index === -1) return null;
|
||||
|
||||
const current = models[index];
|
||||
const next = {
|
||||
const currentCompat = (current as JsonRecord).compatByProtocol as CompatByProtocolMap | undefined;
|
||||
let mergedCompat: CompatByProtocolMap | undefined = currentCompat;
|
||||
if (
|
||||
updates.compatByProtocol !== undefined &&
|
||||
typeof updates.compatByProtocol === "object" &&
|
||||
updates.compatByProtocol !== null &&
|
||||
!Array.isArray(updates.compatByProtocol)
|
||||
) {
|
||||
mergedCompat = deepMergeCompatByProtocol(
|
||||
currentCompat,
|
||||
updates.compatByProtocol as Partial<
|
||||
Record<ModelCompatProtocolKey, Partial<ModelCompatPerProtocol>>
|
||||
>
|
||||
);
|
||||
if (!compatByProtocolHasEntries(mergedCompat)) mergedCompat = undefined;
|
||||
}
|
||||
|
||||
const next: JsonRecord = {
|
||||
...current,
|
||||
...(updates.modelName !== undefined ? { name: updates.modelName || current.name } : {}),
|
||||
...(updates.apiFormat !== undefined ? { apiFormat: updates.apiFormat } : {}),
|
||||
@@ -284,10 +372,21 @@ export async function updateCustomModel(
|
||||
...(updates.normalizeToolCallId !== undefined
|
||||
? { normalizeToolCallId: Boolean(updates.normalizeToolCallId) }
|
||||
: {}),
|
||||
...(updates.preserveOpenAIDeveloperRole !== undefined
|
||||
? { preserveOpenAIDeveloperRole: Boolean(updates.preserveOpenAIDeveloperRole) }
|
||||
: {}),
|
||||
};
|
||||
if (Object.prototype.hasOwnProperty.call(updates, "preserveOpenAIDeveloperRole")) {
|
||||
if (updates.preserveOpenAIDeveloperRole === null) {
|
||||
delete next.preserveOpenAIDeveloperRole;
|
||||
} else {
|
||||
next.preserveOpenAIDeveloperRole = Boolean(updates.preserveOpenAIDeveloperRole);
|
||||
}
|
||||
}
|
||||
if (updates.compatByProtocol !== undefined) {
|
||||
if (mergedCompat && compatByProtocolHasEntries(mergedCompat)) {
|
||||
next.compatByProtocol = mergedCompat;
|
||||
} else {
|
||||
delete next.compatByProtocol;
|
||||
}
|
||||
}
|
||||
|
||||
models[index] = next;
|
||||
|
||||
@@ -324,11 +423,33 @@ function getCustomModelRow(providerId: string, modelId: string): JsonRecord | nu
|
||||
/**
|
||||
* Whether the given provider/model has "normalize tool call id" (9-char Mistral-style) enabled.
|
||||
* Custom model row wins; otherwise {@link getModelCompatOverrides}.
|
||||
* When `sourceFormat` is one of `openai` | `openai-responses` | `claude`, per-protocol
|
||||
* `compatByProtocol[sourceFormat].normalizeToolCallId` overrides the legacy top-level flag.
|
||||
*/
|
||||
export function getModelNormalizeToolCallId(providerId: string, modelId: string): boolean {
|
||||
export function getModelNormalizeToolCallId(
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
sourceFormat?: string | null
|
||||
): boolean {
|
||||
const m = getCustomModelRow(providerId, modelId);
|
||||
if (m) return Boolean(m.normalizeToolCallId);
|
||||
const protocol = sourceFormat && isCompatProtocolKey(sourceFormat) ? sourceFormat : null;
|
||||
|
||||
if (m) {
|
||||
if (protocol) {
|
||||
const pc = (m.compatByProtocol as CompatByProtocolMap | undefined)?.[protocol];
|
||||
if (pc && Object.prototype.hasOwnProperty.call(pc, "normalizeToolCallId")) {
|
||||
return Boolean(pc.normalizeToolCallId);
|
||||
}
|
||||
}
|
||||
return Boolean(m.normalizeToolCallId);
|
||||
}
|
||||
const co = readCompatList(providerId).find((e) => e.id === modelId);
|
||||
if (protocol && co?.compatByProtocol?.[protocol]) {
|
||||
const pc = co.compatByProtocol[protocol]!;
|
||||
if (Object.prototype.hasOwnProperty.call(pc, "normalizeToolCallId")) {
|
||||
return Boolean(pc.normalizeToolCallId);
|
||||
}
|
||||
}
|
||||
return Boolean(co?.normalizeToolCallId);
|
||||
}
|
||||
|
||||
@@ -336,19 +457,35 @@ export function getModelNormalizeToolCallId(providerId: string, modelId: string)
|
||||
* Explicit preserve-openai-developer preference for this provider/model.
|
||||
* `undefined` = unset → routing keeps legacy default (preserve developer for OpenAI format).
|
||||
* `false` = map developer → system (e.g. MiniMax). `true` = keep developer.
|
||||
* Per-protocol overrides live under `compatByProtocol[sourceFormat]` when `sourceFormat` matches.
|
||||
*/
|
||||
export function getModelPreserveOpenAIDeveloperRole(
|
||||
providerId: string,
|
||||
modelId: string
|
||||
modelId: string,
|
||||
sourceFormat?: string | null
|
||||
): boolean | undefined {
|
||||
const m = getCustomModelRow(providerId, modelId);
|
||||
const protocol = sourceFormat && isCompatProtocolKey(sourceFormat) ? sourceFormat : null;
|
||||
|
||||
if (m) {
|
||||
if (protocol) {
|
||||
const pc = (m.compatByProtocol as CompatByProtocolMap | undefined)?.[protocol];
|
||||
if (pc && Object.prototype.hasOwnProperty.call(pc, "preserveOpenAIDeveloperRole")) {
|
||||
return Boolean(pc.preserveOpenAIDeveloperRole);
|
||||
}
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(m, "preserveOpenAIDeveloperRole")) {
|
||||
return Boolean(m.preserveOpenAIDeveloperRole);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const co = readCompatList(providerId).find((e) => e.id === modelId);
|
||||
if (protocol && co?.compatByProtocol?.[protocol]) {
|
||||
const pc = co.compatByProtocol[protocol]!;
|
||||
if (Object.prototype.hasOwnProperty.call(pc, "preserveOpenAIDeveloperRole")) {
|
||||
return Boolean(pc.preserveOpenAIDeveloperRole);
|
||||
}
|
||||
}
|
||||
if (co && Object.prototype.hasOwnProperty.call(co, "preserveOpenAIDeveloperRole")) {
|
||||
return Boolean(co.preserveOpenAIDeveloperRole);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { randomUUID } from "crypto";
|
||||
import { getDbInstance } from "./core";
|
||||
import { backupDbFile } from "./backup";
|
||||
|
||||
|
||||
+30
-18
@@ -12,21 +12,28 @@
|
||||
* @module lib/gracefulShutdown
|
||||
*/
|
||||
|
||||
/** Whether we are currently shutting down */
|
||||
let isShuttingDown = false;
|
||||
|
||||
/** Number of in-flight requests being tracked */
|
||||
let activeRequests = 0;
|
||||
|
||||
/** Grace period before forced exit (default 30s, configurable) */
|
||||
const SHUTDOWN_TIMEOUT_MS = parseInt(process.env.SHUTDOWN_TIMEOUT_MS || "30000", 10);
|
||||
|
||||
declare global {
|
||||
var __omnirouteShutdown:
|
||||
| { init: boolean; shuttingDown: boolean; activeRequests: number }
|
||||
| undefined;
|
||||
}
|
||||
|
||||
function getShutdownState() {
|
||||
if (!globalThis.__omnirouteShutdown) {
|
||||
globalThis.__omnirouteShutdown = { init: false, shuttingDown: false, activeRequests: 0 };
|
||||
}
|
||||
return globalThis.__omnirouteShutdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the server is currently shutting down.
|
||||
* Route handlers can use this to reject new requests.
|
||||
*/
|
||||
export function isDraining(): boolean {
|
||||
return isShuttingDown;
|
||||
return getShutdownState().shuttingDown;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,12 +41,13 @@ export function isDraining(): boolean {
|
||||
* Returns a done callback.
|
||||
*/
|
||||
export function trackRequest(): () => void {
|
||||
activeRequests++;
|
||||
const state = getShutdownState();
|
||||
state.activeRequests++;
|
||||
let called = false;
|
||||
return () => {
|
||||
if (!called) {
|
||||
called = true;
|
||||
activeRequests--;
|
||||
state.activeRequests--;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -48,19 +56,20 @@ export function trackRequest(): () => void {
|
||||
* Get current active request count (for monitoring/health endpoints).
|
||||
*/
|
||||
export function getActiveRequestCount(): number {
|
||||
return activeRequests;
|
||||
return getShutdownState().activeRequests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for all in-flight requests to complete, with timeout.
|
||||
*/
|
||||
async function waitForDrain(): Promise<void> {
|
||||
const state = getShutdownState();
|
||||
const start = Date.now();
|
||||
const CHECK_INTERVAL_MS = 250;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
if (activeRequests <= 0) {
|
||||
if (state.activeRequests <= 0) {
|
||||
console.log("[Shutdown] All in-flight requests drained.");
|
||||
resolve();
|
||||
return;
|
||||
@@ -68,13 +77,13 @@ async function waitForDrain(): Promise<void> {
|
||||
|
||||
if (Date.now() - start > SHUTDOWN_TIMEOUT_MS) {
|
||||
console.warn(
|
||||
`[Shutdown] Timeout after ${SHUTDOWN_TIMEOUT_MS}ms with ${activeRequests} active requests. Forcing exit.`
|
||||
`[Shutdown] Timeout after ${SHUTDOWN_TIMEOUT_MS}ms with ${state.activeRequests} active requests. Forcing exit.`
|
||||
);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Shutdown] Waiting for ${activeRequests} in-flight request(s)...`);
|
||||
console.log(`[Shutdown] Waiting for ${state.activeRequests} in-flight request(s)...`);
|
||||
setTimeout(check, CHECK_INTERVAL_MS);
|
||||
};
|
||||
|
||||
@@ -87,7 +96,6 @@ async function waitForDrain(): Promise<void> {
|
||||
*/
|
||||
async function cleanup(): Promise<void> {
|
||||
try {
|
||||
// Close SQLite database — import dynamically to avoid circular deps
|
||||
const { getDbInstance } = await import("@/lib/db/core");
|
||||
const db = getDbInstance();
|
||||
if (db && typeof db.close === "function") {
|
||||
@@ -104,11 +112,15 @@ async function cleanup(): Promise<void> {
|
||||
* Should be called once during server startup.
|
||||
*/
|
||||
export function initGracefulShutdown(): void {
|
||||
const shutdown = async (signal: string) => {
|
||||
if (isShuttingDown) return; // Prevent double-shutdown
|
||||
isShuttingDown = true;
|
||||
const state = getShutdownState();
|
||||
if (state.init) return;
|
||||
state.init = true;
|
||||
|
||||
console.log(`\n[Shutdown] Received ${signal}. Draining ${activeRequests} request(s)...`);
|
||||
const shutdown = async (signal: string) => {
|
||||
if (state.shuttingDown) return;
|
||||
state.shuttingDown = true;
|
||||
|
||||
console.log(`\n[Shutdown] Received ${signal}. Draining ${state.activeRequests} request(s)...`);
|
||||
|
||||
await waitForDrain();
|
||||
await cleanup();
|
||||
|
||||
@@ -48,6 +48,8 @@ export {
|
||||
getModelPreserveOpenAIDeveloperRole,
|
||||
} from "./db/models";
|
||||
|
||||
export type { ModelCompatPerProtocol, ModelCompatPatch } from "./db/models";
|
||||
|
||||
export {
|
||||
// Combos
|
||||
getCombos,
|
||||
|
||||
+42
-21
@@ -32,11 +32,32 @@ const CHECK_TIMEOUT_MS = 5_000;
|
||||
const INITIAL_DELAY_MS = 15_000; // Wait for server boot before first sweep
|
||||
const LOG_PREFIX = "[LocalHealthCheck]";
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────────────
|
||||
// ── State (globalThis survives HMR re-evaluation) ───────────────────────
|
||||
|
||||
const healthCache = new Map<string, HealthStatus>();
|
||||
let initialized = false;
|
||||
let sweepTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
declare global {
|
||||
var __omnirouteLocalHC:
|
||||
| {
|
||||
initialized: boolean;
|
||||
sweepTimer: ReturnType<typeof setTimeout> | null;
|
||||
healthCache: Map<string, HealthStatus>;
|
||||
sweepInProgress: boolean;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
|
||||
function getLHCState() {
|
||||
if (!globalThis.__omnirouteLocalHC) {
|
||||
globalThis.__omnirouteLocalHC = {
|
||||
initialized: false,
|
||||
sweepTimer: null,
|
||||
healthCache: new Map(),
|
||||
sweepInProgress: false,
|
||||
};
|
||||
}
|
||||
return globalThis.__omnirouteLocalHC;
|
||||
}
|
||||
|
||||
const healthCache = getLHCState().healthCache;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -101,12 +122,11 @@ async function checkNode(node: {
|
||||
}
|
||||
}
|
||||
|
||||
let sweepInProgress = false;
|
||||
|
||||
/** Single sweep: check all local provider_nodes in parallel. */
|
||||
export async function sweep(): Promise<void> {
|
||||
if (sweepInProgress) return; // Prevent concurrent sweeps
|
||||
sweepInProgress = true;
|
||||
const state = getLHCState();
|
||||
if (state.sweepInProgress) return;
|
||||
state.sweepInProgress = true;
|
||||
|
||||
try {
|
||||
let nodes: Array<{ id: string; prefix: string; baseUrl: string }>;
|
||||
@@ -149,15 +169,15 @@ export async function sweep(): Promise<void> {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
sweepInProgress = false;
|
||||
// Schedule next sweep with backoff based on worst-case failure count
|
||||
state.sweepInProgress = false;
|
||||
scheduleSweep();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSweep(): void {
|
||||
if (!initialized) return; // Don't schedule if stopped
|
||||
if (sweepTimer) clearTimeout(sweepTimer);
|
||||
const state = getLHCState();
|
||||
if (!state.initialized) return;
|
||||
if (state.sweepTimer) clearTimeout(state.sweepTimer);
|
||||
|
||||
// Use the maximum consecutive failures across all nodes to determine interval
|
||||
let maxFailures = 0;
|
||||
@@ -168,7 +188,7 @@ function scheduleSweep(): void {
|
||||
}
|
||||
|
||||
const interval = getNextInterval(maxFailures);
|
||||
sweepTimer = setTimeout(sweep, interval);
|
||||
state.sweepTimer = setTimeout(sweep, interval);
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────
|
||||
@@ -191,27 +211,28 @@ export function getAllHealthStatuses(): Record<string, HealthStatus> {
|
||||
|
||||
/** Start the health check scheduler (idempotent). */
|
||||
export function initLocalHealthCheck(): void {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
const state = getLHCState();
|
||||
if (state.initialized) return;
|
||||
state.initialized = true;
|
||||
|
||||
console.log(
|
||||
LOG_PREFIX,
|
||||
`Starting local provider health check (initial delay ${INITIAL_DELAY_MS / 1000}s)`
|
||||
);
|
||||
|
||||
// Delay first sweep to let the server finish booting
|
||||
sweepTimer = setTimeout(() => {
|
||||
state.sweepTimer = setTimeout(() => {
|
||||
sweep().catch((err) => console.error(LOG_PREFIX, "Initial sweep failed:", err));
|
||||
}, INITIAL_DELAY_MS);
|
||||
}
|
||||
|
||||
/** Stop the scheduler (for tests / hot-reload). */
|
||||
export function stopLocalHealthCheck(): void {
|
||||
if (sweepTimer) {
|
||||
clearTimeout(sweepTimer);
|
||||
sweepTimer = null;
|
||||
const state = getLHCState();
|
||||
if (state.sweepTimer) {
|
||||
clearTimeout(state.sweepTimer);
|
||||
state.sweepTimer = null;
|
||||
}
|
||||
initialized = false;
|
||||
state.initialized = false;
|
||||
}
|
||||
|
||||
// Auto-initialize on first import (same pattern as tokenHealthCheck.ts:272)
|
||||
|
||||
+23
-11
@@ -99,23 +99,34 @@ export function clearHealthCheckLogCache() {
|
||||
cacheTimestamp = 0;
|
||||
}
|
||||
|
||||
// ── Singleton guard ──────────────────────────────────────────────────────────
|
||||
let initialized = false;
|
||||
let intervalHandle = null;
|
||||
// ── Singleton guard (globalThis survives HMR re-evaluation) ─────────────────
|
||||
|
||||
declare global {
|
||||
var __omnirouteTokenHC:
|
||||
| { initialized: boolean; interval: ReturnType<typeof setInterval> | null }
|
||||
| undefined;
|
||||
}
|
||||
|
||||
function getHCState() {
|
||||
if (!globalThis.__omnirouteTokenHC) {
|
||||
globalThis.__omnirouteTokenHC = { initialized: false, interval: null };
|
||||
}
|
||||
return globalThis.__omnirouteTokenHC;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the health-check scheduler (idempotent).
|
||||
*/
|
||||
export function initTokenHealthCheck() {
|
||||
if (initialized || isHealthCheckDisabled()) return;
|
||||
initialized = true;
|
||||
const state = getHCState();
|
||||
if (state.initialized || isHealthCheckDisabled()) return;
|
||||
state.initialized = true;
|
||||
|
||||
log(`${LOG_PREFIX} Starting proactive token health-check (tick every ${TICK_MS / 1000}s)`);
|
||||
|
||||
// Run first sweep after a short delay so the server finishes booting
|
||||
setTimeout(() => {
|
||||
sweep();
|
||||
intervalHandle = setInterval(sweep, TICK_MS);
|
||||
state.interval = setInterval(sweep, TICK_MS);
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
@@ -123,11 +134,12 @@ export function initTokenHealthCheck() {
|
||||
* Stop the scheduler (useful for tests / hot-reload).
|
||||
*/
|
||||
export function stopTokenHealthCheck() {
|
||||
if (intervalHandle) {
|
||||
clearInterval(intervalHandle);
|
||||
intervalHandle = null;
|
||||
const state = getHCState();
|
||||
if (state.interval) {
|
||||
clearInterval(state.interval);
|
||||
state.interval = null;
|
||||
}
|
||||
initialized = false;
|
||||
state.initialized = false;
|
||||
}
|
||||
|
||||
// ── Core sweep ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Model compatibility protocol keys — shared between client UI and server.
|
||||
* Must not import Node or DB code so client components can import safely.
|
||||
*/
|
||||
|
||||
/** Client request shapes from detectFormat — compat options apply when the client uses this protocol */
|
||||
export const MODEL_COMPAT_PROTOCOL_KEYS = ["openai", "openai-responses", "claude"] as const;
|
||||
|
||||
export type ModelCompatProtocolKey = (typeof MODEL_COMPAT_PROTOCOL_KEYS)[number];
|
||||
@@ -490,6 +490,16 @@ export const APIKEY_PROVIDERS = {
|
||||
website: "https://tavily.com",
|
||||
authHint: "API key from app.tavily.com (format: tvly-...)",
|
||||
},
|
||||
alibaba: {
|
||||
id: "alibaba",
|
||||
alias: "ali",
|
||||
name: "Alibaba Cloud (DashScope)",
|
||||
icon: "cloud_queue",
|
||||
color: "#FF6600",
|
||||
textIcon: "AL",
|
||||
website: "https://dashscope-intl.aliyuncs.com",
|
||||
hasFree: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface ApiKeyMetadata {
|
||||
usedBudget?: number;
|
||||
isActive?: boolean;
|
||||
accessSchedule?: AccessSchedule | null;
|
||||
maxRequestsPerDay?: number | null;
|
||||
maxRequestsPerMinute?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,6 +105,65 @@ function isWithinSchedule(schedule: AccessSchedule): boolean {
|
||||
return localMinutes >= fromMinutes && localMinutes < untilMinutes;
|
||||
}
|
||||
|
||||
// ── In-memory request counter for per-key rate limits (#452) ──
|
||||
|
||||
/** Sliding-window request timestamps per API key */
|
||||
const _requestTimestamps = new Map<string, number[]>();
|
||||
const REQUEST_COUNTER_MAX_KEYS = 5000;
|
||||
const REQUEST_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const REQUEST_MINUTE_MS = 60 * 1000;
|
||||
|
||||
/** Record a request and check per-key limits. Returns null if OK, or an error message. */
|
||||
function checkRequestCountLimits(
|
||||
apiKeyId: string,
|
||||
maxPerDay: number | null | undefined,
|
||||
maxPerMinute: number | null | undefined
|
||||
): string | null {
|
||||
if (!maxPerDay && !maxPerMinute) return null;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Get or create timestamp array for this key
|
||||
let timestamps = _requestTimestamps.get(apiKeyId);
|
||||
if (!timestamps) {
|
||||
timestamps = [];
|
||||
_requestTimestamps.set(apiKeyId, timestamps);
|
||||
// Prevent unbounded growth
|
||||
if (_requestTimestamps.size > REQUEST_COUNTER_MAX_KEYS) {
|
||||
const firstKey = _requestTimestamps.keys().next().value;
|
||||
if (firstKey) _requestTimestamps.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Prune timestamps older than 24h
|
||||
const dayAgo = now - REQUEST_DAY_MS;
|
||||
while (timestamps.length > 0 && timestamps[0] < dayAgo) {
|
||||
timestamps.shift();
|
||||
}
|
||||
|
||||
// Check per-minute limit (before recording this request)
|
||||
if (maxPerMinute && maxPerMinute > 0) {
|
||||
const minuteAgo = now - REQUEST_MINUTE_MS;
|
||||
const recentCount = timestamps.filter((t) => t >= minuteAgo).length;
|
||||
if (recentCount >= maxPerMinute) {
|
||||
return `Per-minute request limit exceeded (${maxPerMinute} RPM). Try again in a few seconds.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Check per-day limit
|
||||
if (maxPerDay && maxPerDay > 0) {
|
||||
if (timestamps.length >= maxPerDay) {
|
||||
return `Daily request limit exceeded (${maxPerDay} RPD). Resets in ${Math.ceil(
|
||||
(timestamps[0] + REQUEST_DAY_MS - now) / 60000
|
||||
)} minutes.`;
|
||||
}
|
||||
}
|
||||
|
||||
// All checks passed — record this request
|
||||
timestamps.push(now);
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ApiKeyPolicyResult {
|
||||
/** API key string (null if no key provided) */
|
||||
apiKey: string | null;
|
||||
@@ -224,5 +285,21 @@ export async function enforceApiKeyPolicy(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 5: Request-count limits (#452) ──
|
||||
if (apiKeyInfo.id && (apiKeyInfo.maxRequestsPerDay || apiKeyInfo.maxRequestsPerMinute)) {
|
||||
const limitError = checkRequestCountLimits(
|
||||
apiKeyInfo.id,
|
||||
apiKeyInfo.maxRequestsPerDay,
|
||||
apiKeyInfo.maxRequestsPerMinute
|
||||
);
|
||||
if (limitError) {
|
||||
return {
|
||||
apiKey,
|
||||
apiKeyInfo,
|
||||
rejection: errorResponse(HTTP_STATUS.RATE_LIMITED, limitError),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { apiKey, apiKeyInfo, rejection: null };
|
||||
}
|
||||
|
||||
@@ -1,17 +1,99 @@
|
||||
import { machineIdSync } from "node-machine-id";
|
||||
import { execSync, execFileSync } from "child_process";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
|
||||
/**
|
||||
* Get consistent machine ID using node-machine-id with salt
|
||||
* Get raw machine ID using OS-specific methods.
|
||||
*
|
||||
* IMPORTANT: We do NOT use `if (process.platform === ...)` branching here.
|
||||
* Next.js SWC bundler evaluates `process.platform` at BUILD time, so when the
|
||||
* project is built on Linux, the win32/darwin branches get dead-code-eliminated
|
||||
* and the Linux fallback (which uses `head`) runs on Windows at runtime.
|
||||
*
|
||||
* Instead, we use a try/catch waterfall: try each OS method and fall through
|
||||
* to the next on failure. The correct method always succeeds on the target OS.
|
||||
*/
|
||||
function getMachineIdRaw(): string {
|
||||
// Strategy 1: Windows — REG.exe query for MachineGuid
|
||||
try {
|
||||
const sysRoot = process.env.SystemRoot || process.env.windir || "C:\\Windows";
|
||||
const regPath = `${sysRoot}\\System32\\REG.exe`;
|
||||
if (existsSync(regPath)) {
|
||||
const output = execFileSync(
|
||||
regPath,
|
||||
["QUERY", "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"],
|
||||
{ encoding: "utf8", timeout: 5000 }
|
||||
);
|
||||
const id = output
|
||||
.split("REG_SZ")[1]
|
||||
?.replace(/\r+|\n+|\s+/gi, "")
|
||||
?.toLowerCase();
|
||||
if (id && id.length > 8) return id;
|
||||
}
|
||||
} catch {
|
||||
// Not Windows or REG.exe failed — continue
|
||||
}
|
||||
|
||||
// Strategy 2: macOS — ioreg IOPlatformUUID
|
||||
try {
|
||||
const output = execSync("ioreg -rd1 -c IOPlatformExpertDevice", {
|
||||
encoding: "utf8",
|
||||
timeout: 5000,
|
||||
});
|
||||
if (output.includes("IOPlatformUUID")) {
|
||||
const id = output
|
||||
.split("IOPlatformUUID")[1]
|
||||
?.split("\n")[0]
|
||||
?.replace(/=|\s+|"/gi, "")
|
||||
?.toLowerCase();
|
||||
if (id && id.length > 8) return id;
|
||||
}
|
||||
} catch {
|
||||
// Not macOS or ioreg not available — continue
|
||||
}
|
||||
|
||||
// Strategy 3: Linux — read machine-id files directly (no `head` or pipe)
|
||||
try {
|
||||
for (const filePath of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
||||
if (existsSync(filePath)) {
|
||||
const content = readFileSync(filePath, "utf8").trim().toLowerCase();
|
||||
if (content.length > 8) return content;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Files not readable — continue
|
||||
}
|
||||
|
||||
// Strategy 4: Hostname fallback (works on all platforms)
|
||||
try {
|
||||
const hostname = execSync("hostname", { encoding: "utf8", timeout: 5000 });
|
||||
const id = hostname.trim().toLowerCase();
|
||||
if (id) return id;
|
||||
} catch {
|
||||
// hostname failed — continue
|
||||
}
|
||||
|
||||
// Strategy 5: Node.js os.hostname() (no exec needed)
|
||||
try {
|
||||
const os = require("os");
|
||||
return os.hostname().toLowerCase();
|
||||
} catch {
|
||||
// Final fallback
|
||||
}
|
||||
|
||||
return "unknown-machine";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get consistent machine ID using native registry/OS query with salt
|
||||
* This ensures the same physical machine gets the same ID across runs
|
||||
*
|
||||
* @param {string} salt - Optional salt to use (defaults to environment variable)
|
||||
* @returns {Promise<string>} Machine ID (16-character base32)
|
||||
*/
|
||||
export async function getConsistentMachineId(salt = null) {
|
||||
// For server-side, use node-machine-id with salt
|
||||
const saltValue = salt || process.env.MACHINE_ID_SALT || "endpoint-proxy-salt";
|
||||
try {
|
||||
const rawMachineId = machineIdSync();
|
||||
const rawMachineId = getMachineIdRaw();
|
||||
// Create consistent ID using salt
|
||||
const crypto = await import("crypto");
|
||||
const hashedMachineId = crypto
|
||||
@@ -41,9 +123,8 @@ export async function getConsistentMachineId(salt = null) {
|
||||
* @returns {Promise<string>} Raw machine ID
|
||||
*/
|
||||
export async function getRawMachineId() {
|
||||
// For server-side, use raw node-machine-id
|
||||
try {
|
||||
return machineIdSync();
|
||||
return getMachineIdRaw();
|
||||
} catch (error) {
|
||||
console.log("Error getting raw machine ID:", error);
|
||||
// Fallback to random ID if node-machine-id fails
|
||||
|
||||
@@ -340,6 +340,13 @@ export const clearModelAvailabilitySchema = z.object({
|
||||
model: modelIdSchema,
|
||||
});
|
||||
|
||||
const modelCompatPerProtocolSchema = z
|
||||
.object({
|
||||
normalizeToolCallId: z.boolean().optional(),
|
||||
preserveOpenAIDeveloperRole: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const providerModelMutationSchema = z.object({
|
||||
provider: z.string().trim().min(1, "provider is required").max(120),
|
||||
modelId: z.string().trim().min(1, "modelId is required").max(240),
|
||||
@@ -348,7 +355,10 @@ export const providerModelMutationSchema = z.object({
|
||||
apiFormat: z.enum(["chat-completions", "responses"]).default("chat-completions"),
|
||||
supportedEndpoints: z.array(z.enum(["chat", "embeddings", "images", "audio"])).default(["chat"]),
|
||||
normalizeToolCallId: z.boolean().optional(),
|
||||
preserveOpenAIDeveloperRole: z.boolean().optional(),
|
||||
preserveOpenAIDeveloperRole: z.boolean().nullable().optional(),
|
||||
compatByProtocol: z
|
||||
.record(z.enum(["openai", "openai-responses", "claude"]), modelCompatPerProtocolSchema)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const pricingFieldsSchema = z
|
||||
|
||||
@@ -125,7 +125,6 @@ test.describe("Bailian Coding Plan Provider", () => {
|
||||
});
|
||||
|
||||
test("invalid URL blocks save with validation error", async ({ page }) => {
|
||||
let validationErrorCaptured = false;
|
||||
let createAttempted = false;
|
||||
|
||||
await page.route("**/api/providers", async (route) => {
|
||||
@@ -227,24 +226,25 @@ test.describe("Bailian Coding Plan Provider", () => {
|
||||
.last();
|
||||
await saveButton.click();
|
||||
|
||||
const errorLocator = page
|
||||
.locator("text=/invalid.*url|url.*invalid|must be a valid url/i")
|
||||
.or(
|
||||
page
|
||||
.locator(".text-red-500")
|
||||
.or(page.locator('[class*="error"]').or(page.locator('[class*="text-destructive"]')))
|
||||
);
|
||||
|
||||
// Wait for React to process the validation and re-render
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const errorVisible = await errorLocator.isVisible({ timeout: 5000 }).catch(() => false);
|
||||
// Check for the validation error scoped to the dialog to avoid strict-mode
|
||||
// violations from broad selectors matching unrelated page elements.
|
||||
const errorTextLocator = dialog
|
||||
.locator("text=/invalid.*url|url.*invalid|must be a valid url|must use http/i")
|
||||
.first();
|
||||
const errorClassLocator = dialog.locator(".text-red-500").first();
|
||||
|
||||
let errorVisible =
|
||||
(await errorTextLocator.isVisible().catch(() => false)) ||
|
||||
(await errorClassLocator.isVisible().catch(() => false));
|
||||
|
||||
if (!errorVisible) {
|
||||
// Fallback: if the dialog stays open after clicking save, it means the
|
||||
// client-side validation prevented submission (which is the desired behavior).
|
||||
await page.waitForTimeout(2000);
|
||||
const modalStillOpen = await dialog.isVisible();
|
||||
if (modalStillOpen) {
|
||||
validationErrorCaptured = true;
|
||||
}
|
||||
errorVisible = await dialog.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
expect(errorVisible).toBe(true);
|
||||
|
||||
@@ -44,6 +44,7 @@ function withTempEnv(fn) {
|
||||
|
||||
test("bootstrapEnv prefers ~/.omniroute/.env over server.env", () => {
|
||||
withTempEnv(({ dataDir }) => {
|
||||
process.env.DATA_DIR = dataDir;
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dataDir, ".env"),
|
||||
@@ -65,6 +66,7 @@ test("bootstrapEnv prefers ~/.omniroute/.env over server.env", () => {
|
||||
|
||||
test("bootstrapEnv refuses to generate a new key over encrypted data", () => {
|
||||
withTempEnv(({ dataDir }) => {
|
||||
process.env.DATA_DIR = dataDir;
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
const db = new Database(path.join(dataDir, "storage.sqlite"));
|
||||
try {
|
||||
@@ -77,8 +79,10 @@ test("bootstrapEnv refuses to generate a new key over encrypted data", () => {
|
||||
id_token TEXT
|
||||
);
|
||||
`);
|
||||
db.prepare("INSERT INTO provider_connections (id, access_token) VALUES (?, ?)")
|
||||
.run("conn-1", "enc:v1:deadbeef:feedface:cafebabe");
|
||||
db.prepare("INSERT INTO provider_connections (id, access_token) VALUES (?, ?)").run(
|
||||
"conn-1",
|
||||
"enc:v1:deadbeef:feedface:cafebabe"
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
@@ -92,17 +96,16 @@ test("bootstrapEnv refuses to generate a new key over encrypted data", () => {
|
||||
|
||||
test("bootstrapEnv fails closed when existing database cannot be inspected", () => {
|
||||
withTempEnv(({ dataDir }) => {
|
||||
process.env.DATA_DIR = dataDir;
|
||||
fs.mkdirSync(path.join(dataDir, "storage.sqlite"), { recursive: true });
|
||||
|
||||
assert.throws(
|
||||
() => bootstrapEnv({ quiet: true }),
|
||||
/Unable to inspect existing database/
|
||||
);
|
||||
assert.throws(() => bootstrapEnv({ quiet: true }), /Unable to inspect existing database/);
|
||||
});
|
||||
});
|
||||
|
||||
test("bootstrapEnv ignores blank dataDirOverride values", () => {
|
||||
withTempEnv(({ dataDir }) => {
|
||||
process.env.DATA_DIR = dataDir;
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dataDir, ".env"), "JWT_SECRET=jwt-from-dot-env\n", "utf8");
|
||||
|
||||
|
||||
@@ -1,26 +1,51 @@
|
||||
import { describe, it, beforeEach, afterEach } from "node:test";
|
||||
import { describe, it, beforeEach, afterEach, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import Database from "better-sqlite3";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
|
||||
// ─── Test Setup: Use temp DB ────────────────────────
|
||||
function assertAlmostEqual(actual, expected, epsilon = 1e-9, message = "") {
|
||||
assert.ok(
|
||||
Math.abs(actual - expected) <= epsilon,
|
||||
message || `expected ${actual} to be within ${epsilon} of ${expected}`
|
||||
);
|
||||
}
|
||||
|
||||
let tmpDir;
|
||||
let originalEnv;
|
||||
// ─── Test Setup: Single temp dir for whole file (core caches DATA_DIR at first import) ────────
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-domain-test-"));
|
||||
originalEnv = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = tmpDir;
|
||||
const fileTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-domain-test-"));
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = fileTmpDir;
|
||||
|
||||
async function removeStorageFiles(dir) {
|
||||
const storage = path.join(dir, "storage.sqlite");
|
||||
try {
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
core.resetDbInstance();
|
||||
} catch {
|
||||
/* core may not be loaded yet */
|
||||
}
|
||||
for (const suffix of ["", "-wal", "-shm", "-journal"]) {
|
||||
const p = storage + suffix;
|
||||
try {
|
||||
if (fs.existsSync(p)) fs.unlinkSync(p);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await removeStorageFiles(fileTmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.DATA_DIR = originalEnv;
|
||||
if (tmpDir && fs.existsSync(tmpDir)) {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
afterEach(async () => {
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
core.resetDbInstance();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
process.env.DATA_DIR = originalDataDir;
|
||||
if (fs.existsSync(fileTmpDir)) fs.rmSync(fileTmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── Fallback Policy Tests ────────────────────────
|
||||
@@ -151,7 +176,7 @@ describe("costRules persistence", () => {
|
||||
recordCost("key2", 1.0);
|
||||
|
||||
const total = getDailyTotal("key2");
|
||||
assert.ok(total >= 4.5);
|
||||
assertAlmostEqual(total, 4.5, 1e-9, `daily total ${total} should equal 4.5 (3.5 + 1.0)`);
|
||||
|
||||
// Should still be allowed
|
||||
const check = checkBudget("key2", 0);
|
||||
@@ -187,8 +212,18 @@ describe("costRules persistence", () => {
|
||||
recordCost("key3", 2.5);
|
||||
|
||||
const summary = getCostSummary("key3");
|
||||
assert.ok(summary.dailyTotal >= 4.0);
|
||||
assert.ok(summary.monthlyTotal >= 4.0);
|
||||
assertAlmostEqual(
|
||||
summary.dailyTotal,
|
||||
4.0,
|
||||
1e-9,
|
||||
`dailyTotal ${summary.dailyTotal} should equal 4.0 (1.5 + 2.5)`
|
||||
);
|
||||
assertAlmostEqual(
|
||||
summary.monthlyTotal,
|
||||
4.0,
|
||||
1e-9,
|
||||
`monthlyTotal ${summary.monthlyTotal} should equal 4.0`
|
||||
);
|
||||
assert.equal(summary.budget.dailyLimitUsd, 100);
|
||||
|
||||
resetCostData();
|
||||
|
||||
@@ -4,6 +4,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const isWindows = process.platform === "win32";
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-fixes-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
@@ -39,7 +40,20 @@ async function withEnv(name, value, fn) {
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
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 (err) {
|
||||
if ((err?.code === "EBUSY" || err?.code === "EPERM") && attempt < 9) {
|
||||
await new Promise((r) => setTimeout(r, 100 * (attempt + 1)));
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -87,38 +101,88 @@ test("token refresh dedupe key avoids collision for same-prefix tokens", async (
|
||||
}
|
||||
});
|
||||
|
||||
test("restoreDbBackup clears stale sqlite sidecars before reopen", async () => {
|
||||
await resetStorage();
|
||||
test(
|
||||
"restoreDbBackup clears stale sqlite sidecars before reopen",
|
||||
{ skip: isWindows },
|
||||
async () => {
|
||||
await resetStorage();
|
||||
|
||||
const db = core.getDbInstance();
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(
|
||||
"INSERT INTO provider_connections (id, provider, auth_type, name, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
).run("restore-test-conn", "openai", "apikey", "restore-test", 1, now, now);
|
||||
const db = core.getDbInstance();
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(
|
||||
"INSERT INTO provider_connections (id, provider, auth_type, name, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
).run("restore-test-conn", "openai", "apikey", "restore-test", 1, now, now);
|
||||
|
||||
const backupId = "db_2000-01-01T00-00-00-000Z_manual.sqlite";
|
||||
const backupPath = path.join(core.DB_BACKUPS_DIR, backupId);
|
||||
fs.mkdirSync(core.DB_BACKUPS_DIR, { recursive: true });
|
||||
await db.backup(backupPath);
|
||||
const backupId = "db_2000-01-01T00-00-00-000Z_manual.sqlite";
|
||||
const backupPath = path.join(core.DB_BACKUPS_DIR, backupId);
|
||||
fs.mkdirSync(core.DB_BACKUPS_DIR, { recursive: true });
|
||||
await db.backup(backupPath);
|
||||
|
||||
fs.writeFileSync(`${core.SQLITE_FILE}-wal`, "STALE-WAL-MARKER");
|
||||
fs.writeFileSync(`${core.SQLITE_FILE}-shm`, "STALE-SHM-MARKER");
|
||||
fs.writeFileSync(`${core.SQLITE_FILE}-journal`, "STALE-JOURNAL-MARKER");
|
||||
core.resetDbInstance();
|
||||
fs.writeFileSync(`${core.SQLITE_FILE}-wal`, "STALE-WAL-MARKER");
|
||||
fs.writeFileSync(`${core.SQLITE_FILE}-shm`, "STALE-SHM-MARKER");
|
||||
fs.writeFileSync(`${core.SQLITE_FILE}-journal`, "STALE-JOURNAL-MARKER");
|
||||
|
||||
await backupDb.restoreDbBackup(backupId);
|
||||
await backupDb.restoreDbBackup(backupId);
|
||||
|
||||
for (const suffix of ["-wal", "-shm", "-journal"]) {
|
||||
const sidecarPath = `${core.SQLITE_FILE}${suffix}`;
|
||||
if (!fs.existsSync(sidecarPath)) continue;
|
||||
const text = fs.readFileSync(sidecarPath, "utf8");
|
||||
assert.equal(text.includes("STALE-"), false, `sidecar ${suffix} still contains stale marker`);
|
||||
for (const suffix of ["-wal", "-shm", "-journal"]) {
|
||||
const sidecarPath = `${core.SQLITE_FILE}${suffix}`;
|
||||
if (!fs.existsSync(sidecarPath)) continue;
|
||||
const text = fs.readFileSync(sidecarPath, "utf8");
|
||||
assert.equal(text.includes("STALE-"), false, `sidecar ${suffix} still contains stale marker`);
|
||||
}
|
||||
|
||||
const reopenedDb = core.getDbInstance();
|
||||
const row = reopenedDb
|
||||
.prepare("SELECT COUNT(*) AS cnt FROM provider_connections WHERE id = ?")
|
||||
.get("restore-test-conn");
|
||||
assert.equal(row.cnt, 1);
|
||||
}
|
||||
);
|
||||
|
||||
const reopenedDb = core.getDbInstance();
|
||||
const row = reopenedDb
|
||||
.prepare("SELECT COUNT(*) AS cnt FROM provider_connections WHERE id = ?")
|
||||
.get("restore-test-conn");
|
||||
assert.equal(row.cnt, 1);
|
||||
test("unlinkFileWithRetry retries EBUSY/EPERM and eventually succeeds", async () => {
|
||||
const target = path.join(TEST_DATA_DIR, "retry-target.tmp");
|
||||
fs.writeFileSync(target, "retry-me");
|
||||
|
||||
const originalExistsSync = fs.existsSync;
|
||||
const originalUnlinkSync = fs.unlinkSync;
|
||||
const seenCodes = [];
|
||||
let attempts = 0;
|
||||
|
||||
fs.existsSync = (filePath) => {
|
||||
if (filePath === target) return attempts < 3 || originalExistsSync(filePath);
|
||||
return originalExistsSync(filePath);
|
||||
};
|
||||
|
||||
fs.unlinkSync = (filePath) => {
|
||||
if (filePath === target) {
|
||||
attempts++;
|
||||
if (attempts === 1) {
|
||||
const err = new Error("busy");
|
||||
err.code = "EBUSY";
|
||||
seenCodes.push(err.code);
|
||||
throw err;
|
||||
}
|
||||
if (attempts === 2) {
|
||||
const err = new Error("perm");
|
||||
err.code = "EPERM";
|
||||
seenCodes.push(err.code);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return originalUnlinkSync(filePath);
|
||||
};
|
||||
|
||||
try {
|
||||
await backupDb.unlinkFileWithRetry(target, { maxAttempts: 5, baseDelayMs: 1 });
|
||||
assert.equal(attempts, 3);
|
||||
assert.deepEqual(seenCodes, ["EBUSY", "EPERM"]);
|
||||
assert.equal(fs.existsSync(target), false);
|
||||
} finally {
|
||||
fs.existsSync = originalExistsSync;
|
||||
fs.unlinkSync = originalUnlinkSync;
|
||||
if (originalExistsSync(target)) originalUnlinkSync(target);
|
||||
}
|
||||
});
|
||||
|
||||
test("provider connection persists rateLimitProtection across reopen", async () => {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Unit tests for PR #397 — Anthropic-format tools filter fix (#346)
|
||||
*
|
||||
* Verifies that tools arriving in Anthropic format (`tool.name` without `.function`)
|
||||
* are NOT dropped by the empty-name filter in chatCore.ts.
|
||||
* Before the fix, ALL anthropic-format tools were silently dropped, causing
|
||||
* `400: tool_choice.any may only be specified while providing tools` from Anthropic.
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Inline the filter logic from chatCore.ts (lines 225-231 after #397 fix)
|
||||
function filterEmptyNameTools(tools) {
|
||||
return tools.filter((tool) => {
|
||||
const fn = tool.function;
|
||||
const name = fn?.name ?? tool.name;
|
||||
return name && String(name).trim().length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
describe("tools empty-name filter — #346 / PR #397", () => {
|
||||
it("should keep tools with valid OpenAI format name (tool.function.name)", () => {
|
||||
const tools = [
|
||||
{ type: "function", function: { name: "get_weather", description: "Get weather" } },
|
||||
];
|
||||
assert.equal(filterEmptyNameTools(tools).length, 1);
|
||||
});
|
||||
|
||||
it("should keep tools with valid Anthropic format name (tool.name)", () => {
|
||||
const tools = [
|
||||
{ name: "get_weather", description: "Get weather", input_schema: { type: "object" } },
|
||||
];
|
||||
assert.equal(filterEmptyNameTools(tools).length, 1);
|
||||
});
|
||||
|
||||
it("should drop tools with empty OpenAI format name (tool.function.name = '')", () => {
|
||||
const tools = [{ type: "function", function: { name: "" } }];
|
||||
assert.equal(filterEmptyNameTools(tools).length, 0);
|
||||
});
|
||||
|
||||
it("should drop tools with empty Anthropic format name (tool.name = '')", () => {
|
||||
const tools = [{ name: "", description: "Ghost tool", input_schema: { type: "object" } }];
|
||||
assert.equal(filterEmptyNameTools(tools).length, 0);
|
||||
});
|
||||
|
||||
it("should NOT drop Anthropic-format tools when function wrapper is absent (regression for PR #397)", () => {
|
||||
// Before fix: fn was undefined, fn?.name was undefined, filter returned false → ALL tools dropped
|
||||
// After fix: fn?.name ?? tool.name → falls back to tool.name → keeps valid tools
|
||||
const tools = [
|
||||
{
|
||||
name: "search",
|
||||
description: "Search the web",
|
||||
input_schema: { type: "object", properties: {} },
|
||||
},
|
||||
{ name: "code_exec", description: "Execute code", input_schema: { type: "object" } },
|
||||
];
|
||||
const result = filterEmptyNameTools(tools);
|
||||
assert.equal(result.length, 2, "Both anthropic-format tools should be preserved");
|
||||
});
|
||||
|
||||
it("should handle mixed format tools in the same array", () => {
|
||||
const tools = [
|
||||
{ type: "function", function: { name: "openai_tool" } }, // OpenAI format
|
||||
{ name: "anthropic_tool", input_schema: { type: "object" } }, // Anthropic format
|
||||
{ type: "function", function: { name: "" } }, // Empty OpenAI — should be dropped
|
||||
{ name: "", input_schema: { type: "object" } }, // Empty Anthropic — should be dropped
|
||||
];
|
||||
const result = filterEmptyNameTools(tools);
|
||||
assert.equal(result.length, 2, "Should keep 2 valid tools (one of each format)");
|
||||
assert.ok(
|
||||
result.some((t) => t.function?.name === "openai_tool"),
|
||||
"OpenAI tool preserved"
|
||||
);
|
||||
assert.ok(
|
||||
result.some((t) => t.name === "anthropic_tool"),
|
||||
"Anthropic tool preserved"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle tools with whitespace-only names", () => {
|
||||
const tools = [{ name: " ", input_schema: { type: "object" } }];
|
||||
assert.equal(filterEmptyNameTools(tools).length, 0);
|
||||
});
|
||||
|
||||
it("should handle null/undefined tool.name gracefully", () => {
|
||||
const tools = [
|
||||
{ input_schema: { type: "object" } }, // Neither name nor function
|
||||
{ name: null, input_schema: { type: "object" } },
|
||||
];
|
||||
assert.equal(filterEmptyNameTools(tools).length, 0);
|
||||
});
|
||||
});
|
||||
+2
-1
@@ -34,7 +34,8 @@
|
||||
"**/*.js",
|
||||
"**/*.jsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
".next/dev/types/**/*.ts",
|
||||
".next/dev/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
|
||||
Reference in New Issue
Block a user